ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 8-1. 클래스 계층 이해하기 : 상속
    Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 11. 5. 22:40

    8장

    8-1. 클래스 계층 이해하기 : 상속

    8-2. 클래스 계층 이해하기 : 추상 클래스와 인터페이스


    7장은 양이 좀 많았는데 8장은 적은 분량은 아닌 것 같은데 7장에 비하면 확실히 나은 것 같다

    8장에서는 4, 6장에서 배운 코틀린의 객체지향 측면을 계속 다룬다.

    클래스 상속 개념 / 하위 클래서 정의 방법, 추상 클래스 / 인터페이스 / 클래스 위임을 통해

    복잡한 클래스 계층 구조를 설계하는 방법에 대한 내용이 있다. 

     

    8-1. 상속

     

    도메인 개념에 있는 is-a 관계(자동차는 교통수단이다와 같은 A는 B에 포함된다는 관계)를 표현하기 위해 대부분의

    객체지향 언어는 상속이라는 개념을 사용한다.

    클래스 A가 (하위(subclass) / 파생(derived) 클래스라고 부른다)

    클래스 B(상위(superclass) 클래스나 기반(base) 클래스라고 부른다)를 상속하면, 

    A의 모든 인스턴스는 자동으로 B의 인스턴스로 간주된다.

    이로 인해 클래스A는 클래스B에 정의된 모든 멤버와 확장을 자동으로 얻는다.

    이 관계는 추이적(transitive)이라고 하는데, 이는 클래스 B가 어떤 클래스 C를 상속하면 

    클래스 A는 클래스 C의 (간접적인) 하위 클래스가 된다는 의미이다.

     

    자바와 마찬가지로 코틀린 클래스는 단일 상속만을 지원한다.

    즉 어떤 클래스의 상위 클래스가 최대 하나뿐이라는 뜻 이다.

    어떤 클래스의 상위 클래스를 명시하지 않으면

    컴파일러는 자동으로 이 클래스가 냊아 클래스인 Any를 상속하는 것으로 가정한다.

    따라서 프로그램의 모든 클래스는 잘 정의된 상속 트리를 구성하게 되며

    이 트리를 클래스 계층(class hierarchy)라고 부른다.

     

    1) 하위 클래스 선언

    어떤 클래스를 상속하려면, 클래스를 정의하면서 주생성자 뒤에 : 을 넣고

    그 뒤에 상위 클래스가 될 클래스의 이름을 넣으면 된다.

    그리고 부모 클래스에 대해 open 키워드를 사용하는데,

    이 open 변경자는 해당 클래스가 상속에 대해 열려 있다는 뜻 이다

    (open 변경자가 붙은 클래스는 부모 클래스, 상위 클래스가 될 수 있다)

    open class Vehicle{
        var curSpeed = 0
    
        fun start(){
            println("moving..")
        }
    
        fun stop(){
            println("stopped!")
        }
    }
    
    open class FlyingVehicle : Vehicle(){
        fun takeOff(){
            println("Taking off")
        }
    
        fun land(){
            println("Landed")
        }
    }
    
    class Aircraft(val seats: Int) : FlyingVehicle()

    반면 Aircraft는 open 변경자가 없으므로 상속이 불가능하다.(자바에서는 final 클래스로 간주된다)이 경우 Aircraft를 상속하면 컴파일러가 오류를 발생시킨다.

     

    Vehicle과 FlyingVehicle의 하위 클래스를 정의하면서 두 클래스의 이름 뒤에 괄호가 있는데,이는 상위 클래스 생성자를 호출하기 위해서이다.상위 클래스 초기화에 필요한 인자가 있따면, 이 괄호 사이에 넣으면 된다.

    open class FlyingVehicle(a: Int) : Vehicle(){
        fun takeOff(){
            println("Taking off")
        }
    
        fun land(){
            println("Landed")
        }
    }
    
    class Aircraft(val seats: Int) : FlyingVehicle(a = seats)

    자바와 코틀린 클래스의 디폴트 동작 차이(상속에 대해 열려 있는지 여부)에 조심해야 한다.

    코틀린의 경우 디폴트 동작은(open 키워드를 작성하지 않은 경우) 상속이 불가능하다.

    open 키워드를 붙여서 선언해야만 어떤 클래스를 상속할 수 있다.

    (즉 코틀린의 디폴트는 자바의 final 키워드를 붙인 클래스이다.)

    자바에서는 모들 클래스가 디폴트로 상속에 대해 열려 있으며 상속을 금지하려면 final을 명시해야 한다.

     

    자바에서는 설계 시 상속을 염두에 두지 않고 만들어진 클래스들은

    깨지기 쉬운 기반 클래스(fragile base class) 문제를 야기하는 경우가 많다.

    이 문제는 Base Class를 변경했는데 하위 클래스에서 올바르지 못한 동작이 발생하는 경우를 말한다.

    따라서 상속할 수 있는 클래스를 주의 깊게 설계하고 Base Class들이

    지킨다고 가정하고 있는 내용을 명시적으로 문서화해 실수를 줄이는것이 적극 권장된다.


    하위 클래스의 인스턴스는 상위 클래스의 인스턴스이기도 하다.

    하위 클래스 인터페이스는 상위 클래스의 멤버를 모두 상속한다.

    val aircraft = Aircraft(100)
    val vehicle: Vehicle = aircraft
    vehicle.start() // Vehicle 메서드 호출
    vehicle.stop() // Vehicle 메서드 호출
    aircraft.stop() // Vehicle 메서드 호출
    aircraft.takeOff() // FlyingVehicle 메서드 호출
    aircraft.land() // FlyingVehicle 메서드 호출
    println(aircraft.seats) // Aircraft 프로퍼티 접근

    몇몇 종류의 클래스들은 상속을 제한적으로 지원한다.

    예를 들자면 데이터 클래스는 항상 final이므로 open으로 선언할 수 없다.

    처음에는 데이터 클래스가 다른 클래스를 상속하는 것도 금지되었지만,

    지금은 다른 클래스를 상속하는 것은 가능하다(코틀린 1.1부터 가능해졌다고 한다.)

    data class Car(val seats: Int) : Vehicle()

    반면 인라인 클래스는 다른 클래스를 상속할 수 없고, 다른 클래스의 부모 클래스 역할도 불가능하다.

    객체(object, 동반 객체를 포함)는 자유롭게 open 클래스를 상속할 수 있다.

    하지만 모든 객체는 인스턴스가 단 하나뿐이므로 객체를 상속하거나 객체를 open으로 선언할 수는 없다.

     

    상속이 제공하는 강력한 기능은 임의 다형성(ad-hoc polymorphism)이다.

    임의 다형성은 상위 클래스 멤버의 여러 다른 구현을 하위 클래스에서 제공하고,

    런타임에 실제 인스턴스가 속한 클래스에 따라 구현을 선택해주는 기능을 말한다.

    코틀린에서는 상위 클래스의 멤버를 오버라이드해서 임의 다형성을 만족할 수 있다.

    코틀린에서 상위 클래스의 멤버를 오버라이드 하기 위해서는

    상위 클래스의 멤버에도 open 키워드를 붙여야 한다.

    open class Vehicle{
        open fun start(){
            println("moving..")
        }
    
        fun stop(){
            println("stopped!")
        }
    }
    
    class Car : Vehicle(){
        override fun start(){
            println("riding..")
        } 
    }
    
    class Boat : Vehicle(){
        override fun start() {
            println("sailing..")
        }
    }

    Vehicle 클래스는 start() 메서드의 공통 구현을 제공한다.

    그리고 Vehicle을 상속한 클래스인 Car, Boat는 start()를 오버라이드한다.

    여기서 Vehicle 클래스와 start() 메서드를 open으로 지정했다.

    메서드를 open으로 지정하면 하위 클래스에서 오버라이드 할 수 있다.

    하위 클래스인 Car와 Boat에서는 상위 클래스의 메서드를 오버라이드해 구현하는 메서드 앞에 override를 붙여야 한다. 

     

    Vehicle 타입에 대한 메서드 호출은 런타임에 그 인스턴스의 클래스가 무엇인지에 따라 달라진다.

    fun startAndStop(vehicle: Vehicle){
        vehicle.start()
        vehicle.stop()
    }
    
    fun main(){
    
        startAndStop(Car())
        startAndStop(Boat())
    //    riding..
    //    stopped!
    //    sailing..
    //    stopped!
    }

    반면 stop() 메서드는 이 메서드 앞에 명시적으로 open 키워드가 없으므로

    자바에서 final을 작성한 것과 동일하다. 즉 상속이 불가능하므로 stop()메서드는 오버라이드할 수 없고,

    하위 메서드는 stop() 메서드를 단순히 상속하는데 그친다.

     

    코틀린과 자바 상속의 두 가지 중요한 차이가 있다.

    먼저 코틀린 함수와 프로퍼티는 기본적으로 final이며,

    하위 클래스에서 오버라이드하게 허용하려면 open을 명시해야 한다.

    하지만 자바에서 메서드나 프로퍼티는 암시적으로 open이기 때문에

    오버라이드를 막으려면 final 변경자를 이용해 오버라이드를 막는다는 사실을 명시해야 한다.

     

    그리고 코틀린에서 멤버를 오버라이드 하는 경우 override 키워드를 앞에 붙여야 하며 붙이지 않을 경우

    컴파일 에러가 발생한다.

    반면 자바에서는 @Override 어노테이션을 붙이는 편을 권장하지만, 필수는 아니다.


    멤버와 확장(extention)의 중요한 차이점에 대해 생각해보자.

    클래스 멤버는 (final이 아니라면) 오버라이드 할 수 있고,

    그에 따라 런타임에 인스턴스의 구체적인 타입에 따라 어떤 구현이 호출될지 결정할 수 있지만,

    확장은 항상 정적으로 호출할 대상이 결정된다.

    즉 컴파일러는 항상 정적으로 알려진 수신 객체 타입을 기반으로 호출할 확장을 선택한다.

    open class Vehicle{
    
        open fun start(){
            println("moving..")
        }
    }
    
    fun Vehicle.stop(){
        println("stopped moving")
    }
    
    class Car : Vehicle(){
        override fun start(){
            println("riding..")
        }
    }
    
    fun Car.stop(){
        println("stopped riding")
    }
    
    fun main(){
    
        val vehicle: Vehicle = Car()
        vehicle.start() // riding..
        vehicle.stop() // stopped moving
    }

    프로그램이 Car 클래스에 정의된 start()를 호출한다는 사실은 명확하다.

    이 메서드 호출은 vehicle 변수의 런타임 타입에 의해 동적으로(위 코드에서 Car) 결정되기 때문이다.

    반면 stop()은 vehicle 변수의 정적 타입(위 코드에서 Vehicle)에 의해 결정되기 때문에 Vehicle.stop()이 호출된다.

     

    open class Vehicle{
    
        open fun start(speed: Int){
            println("moving at $speed")
        }
    }
    
    fun Vehicle.stop(){
        println("stopped moving")
    }
    
    class Car : Vehicle(){
    	// 시그니처가 달라서 다른 메서드를 오버라이딩 하려는 것으로 인식
        override fun start(){ // ERROR : 'start' overrides nothing
            println("riding..")
        }
    }

    오버라이드를 하는 멤버의 시그니처가 상위 클래스의 멤버 시그니쳐와 일치해야 한다.

     

    open class Vehicle{
    
        open fun start(speed: Int): String?{
            return if(speed == 0){
                null
            }else{
                "moving at $speed"
            }
        }
    }
    
    class Car : Vehicle(){
        override fun start(speed: Int): String{
            return "riding at $speed"        
        }
    }

    반환 타입은 하위 타입으로 바꿀 수 있다.(String? > String)

     

    오버라이드하는 멤버를 final로 선언하면 더 이상 하위 클래스가 이 멤버를 오버라이드 할 수 없다.

    open class Vehicle{
        open fun start(speed: Int){
            println("moving at $speed")
        }
    }
    
    class Car : Vehicle(){
        final override fun start(speed: Int){
            println("riding at $speed")
        }
    }
    
    class Bus : Car(){ // ERROR: This type is final, so it cannot be inherited from
        override fun start(speed: Int) {
            println("sailing..")
        }
    }

    하위 클래스 본문에 구현을 넣는 방법과 주생성자 파라미터를 이용해 프로퍼티도 오버라이드할 수 있다.

    open class Entity{
        open val name: String get() = ""
    }
    
    class Person(override var name: String) : Entity()

    불변 프로퍼티를 가변 프로퍼티로 오버라이드하는것도 가능하다

    open class Entity{
        open val name: String get() = ""
    }
    
    class Person : Entity(){
        override var name: String = ""
    }

    자바와 마찬가지로 코틀린도 멤버의 영역을 하위 클래스의 영역으로만 제한하는 접근 변경자를 제공하는데,

    이 멤버에는 protected 키워드를 붙여서 사용한다.

    open class Vehicle{
        protected open fun onStart(){
            println("Start!")
        }
        open fun start(speed: Int){
            println("moving at $speed")
        }
    }
    
    class Car : Vehicle(){
        override fun onStart() {
            println("It's a car")
        }
    }
    
    fun main(){
    
        val car = Car()
        car.start(10) /// OK
        car.onStart() // ERROR : Cannot access 'onStart': it is protected in 'Car'
    }

    코틀린과 자바의 protected 변경자는 차이점이 있다.

    두 언어 모두 하위 클래스에서 상위 클래스의 protected 멤버 접근을 허용하지만

    자바는 같은 패키지에 속한 아무 코드에서나 this 멤버에 접근할 수 있다.

    반면 코틀린에서는 this 접근이 금지된다.

     

    때때로 함수나 프로퍼티를 오버라이드한 버전이 원래 버전을 재사용해야 하는 경우가 있다.

    이 경우 멤버 참조 앞에 super 키워드를 붙이면 원래 버전을 참조할 수 있다.

    (this를 사용하는 구문과 유사하지만, 현재 클래스의 멤버가 아니라 상위 클래스에서 상속받은 멤버에 접근한다는 차이)

    open class Vehicle{
        open fun start(speed: Int): String = "I'm moving $speed! "
    }
    
    class Car : Vehicle(){
        override fun start(speed: Int) = super.start(speed = speed) + "in a car"
    }
    
    fun main(){
    
        val car = Car()
        println(car.start(10)) // I'm moving 10! in a car
    }

    2) 하위 클래스 초기화

    4장에서 생성자를 이용해 어떤 클래스의 인스턴스 상태를 초기화하는 방법에 대해 설명했었다.

    프로그램은 하위 클래스의 인스턴스를 생성하는 동안에 상위 클래스에 정의된 초기화 코드를 호출해야 한다.

    상위 클래스에서 초기화하는 상태가 하위 클래스 코드가 사용할 환경이 되기 때문에

    항상 상위 클래스 초기화를 먼저 시행해야 한다.

    코틀린에서는 이 순서가 자동으로 지켜진다.

    만약 어떤 클래스 A의 인스턴스를 생성하려고 시도하면 A는 자신의 상위 생성자를 호출하며

    이런 호출이 최상위 클래스(=Any 클래스)에 이를 때 까지 연쇄적으로 일어난다.

     

    그 결과 처음에는 Any의 초기화 코드가 실행되고, 그 다음 Any를 바로 상속한 조상 클래스의 초기화 코드가

    이후 동일하게 A 초기화 코드가 실행될 때 까지 순차적으로 실행된다.

    open class Vehicle{
        init {
            println("Initializing Vehicle")
        }
    }
    
    open class Car : Vehicle(){
        init {
            println("Initializing Car")
        }
    }
    
    class Truck : Car(){
        init {
            println("Initializing Truck")
        }
    }
    fun main(){
        val truck = Truck()
    	// Initializing Vehicle
    	// Initializing Car
    	// Initializing Truck
    }

    이 결과를 통해 상위 클래스 > 하위 클래스 순서로 초기화가 진행된다는 사실을 다시한번 확인할 수 있다.

     

    8-1-1)에서 Vehicle과 FlyingVehicle에 대해 설명할 때 상위 클래스 이름 뒤에 있는 괄호가

    생성자 호출을 구성한다는 내용이 있었는데, 지금까지 생성한 모든 상위 클래스는 디폴트 생성자를 사용했으므로

    코드에서 인자를 상위 클래스 생성자에 넘기지 않았다.

    데이터를 상위 클래스 생성자에 전달하고 싶은 경우 아래와 같이 코드를 작성하면 된다.

    open class Person(val name: String, val age: Int){
        init {
            println("$name, $age")
        }
    }
    
    class Student(name: String, age: Int, val university: String) : Person(name, age)
    
    fun main(){
        val student = Student("SeungHoon", 25, "MIT")
        // SeungHoon, 25
    }

    위 코드에서 Student 클래스의 주생성자는 위임호출(delegating call)이라고 하는 Person(name, age) 호출을 사용해

    자신의 파라미터 중 세가지를 Person 상위 클래스에게 넘긴다

     

    일반 생성자 호출과 마찬가지로 위임 호출(delegating call)도 주생성자나 부생성자에 모두 적용할 수 있다.

    open class Person{
        val name: String
        val age: Int
    
        constructor(name: String, age: Int){
            this.age = age
            this.name = name
        }
    }
    
    class Student(name: String, age: Int, val university: String) : Person(name, age)
    
    fun main(){
    
        val student = Student("SungHoon", 25, "MIT")
    }

    Student 클래스에서 부생성자를 사용하고 싶을 경우 위임 호출(delegating call)을

    생성자 시그니처 바로 뒤에 위치시키면 가능하다

    open class Person(val name: String, val age: Int)
    
    class Student : Person{
        val university: String
        constructor(name: String, age: Int, university: String) : super(name, age){
            this.university = university
        }
    }

    super 키워드는 부생성자가 상위 클래스의 생성자를 위임 호출(delegating call)한다는 사실을 컴파일러에게 알려준다.

    이 구문은 같은 클래스의 다른 생성자를 호출하는 this 키워드와 사용법이 비슷하다.

    주생성자를 호출하는 경우와 비교할 때 차이점은

    상위 클래스 이름 다음에 괄호가 없다는 점 이다. (Person() 대신 Person을 사용한다)

    이렇게 사용하는 이유는 우리가 정의하고있는 클래스에 주생성자가 없으므로 부생성자로부터 위임 호출을 해야하기 때문이다.

     

    클래스에 주생성자가 있으면 부생성자가 상위 클래스를 위임 호출할 수 없다

    open class Person(val name: String, val age: Int)
    
    class Student() : Person{ // ERROR : This type has a constructor, and thus must be initialized here
        val university: String
        constructor(name: String, age: Int, university: String) : 
                super(name, age){ // ERROR : Primary constructor call expected
            this.university = university
        }
    }

     


    특별한 경우로 상위 클래스가 여러 생성자를 제공하고,

    하위 클래스에서 상위 클래스의 생성자중 둘 이상을 지원하고 싶은 경우

    주생성자를 아예 정의하지 않고 부생성자를 사용하는 방법이 유일한 해법이다.

    open class Person{
        val name: String
        val age: Int
        
        constructor(name: String, age: Int){
            this.name = name
            this.age = age
        }
        constructor(firstName: String, familyName: String, age: Int) :
            this("$firstName $familyName", age){
        }
    }
    
    class Student : Person{ 
        val university: String
        constructor(name: String, age: Int, university: String) :
                super(name, age){ 
            this.university = university
        }
        
        constructor(
                firstName: String,
                familyName: String,
                age: Int,
                university: String
        ) :
                super(firstName, familyName, age){
                    this.university = university
                }
    }
    
    fun main(){
    
        Student("Euan", "Reyonolds", 25, "MIT")
        // firstName, familyName, age, university
        Student("Val Watts", 22, "ETHZ")
        // name, age, university
    }

    이 경우가 코틀린 언어가 부생성자를 추가한 여러 가지 이유중 하나라고 한다.

    특히 주생성자와 부생성자를 구분하지 않는 자바 코드와의 상호 운용성을 고려한다면 이런 방식이 중요해진다고 한다.


    이 절에서 강조하는 다른 문제는 this 누출(leaking this) 문제이다.

    open class Person(val name: String, val age: Int){
        open fun showInfo(){
            println("$name, $age")
        }
        init {
            showInfo()
        }
    }
    
    class Student(
            name: String,
            age: Int,
            val university: String
    ) : Person(name, age){
        override fun showInfo() {
            println("$name, $age (student at $university)")
        }
    }
    
    fun main(){
        Student("Val Watts", 22, "ETHZ")
        // Val Watts, 22 (student at null)
    }

    코드를 작성할 때 생각했던 결과는 student의 결과값에 이름 나이 학교가 모두 출력될 것을 예상했겠지만,

    실제 결과를 보면 학교가 null로 출력되는것을 볼 수 있다.

     

    왜 university 변수가 null일까? 

    그 이유는 showInfo()가 상위 클래스의 초기화 코드에서 호출되기 때문이다.

    이 함수는 가상 함수(virtual function)이므로 프로그램은 런타임 객체의 실제 타입인

    Student클래스가 오버라이드한 showInfo()를 호출한다.

    하지만 showInfo()가 호출되는 시점에 university 변수는 아직 초기화되지 않았다.

    이러한 상황을 this 누출(leaking this)라고 부르는데, 그 이유는 상위 클래스가 현재의 인스턴스를 코드에 누출하는데,

    현재 인스턴스는 일반적으로 아직 초기화되지 않은 인스턴스의 상태에 의존할 수 있기 때문이다.

     

    개인적으로 이해한 방식을 덧붙이자면,

    상속을 받은 자식 객체를 인스턴스화 하는 경우, 상위 클래스의 생성자를 먼저 호출하게 된다.

    하지만 위 코드의 경우 Person의 init을 실행하려고 보니 showInfo()가 오버라이드 됬으므로

    Student의 showInfo()가 호출되게 된다.

    그래서 상위 클래스(Person)의 생성사 호출 도중 오버라이드된 Student의 showInfo()가 먼저 호출되고

    Val Watts, 22 (student at null)을 출력한 뒤 상위 클래스의 생성자 호출이 끝나면

    Student의 university의 값이 할당되게 되는것으로 이해했다.

     

    하나의 예시를 보면 좀 더 이해가 빠를 것 같다.

    open class Person(val name: String, val age: Int){
        override fun toString() = "$name $age"
        init {
            println(this) // 잠재적 위험 요소
        }
    }
    
    class Student(
            name: String,
            age: Int,
            val university: String
    ) : Person(name, age){
        override fun toString(): String {
            return super.toString() + "(student at $university)"
        }
    
        init {
            println(this)
        }
    }
    
    fun main(){
        Student("Val Watts", 22, "ETHZ")
        // Val Watts 22(student at null) : init Person
        // Val Watts 22(student at ETHZ) : init Student
    }

    this 누출 문제는 코틀린의 not-null 타입의 변수 값이 null이 될 수도 있는 아주 드문 경우라고 한다.

    그래서 위 코드의 Person의 init 내부의 this에는

    Leaking 'this' in constructor of non-final class Person 이라는 경고가 생긴다.


    3) 타입 검사와 캐스팅

    어떤 클래스의 변수가 런타임에는 해당 클래스의 하위 타입 중에서 아무 타입의 객체나 가리킬 수 있으므로

    어떤 인스턴스가 더 구체적인 타입에 속하는지 검사하고 필요할 때 타입을 변환할 수 있는 방법이 있다.

    val objects = arrayOf("1", 2, "3", '4')

    컴파일러 관점에서 Any가 문자열, 정수, 문자를 포함하는 최소한의 공통 상위 타입이기 때문에

    위 코드의 objects는 Any로 이루어진 배열 타입이다.

    하지만 String이나 Int에만 사용할 수 있는 연산을 objects 배열에 원소에 사용하고 싶을 경우가 있을 수 있다.

    연산을 배열 원소에 직접 사용하면 원소 타입이 Any 타입이라

    String이나 Int에 가능한 연산을 지원하지 않으므로 컴파일되지 않는다.

    val objects = arrayOf("1", 2, "3", '4')
    for (obj in objects){
        println(obj * 2) // ERROR : unresolved reference
    }

    타입 캐스팅 연산을 통해 위 문제를 해결할 수 있다.

    is 연산자는 왼쪽 피연산자가 오른쪽에 주어진 타입인 경우 true를 반환한다.

    그래서 위 코드를 is 연산자를 이용해 변경하면 에러를 없앨 수 있다.

    val objects = arrayOf("1", 2, "3", '4')
    for (obj in objects){
        println(obj is Int) // false true false false
    }

    null 값은 모든 nullable 타입의 인스턴스로 간주되지만,

    모든 not-null 타입의 인스턴스는 아닌 것으로 간주된다.

    println(null is Int) // false
    println(null is Float?) // true

    !is 연산자를 통해 is와 반대인 연산으로 사용할 수 있다.

     

    is나 !is 연산자의 왼쪽 피연산자의 정적 타입이

    오른쪽에 오는 타입의 상위 타입인 경우에만 두 연산자를 사용할 수 있다.

    Int타입값의 타입을 String 타입과 비교하는 것은 의미가 없기 때문에 컴파일러는 정적으로

    String이 Int의 하위 타입이 아니라는 사실을 알고 컴파일 에러를 발생시킨다.

    println(12 is String) // ERROR: Incompatible types: String and Int

    is 연산자는 자바의 instanceOf 연산자와 매우 비슷하다. 하지만 이 두 연산의 널 취급 방식은 완전히 다르다.

    자바의 intanceOf 연산자는 null에 대해 항상 false를 반환하지만

    코틀린의 is 연산자는 연산자 오른쪽에 있는 타입이 널이 될 수 있는지 여부에 따라 결과가 달라진다.

     

    4장에서 nullable 타입의 값을 null과 비교한 경우 값의 타입을

    자동으로 not-null 타입으로 바꿔주는 스마트 캐스트에 대해 작성했었는데,

    이런 기능을 is / !is 에도 사용할 수 있다.

    val objects = arrayOf("1", 2, "3", '4', 5)
    var sum = 0
    for (obj in objects){
        if(obj is Int) {
            sum += obj
        }
    }
    println(sum) // 7

    is / !is 연산자를 in이나 !in처럼 특별한 조건으로 사용할 수 있는 식 내부에서도 스마트 캐스트가 지원된다.

    val objects = arrayOf("1", 2, "3", '4', 5)
    var sum = 0
    for (obj in objects){
        when (obj) {
            is Int -> sum += obj
            is String -> sum += obj.toInt()
        }
    }
    println(sum) // 1 + 2 + 3 + 5 = 11

    컴파일러는 검사 시점과 사용 시점 사이에 변수가 변경되지 않는다고 확실할 수 있을 때만 스마트 캐스트를 허락한다.

    위 예시들과 4장에서 배웠던 null 관련 스마트 캐스트를 통해 스마트 캐스트 규칙을 좀 더 정확하게 표현할 수 있다.

     

    1) 프로퍼티나 커스텀 접근자가 정의된 변수에 대해서는 스마트 캐스트를 쓸 수 없다.

    (컴파일러가 해당 변수를 검사한 뒤 값이 바뀌지 않는다고 보장할 수 없기 때문,

    위임(delegate)을 사용하는 프로퍼티나 지역 변수도 포함)

    class Holder {
        val o: Any get() = ""
    }
    fun main(){
        val o: Any by lazy { 123 }
        
        if(o is Int){
            println(o * 2) // ERROR : Smart cast to 'Int' is impossible, 
        // because 'o' is a property that has open or custom getter
        }
        
        val holder = Holder()
        if(holder.o is String){
            println(holder.o.length) // ERROR : Smart cast to 'String' is impossible
        }
    }

    2) open 멤버 프로퍼티의 경우, 하위 타입에서 this 프로퍼티를 오버라이드하면서 커스텀 접근자를 추가할 수 있기 때문에

    스마트 캐스트를 할 수 없다.

    open class Holder {
        val o: Any get() = ""
    }
    fun main(){
    
        val holder = Holder()
        if(holder.o is String){
            println(holder.o.length) // ERROR : Smart cast to 'String' is impossible
        }
    }

    3) 가변 지역 변수의 경우 검사하는 시점과 변수를 읽는 시점 사이에 값을 명시적으로 변경하거나

    어떤 람다 안에서 변수를 변경하면 스마트 캐스트가 되지 않는다

    (람다 내부에서 변수를 변경하는 코드의 경우 일반적으로 런타임에 어느 시점에 변수가 변경될지 예측할 수 없기 때문)

    var o: Any = 123
    if(o is Int){
        println(o + 1) // OK : Int로 스마트 캐스트
        o = "ABC"
        println(o.length) // OK : String으로 스마트 캐스트
    }
    if(o is String){
        val f = {o = 123}
        println(o.length) // ERROR : Smart cast to 'String' is impossible
    }

    4) 다른 코드에서 언제든 변경이 가능한 하변 프로퍼티는 스마트 캐스트 대상이 아니다.

     

    5) 위임(delegate)이 없는 불변 지역 변수는 항상 스마트 캐스트가 가능하다.

    (이 사실이 불변 변수를 가변 변수보다 선호해야 하는 이유이기도 하다)

     

    6) 스마트 캐스트를 쓸 수 없는 경우에도 명시적인 연산자를 통해 어떤 값의 타입을 강제로 변환(coerce)할 수 있다.

    코틀린은 this 연산자로 안전하지 않은 as와 안전한 버전인 as?를 제공한다.

    이 둘의 차이는 객체의 실제 타입이 변환하려는 대상 타입과 일치하지 않을 때 이를 처리하는 방식에 따라 다르다.

    as는 예외를 던지고, as?는 null을 반환한다.

    fun main(){
        var o: Any = 123
        println((o as Int) + 1) // 124
        println((o as? Int)!! + 1) // 124
        println((o as? String ?: "").length) // 0
        println((o as String).length) // Exception : ClassCastException
    }

    o as String?과 o as? String을 구분해야 한다.

    o가 String? 타입의 값이라면 두 식이 같은 값(null을 포함)을 가질 수 있지만

    o가 not-null 타입이라면 같은 값을 가질 수 없다.

    var o: Any = 123
    println(o as? String) // null
    println(o as String?) // Exception : ClassCastException
    
    println(null as String) // Exception : NullPointerException
    // null > not-null type cast : NullPointerException

    as 연산자는 자바의 캐스팅 식과 같지만 null 처리가 다르다.

    자바에서는 캐스팅을 해도 항상 널은 널로 남지만

    코틀린에서는 대상 타입의 널 가능성 여부에 따라 예외를 던지거나 null이 될 수 있다.


    4) 공통 메서드

    Kotlin.Any 클래스는 코틀린 클래스 계층 구조의 루트다. 즉 다른 모든 클래스는 Any를 직/간접적으로 상속한다.클래스를 정의하면서 상위 클래스를 명시하지 않으면 컴파일러는 자동으로 상위 클래스를 Any로 가정한다.그래서 Any의 멤버는 모든 코틀린 값에 존재한다. Any의 정의를 살펴보자.

    public open class Any {
        public open operator fun equals(other: Any?): Boolean
        public open fun hashCode(): Int
        public open fun toString(): String
    }

    operator 키워드는 equals() 메서드가 연산자 형태(== or !=)로 호출될 수 있다는 뜻 이다.

    이 메서드들은 not-null 타입의 값에 대해 적용할 수 있는 기본 연산을 정의한다.

    • 구조적 동등성(== or !=)
    • 해시 코드 계산(HashSet, HashMap등 일부 컬렉션 타입이 해시 코드를 사용)
    • String으로 변환하는 기본적인 방법

    컴파일러는 클래스에 대한 참조 동등성(referential equality)과 데이터 클래스에 대한 구조적 동등성을 제공한다.이제 임의의 코틀린 클래스에 대한 동등성 연산을 구현해보자

    class Address(
        val city: String,
        val street: String,
        val house: String
    )
    
    open class Entity(
        val name: String,
        val address: Address
    )
    
    class Person(
        name: String,
        address: Address,
        val age: Int
    ) : Entity(name, address)
    
    class Organization(
        name: String,
        address: Address,
        val manager: Person
    ) : Entity(name, address)
    
    fun main(){
    
        val address = arrayOf(
            Address("London", "Ivy Lane", "8A"),
            Address("New York", "Kingsway West", "11/8"),
            Address("Sydney", "North Road", "129")
        )
        
        println(address.indexOf(Address("Sydney", "North Road", "129"))) // -1
    }

    디폴트로 위 코드에 정의돈 모든 클래스는 Any에서 상속받은 참조 동등선만 구현한다.예를들어 컬렉션 객체로 이런 클래스들의 인스턴스를 사용하면, 프로퍼티가 똑같더라도 두 인스턴스가 같은 객체로 간주되지 않는다.따라서 위 코드는 indexOf를 통해 2가 출력되리라고 예상했지만,실제로는 참조동등성이 같은 값이 없으므로 -1을 출력한다.

     

    equals() 메서드를 오버라이드해서 내용을 바탕으로 동등성을 비교하게 되면위 코드를 처음 의도한대로 작동하게 할 수 있다.Address에 아래와 같은 코드를 추가하고나서 다시 위 코드를 실행시키면 2가 출력된다.

    class Address(
        val city: String,
        val street: String,
        val house: String
    ){
        override fun equals(other: Any?): Boolean {
            if(other !is Address) return false
            return city == other.city &&
                    street == other.street &&
                    house == other.house
        }
    }

    == 연산자와 != 연산자는 둘다 equals() 메서드를 사용한다

     

    이 두 연산자를 nullable 값에도 적용할 수 있다.연산자의 왼쪽 피연산자가 null인경우 참조 동등성을 사용해 널과 비교하면 된다.참조 동등성은 === 연산자와 !== 를 이용해 비교할 수 있다.== , !=와 달리 === ,!== 연산자는 동작을 오버라이드해 변경할 수는 없다.

    val addr1 = Address("London", "Ivy Lane", "8A")
    val addr2 = addr1
    val addr3 = Address("London", "Ivy Lane", "8A")
    
    println(addr1 === addr2) // true
    println(addr1 == addr2) // true
    println(addr1 === addr3) // false
    println(addr1 == addr3) // true

    자바에서는 ==, != 연산자는 참조 동등성을 비교하고, 내용을 기반으로 비교하고 싶을 경우 equals()를 이용한다.

    또한 자바에서는 NullPointerException을 방지하기 위해 equals()에서도 수신 객체가 null인 경우에 대한 보호를 추가해야 한다.

     

    자바와 마찬가지로 equals() 메서드의 커스텀 구현은 두 객체에 대응하는 hashCode()와 서로 잘 조화되야 한다.두 구현은 서로 연관이 있어야 하고, equals()가 같다고 반환하는 두 객체는 항상 같은 hashCode()를 반환해야 한다.그 이유는 일부 컬렉션(HashSet 등)이 hashCode()를 사용해 해시 테이블에서 원소가 들어갈 슬롯을 먼저 찾고그 후에 equals()를 통해 해시 코드가 같은 후보를 검색하기 때문이다.(equals() 입장에서) 동등한 두 객체가 서로 다른 해시 코드를 반환하면, 이런 컬렉션은 equals()를 호출하기 전에 서로 다르다고 인식하고 검색에서 제외시키기 때문이다.

     

    equals() 구현의 일반적인 요구 사항은 아래와 같으며 기본적으로 자바와 동일하다

    • 널이 아닌 객체가 널과 같을 수 없다.
    • 동등성 연산은 반사적(reflexive)이어야 한다. 즉, 모든 객체는 자기 자신과 동등해야 한다.
    • 동등성 연산은 대칭적(symmetric)이어야 한다. 즉, a == b이면 b == a 이어야 한다.
    • 동등성 연산인 추이적(transitive)이어야 한다. 즉, a == b이고 b == c이면 a == c여야 한다.

    아래는 실제로 Any 타입에 대해 정의된 내용이다.

    public open class Any {
        /**
         * Indicates whether some other object is "equal to" this one. Implementations must fulfil the following
         * requirements:
         *
         * * Reflexive: for any non-null value `x`, `x.equals(x)` should return true.
         * * Symmetric: for any non-null values `x` and `y`, `x.equals(y)` should return true if and only if `y.equals(x)` returns true.
         * * Transitive:  for any non-null values `x`, `y`, and `z`, if `x.equals(y)` returns true and `y.equals(z)` returns true, then `x.equals(z)` should return true.
         * * Consistent:  for any non-null values `x` and `y`, multiple invocations of `x.equals(y)` consistently return true or consistently return false, provided no information used in `equals` comparisons on the objects is modified.
         * * Never equal to null: for any non-null value `x`, `x.equals(null)` should return false.
         *
         * Read more about [equality](https://kotlinlang.org/docs/reference/equality.html) in Kotlin.
         */
        public open operator fun equals(other: Any?): Boolean
    	
        public open fun hashCode(): Int
        
        public open fun toString(): String
    }

    위에서 본 equals() 메서드와 호환되는 hashCode() 구현은 예를들면 다음과 같다.

    override fun hashCode(): Int {
        var res = city.hashCode()
        res = 31 * res + street.hashCode()
        res = 31 * res + house.hashCode()
        return res
    }

    위 동작을 Entity 클래스에 적용하면 다음과 같은 코드가 작성된다.

    open class Entity(
        val name: String,
        val address: Address
    ){
        override fun equals(other: Any?): Boolean {
            if(this == other) return true
            if(javaClass != other?.javaClass) return false
            
            other as Entity
            if(name != other.name) return false
            if(address != other.address) return false
            
            return true
        }
    
        override fun hashCode(): Int {
            var res = name.hashCode()
            res = 31 * res + address.hashCode()
            return res
        }
    }

    프로퍼티는 각자의 equals()와 hashCode() 구현에 위임(delegate)해 해시와 동등성을 계산한다.

    배열 타입은 예외다. 그 이유는 배열에는 자체적인 내용 기반 동등성 구현이 없기 때문에

    생성된 코드가 contentEquals()와 contentHashCode()를 사용한다.

     

    상위 클래스가 커스텀 equals() / hashCode() 구현을 제공한다면 IDE에 의해 자동으로 생성되는

    equals() / hashCode()는 해당 구현을 equals() / hashCode() 구현에서 호출해준다.

    예를들어 Person 클래스에 대해 Generate equals() / hashCode()를 적용하면 아래와 같은 코드를 얻는다.

    class Person(
        name: String,
        address: Address,
        val age: Int
    ) : Entity(name, address){
        override fun equals(other: Any?): Boolean {
            if(this == other) return true
            if(javaClass != other?.javaClass) return false
            if(!super.equals(other)) return false
            
            other as Person
            if(age != other.age) return false
            return true
        }
    
        override fun hashCode(): Int {
            var res = super.hashCode()
            res = 31 * res + age
            return res
        }
    }

    자바와 마찬가지로 모든 코틀린 클래스에는 toString() 메서드가 들어있다.

    이 메서드는 주어진 인스턴스의 기본 문자열 표현을 제공하는데,

    디폴트는 클래스 이름 뒤에 객체 해시 코드를 조합해 출력한다.

    따라서 대부분의 경우 이를 좀 더 읽기 좋은 표현으로 오버라이드하는 방식이 권장된다.

    class Address(
        val city: String,
        val street: String,
        val house: String
    ){
        override fun toString(): String {
            return "$city, $street, $house"
        }
    }
    
    
    open class Entity(
        val name: String,
        val address: Address
    )
    
    class Person(
        name: String,
        address: Address,
        val age: Int
    ) : Entity(name, address){
        override fun toString(): String {
            return "$name, $age at $address"
        }
    }
    
    class Organization(
        name: String,
        address: Address,
        val manager: Person?
    ) : Entity(name, address){
        override fun toString() = "$name at $address"
    }
    
    fun main(){
    
        println(Person("Euan Reynolds", Address("London", "Ivy Lane", "8A"), 25))
        // Euan Reynolds, 25 at London, Ivy Lane, 8A
        println(Organization("Thriftocracy, Inc", Address("Perth", "North Road", "129"), null))
        // Thriftocracy, Inc at Perth, North Road, 129
    }

    코틀린 표준 라이브러리에는 Any? 타입에 대한 toString() 확장 정의가 들어있다.

    이 함수는 수신객체가 널이 아니면 단순하게 수신 객체의 toString()에게 문자열 생성을 위임하고

    널인 경우 "null" 이라는 문자열을 반환한다.

    이를 통해 nullable type과 not-null 타입 양쪽에 toString()을 사용할 수 있게 된다.


    다음글

    8-2. 클래스 계층 이해하기 : 추상 클래스와 인터페이스

    반응형

    댓글

Designed by Tistory.