8장. 클래스 계층 이해하기

Bimmer·2023년 9월 5일
0
post-thumbnail

상속

도메인 개념에 있는 is-a 관계 (자동차는 교통수단이다.)를 표현하기 위해 객체지향 언어는 상속(inheritance)이라는 개념을 사용한다.
클래스 A가 클래스 B를 상속하면, A의 모든 인스턴스는 자동으로 B의 인스턴스로 간주된다. 이로 인해 클래스 A는 B에 정의된 모든 멤버와 확장을 자동으로 얻는다.

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

어떤 클래스를 상속하려면, 클래스를 정의하면서 주생성자 뒤에 :을 넣고 그 뒤에 상위 클래스가 될 클래스의 이름을 넣으면 된다.

// 상속을 허용하기 위해 open 키워드 추가
open class Vehicle {
    var currentSpeed = 0
   
   	// 프로퍼티도 open으로 오버라이드 허용
    open var seats = 0

	// 메서드 open으로 오버라이드 허용
    open fun start() {
        println("I'm moving")
    }

    fun stop() {
        println("Stopped")
    }
}

// 자바에서 extends 대신 : 를 사용해서 상속
open class FlyingVehicle : Vehicle() {

	// 메서드 open으로 오버라이드 허용
    open fun takeOff() {
        println("Taking off")
    }

	// 메서드 open으로 오버라이드 허용
    open fun land() {
        println("Landed")
    }
}

// Vehicle 클래스의 seats 프로퍼티 오버라이드
class Aircraft(override var seats: Int) : FlyingVehicle() {
    override fun start() {
        println("Aircraft Start")
    }

    override fun takeOff() {
        println("Aircraft take off")
    }

    override fun land() {
        println("Aircraft land")
    }
}

fun main() {
    val aircraft = Aircraft(5)
    println("speed: ${aircraft.currentSpeed}")      // speed: 0

    aircraft.currentSpeed = 200
    println("speed: ${aircraft.currentSpeed}")      // speed: 200

    println(aircraft.start())       // I'm moving
    println(aircraft.stop())        // Stopped
    println(aircraft.takeOff())     // Aircraft take off
    println(aircraft.land())        // Aircraft land

    println(aircraft.seats)         // 5
}

자바와 달리 코틀린에서는 extends나 implements와 같은 특별한 키워드를 사용하지 않는다. 상속은 항상 콜론(:)으로 표시된다.

자바에서는 모든 클래스가 디폴트로 상속에 대해 열려 있으며, 상속을 금지하려면 final을 명시해야 한다. 하지만 코틀린에서는 디폴트가 final이다. 따라서 어떤 클래스를 상속할 수 있게 허용하려면 open을 붙여서 선언해야 한다.

또한 데이터 클래스는 항상 final이며, open으로 선언할 수 없다. 데이터 클래스가 다른 클래스를 상속하는 것은 코틀린 1.1부터 가능하다.

상속이 제공하는 강력한 기능은 임의 다형성(ad-hoc polymorphism)이다. 임의 다형성은 상위 클래스 멤버의 여러 다른 구현을 하위 클래스에서 제공하고, 런타임에 실제 인스턴스가 속한 클래스에따라 구현을 선택해주는 기능을 말한다. 코틀린에서는 상위 클래스의 멤버를 오버라이드해서 임의 다형성을 달성할 수 있다.

Aircraft 클래스는 Vehicle 클래스의 start(), FlyingVehicle 클래스의 takeOff(), land() 메서드를 오버라이드했다.

 

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

우선 코틀린 함수와 프로퍼티는 기본적으로 final이며, 하위 클래스에서 오버라이드하게 허용하려면 open을 명시해야 한다. 하지만 자바에서 메서드나 프로퍼티는 암시적으로 open이기 때문에 오버라이드를 막으려면 final 변경자를 사용해 이 사실을 명시해야 한다.

둘째로, 코틀린에서는 멤버를 오버라이드 하는 경우에 override 키워드를 앞에 붙여야만 한다. 이 키워드를 붙이지 않으면 컴파일러가 오류를 발생시킨다. 반면 자바에서는 @Override 애너테이션을 붙이는 편을 권장하지만, 꼭 표시 하지 않아도 된다. 코틀린에서 override를 붙이게 강제하는 이유는 실수로 상위 클래스의 함수를 오버라이드하는 일을 막기 위해서다. 예를 들어 어떤 멤버를 추가했는데, 우연히 이 멤버의 이름이 상위 클래스 멤버 이름과 같아서 구현을 오버라이드한다면 런타임 시 프로그램의 동작이 예상과 달라지고 찾기 어려운 버그가 생길 수 있기 때문이다.

 

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

두 언어 모두 하위 클래스에서 상위 클래스의 protected 멤버 접근을 허용하지만, 자바는 같은 패키지에 속한 아무 코드에서나 이런 멤버에 접근할 수 있다. 반면 코틀린에서는 이런 접근이 금지된다.

 

하위 클래스 초기화

open class Vehicle {
    init {
        println("Vehicle Init")
    }
}

open class FlyingVehicle : Vehicle() {
    init {
        println("FlyingVehicle Init")
    }
}

class Aircraft(override var seats: Int) : FlyingVehicle() {
    init {
        println("Aircraft Init")
    }
}

fun main() {
    val aircraft = Aircraft(5)
}

=>
Vehicle Init
FlyingVehicle Init
Aircraft Init

프로그램은 하위 클래스의 인스턴스를 생성하는 동안에 상위 클래스에 정의된 초기화 코드를 호출해야 한다. 상위 클래스에서 초기화하는 상태가 하위 클래스 코드가 사용할 환경이 되기 때문에 항상 상위 클래스 초기화를 먼저 시행해야 한다. 코틀린에서는 이 순서가 자동으로 지켜진다.

 

open class Person(
    val name: String,
    val age: Int
) {
    open fun info() = "$name: $age"
}

class Student(name: String, age: Int, val address: String) : Person(name, age)

fun main() {
    val jake = Student("Jake", 29, "Seoul")
    println(jake.info())
    println(jake.address)
}

상위 클래스 생성자에 데이터를 전달하는 경우에는 위임 호출을 사용해야 한다.

 

open class Person {
    val name: String
    val age: Int

    constructor(name: String, age: Int) {
        this.name = name
        this.age = age
    }

    open fun info() = "$name: $age"
}

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

fun main() {
    val jake = Student("Jake", 29, "Seoul")
    println(jake.info())
    println(jake.address)
}

부생성자를 사용하는 경우 위임 호출을 생성자 시그니처 바로 뒤에 위치시켜야 한다.
super 키워드는 부생성자가 상위 클래스의 생성자를 위임 호출한다는 사실을 컴파일러에게 알려준다.

주생성자를 호출하는 경우와 비교할 때, 차이점은 상위 클래스 이름 다음에 괄호가 없다는 것이다. 즉, Person() 대신 Person을 사용한다.

 

open class Person(val name: String, val age: Int)

// error: This type has a constructor, and thus must be initialized here
class Student() : Person {
    val address: String

    constructor(name: String, age: Int, address: String) :
            super(name, age) {  // error: Primary constructor call expected
                this.address = address
            }
}

클래스에 주생성자가 있으면 부생성자가 상위 클래스를 위임 호출할 수 없다.
Student에 빈 주생성자가 있기에 부생성자에서 위임 호출을 할 수 없다.

open class Person(val name: String, val age: Int)


class Student : Person {
    val address: String

    constructor(name: String, age: Int, address: String) :
            super(name, age) {
                this.address = address
            }

    fun info() = "$name: $age : $address"
}

fun main() {
    val jake = Student("jake", 10, "ss")
    println(jake.info())	// jake: 10 : ss
}

Student의 주생성자를 제거하면 에러가 없어진다.

 

this 누출

open class Person(val name: String, val age: Int) {
    open fun showInfo() {
        println("showInfo: $name, $age")
    }
    init {
        showInfo()
    }
}

class Student(
    name: String,
    age: Int,
    val address: String
) : Person(name, age) {
    override fun showInfo() {
        println("override showInfo: $name, $age, $address")
    }
}

fun main() {
    Student("Jake", 29, "Seoul")	// override showInfo: Jake, 29, null
}

위 코드를 실행해보면 address 변수가 null로 출력된다. 이유는 showInfo()가 상위 클래스의 초기화 코드에서 호출되기 때문이다. 이 함수는 가상 함수이므로, 프로그램은 런타임 객체의 실제 타입인 Student 클래스가 오버라이드한 showInfo()를 호출한다. 하지만 Person 초기화 코드는 Student가 초기화되기 전에 실행되므로 showInfo()가 호출되는 시점에 address 변수는 아직 초기화되지 않은 상태기 때문이다.

this 누출 문제는 코틀린의 널이 될 수 없는 타입의 변수가 널이 될 수 있는 아주 드문 경우다.

 

타입 검사와 캐스팅

어떤 클래스의 변수가 런타임에는 해당 클래스의 하위 타입 중에서 아무 타입의 객체나 가리킬 수 있으므로, 어떤 인스턴스가 더 구체적인 타입에 속하는지 검사하고 필요할 때 타입을 변환할 수 있으면 편리하다.

is

val obejcts = arrayOf("1", 2, "3", 4)

for (obj in objects) {
	println(obj * 2)	// error: unresolved reference
}

컴파일러 관점에서 보면, Any가 String 문자열과 Int 정수를 아우르는 최소한의 공통 상위 타입이기 때문에 이 코드의 objects는 Any로 이뤄진 배열이다. 하지만 Int 정수에만 사용할 수 있는 연산을 배열의 원소에 사용하면 에러가 발생한다.

 

fun main() {
    val objects = arrayOf(1, "2", 3, "4")

    for (obj in objects) {
        if (obj is Int) {
            println(obj * 2)
        }
    }
}

이런 경우 is 연산자를 통해 해결이 가능하다. is 연산자는 왼쪽 피연산자가 오른쪽에 주어진 타입인 경우 true를 반환한다.

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

val o: Any = ""
println(o !is Int)		// true
println(o !is String)	// false

println(12 is String)	// error: incompatible type: String and Int (Int 타입과 String 타입을 비교하는 것은 의미 없기에 컴파일 오류 발생)

is 연산자는 자바의 instanceof 연산자와 유사하다. 하지만 instanceof 연산자는 null에 대해 행상 false를 반환하지만, is의 결과는 연산자 오른쪽에 있는 타입이 널을 허용하는 타입인지에 따라 다르게 나타난다.

 

as

is/!is, in/!in을 사용하는 스마트 캐스트를 쓸 수 없는 경우, 명시적인 연산자를 사용해 어떤 값의 타입을 강제로 변환할 수 있다.

프로퍼티나 커스텀 접근자가 정의된 변수에 대해서는 스마트 캐스트를 쓸 수 없다. 컴파일러가 해당 변수를 검사한 다음에 값이 바뀌지 않는다고 보장할 수 없기 때문이다. 이런 유형의 변수에는 위임을 사용하는 프로퍼티나 지역 변수도 포함된다.

열림 멤버 프로퍼티의 경우, 하위 타입에서 이런 프로퍼티를 오버라이드하면서 커스텀 접근자를 추가할 수 있기 때문에 스마트 캐스트를 할 수 없다.

val 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)			// ClassCastException
println(null as String?)				// NullPointerException

as는 예외를 던지지만 as?는 null을 돌려준다.

as 연산자는 자바의 캐스팅 식과 같지만 null 처리가 다르다. 자바에서는 캐스팅을 해도 항상 널은 널로 남지만, 코틀린에서는 대상 타입의 널 가능성에 따라 예외가 발생할 수도 있고 널이 될 수도 있다.

 

추상 클래스와 인터페이스

추상클래스

별도의 인스턴스가 있을 수 없고 구체적인 경우를 구현한 다른 클래스의 인스턴스만 만들 수 있는 추상적인 개념을 표현하는 클래스라면 인스턴스 생성은 바람직하지 않다.

자바와 마찬가지로 코틀린도 추상 클래스를 지원한다. 추상 클래스는 직접 인스턴스화할 수 없고 다른 클래스의 상위 클래스 역할만 할 수 있는 클래스를 뜻한다. 클래스를 추상 클래스로 만들려면 abstract라는 변경자 키워드를 붙여야 한다.

abstract class Entity(val name: String)

// 하위 클래스에서 위임 호출
class Person(name: String, val age: Int) : Entity(name)

// error: cannot create an instance of an abstract class
val entity = Entity("Unknown")

추상 클래스에도 생성자가 있을 수 있다. 추상 클래스와 비추상 클래스의 차이는 추상 클래스의 생성자가 오직 하위 클래스의 생성자에서 위임 호출로만 호출될 수 있다는 점이다.

 

import kotlin.math.PI

abstract class Shape {
    abstract val width: Double
    abstract val height: Double
    abstract fun area(): Double
}

class Circle(val radius: Double) : Shape() {
    val diameter get() = 2 * radius
    override val width get() = diameter
    override val height get() = diameter
    override fun area() = PI * radius * radius
}

class Rectangle(
    override val width: Double,
    override val height: Double
) : Shape() {
    override fun area() = width * height
}

fun Shape.print() {
    println("Bounds: $width * $height, area: ${area()}")
}

fun main() {
    Circle(10.0).print()                // Bounds: 20.0 * 20.0, area: 314.1592653589793
    Rectangle(3.0, 5.0).print() // Bounds: 3.0 * 5.0, area: 15.0
}

추상 클래스는 추상 멤버를 정의할 수 있다. 추상 멤버는 타입, 파라미터, 반환 타입 등 함수나 프로퍼티의 기본적인 모습을 정의하지만 세부 구현을 생략한 멤버다. 비추상 클래스가 추상적인 부모 클래스로부터 이런 멤버를 상속할 때는 반드시 멤버를 오버라이드해서 구현을 제공해야 한다.

또한, 추상 멤버는 암시적으로 열려 있기에 open을 명시할 필요가 없다.

 

인터페이스

코틀린 인터페이스 개념은 자바의 인터페이스와 유사하다. 특히 자바 8에 디폴트 메서드가 도입된 이후로는 더 비슷해졌다. 근본적으로 인터페이스는 메서드나 프로퍼티를 포함하지만 자체적인 인스턴스 상태나 생성자를 만들 수는 없는 타입이다.

interface Vehicle {
	val currentSpeed: Int
    fun move()
    fun stop()
}

인터페이스 멤버는 디폴트가 추상 멤버다. 따라서 구현을 제공하지 않으면 abstract 변경자가 자동으로 붙은 것처럼 간주된다. 명시적으로 abstract를 붙일 수도 있지만 불필요하다.

인터페이스는 클래스나 다른 인터페이스의 상위 타입이 될 수 있다. 비추상 클래스가 인터페이스를 상속할 때는 모든 추상 멤버에 대한 구현을 제공해야 한다. 마찬가지로 클래스가 인터페이스를 상속한 클래스에 있는 인터페이스 멤버를 상속해 구현할 때도 override 키워드를 추가해야 한다.

 

interface Vehicle {
    val currentSpeed: Int
    fun move()
    fun stop()
}

interface FlyingVehivle : Vehicle {
    val currentHeight: Int
    fun takeOff()
    fun land()
}

class Car : Vehicle {
    // Vehicle의 불변 프로퍼티 currentSpeed를 가변 프로퍼티로 override
    override var currentSpeed = 0
        private set

    override fun move() {
        println("Car Riding...")
        currentSpeed = 50
    }

    override fun stop() {
        println("Car Stopped")
        currentSpeed = 0
    }
}

class Aircraft : FlyingVehivle {
    override var currentSpeed = 0
        private set

    override var currentHeight = 0
        private set

    override fun move() {
        println("Aircraft Taxiing")
        currentSpeed = 50
    }

    override fun stop() {
        println("Aircraft Stopped")
        currentSpeed = 0
    }

    override fun takeOff() {
        println("Aircraft Taking off...")
        currentSpeed = 500
        currentHeight = 5000
    }

    override fun land() {
        println("Aircraft Landed")
        currentSpeed = 50
        currentHeight = 0
    }
}

상속 받을 때, 세 가지 타입 정의에서 모두 상위 타입 이름 뒤에 괄호를 붙이지 않았다. 클래스와 달리 인터페이스는 생성자가 ㅇ벗어서 하위 클래스를 초기화할 때 호출해야 할 코드가 없기 때문이다.

인터페이스를 상속할 때는 implements 키워드를 쓰고 클래스를 상속할 때는 extends를 써야만 하는 자바와 달리, 코틀린에서는 모든 상속을 똑같은 기호 콜록(:)을 사용해 표시한다.

자바와 마찬가지로 코틀린 인터페이스가 다른 클래스를 상속할 수는 없다. 단, 모든 코틀린 클래스와 인터페이스가 암시적으로 상속하는 것으로 간주되는 Any 클래스는 예외다.

 

인터페이스 내부에 상태를 정의할 수 없으므로, 인터페이스 안에는 뒷받침하는 필드가 들어있는 프로퍼티를 정의할 수 없다. 초기화 코드나 위임이 붙은 프로퍼티는 금지된다.

interface Vehicle {
	val currentSpeed = 0 			// error
    val maxSpeed by lazy { 100 } 	// error
}

 

인터페이스는 암묵적으로 추상 타입이다. 하지만 추상 클래스와 달리 인터페이스에 대한 생성자는 금지돼 있다.

interface Person(val name: String)	// error: property initializers are not allowed in interfaces

interface Person {
	constructor(name: String)		// error: property initializers are not allowed in interfaces
}

 

자바와 마찬가지로 코틀린 인터페이스도 다중 상속을 지원한다.

interface Car {
    fun move() {
        println("I'm riding")
    }
}

interface Ship {
    fun move() {
        println("I'm sailing")
    }
}

// 다중 상속
class Amphibia : Car, Ship {
    override fun move() {
        // Car와 Ship 모두 move 메서드가 있기에 super<T>를 통해 구분이 필요하다.
        super<Car>.move()
        super<Ship>.move()
    }
}

fun main() {
    Amphibia().move()
}

=>
I'm riding
I'm sailing

 

위임

코틀린 클래스는 기본적으로 final이다. 이렇게 정한 목적은 상속 가능한 클래스를 좀 더 심사숙고한 후 설계해서 하위 클래스를 만들 수 없는 클래스를 실수로 상속하는 일을 없애기 위함이다.

하지만 기존 클래스를 확장하거나 변경해야 하는데, 이 클래스를 상속할 수 없다면 어떻게 해야 할까? 이런 경우 기존 클래스를 재사용하는 잘 알려진 패턴인 위임 패턴을 사용할 수 있다. 어떤 인터페이스의 구현을 만들고 싶다면 이미 있는 구현의 인스턴스를 가져와서 우리가 만드는 클래스로 가맜고, 필요할 때 인터페이스 메서드 구현을 이 기존 구현 인스턴스에 위임할 수 있다.

 

interface PersonData {
    val name: String
    val age: Int
}

open class Person(
    override val name: String,
    override val age: Int
) : PersonData

data class Book(val title: String, val author: PersonData) {
    override fun toString() = "'$title' by ${author.name}"
}

fun main() {
    val valWatts = Person("Val Watts", 30)
    val introKotlin = Book("Introduction to Kotlin", valWatts)

    println(introKotlin)    // 'Introduction to Kotlin' by Val Watts
}

위 코드에서 작가들이 필명을 사용할 수 있게 허용하자. 이를 통해 작가는 다른 사람인 것처럼 가장할 수 있다.

class Alias(
    private val realIdentity: PersonData,
    private val fakeIdentity: PersonData,
) : PersonData by fakeIdentity

fun main() {
    val valWatts = Person("Val Watts", 30)
    val fakeJohn = Alias(valWatts, Person("John", 25))
    val introJava = Book("Introduction to Java", fakeJohn)
    
    println(introJava)	// 'Introduction to Java' by John
}

 

위임과 객체 식을 조합하면 원래 객체와 약간 다른 구현을 만들 때 유용하다.

fun PersonData.aliased(fakeIdentity: PersonData) =
    object : PersonData by fakeIdentity {
        override val age: Int
            get() = this@aliased.age
    }

fun main() {
    val valWatts = Person("Val Watts", 30)
    val fakeJohn = valWatts.aliased(Person("John", 25))

    println("${fakeJohn.name}, ${fakeJohn.age}")    // John, 30
}
profile
hello

0개의 댓글