개발/코틀린

(코틀린) 클래스 계층 이해하기

DinoDev 2022. 11. 6. 11:04
728x90
반응형

클래스 상속 개념을 이해하고 하위 클래스를 정의하는 방법을 터득하며 abstract class, interface, 클래스 위임을 통해 복잡한 클래스 계층 구조를 설계하는 방법에 대해서 알아봅니다. 그리고 강력한 스마트 캐스팅을 사용할 수 있는 타입 검사를 가능하게 해주는 대수적 데이터 타입(Alegebraic Data Type, ADT)인 Sealed class에 대해서도 알아봅니다.

 

상속

객체지향 언어에서 상속은 도메인 개념에 있는 is-a 관계를 표현합니다. is-a는 사람은 동물이고, 자동차는 이동수단과 같은 개념을 의미합니다. 여기서 동물과 이동수단은 상위 클래스(superclass), 기반클래스(base class)라고 부르며 사람과 자동차는 하위 클래스(subclass), 파생클래스(derived class)라고 부릅니다. 하위 클래스는 상위 클래스의 속성들을 가지고 있어서 상위 클래스의 필드나 함수들을 하위 클래스에서도 사용이 가능합니다.

하위 클래스

클래스를 상속할 때는 주생성자 뒤에 :을 넣고 상위 클래스 이름을 넣으면 됩니다.

클래스를 상속시키기 위해서는 상위 클래스가 open 클래스여야 합니다. 만약 open class가 아니면 에러가 발생합니다.

상속(inheritance) 또는 재정의(override)하기 좋은 형태는 변경이 쉽기 때문에 원래 목적을 잃는 경우가 있습니다.
그래서 불변형태를 지향하기 위해서 기본적으로 final형태이며 필요에 의해서 open형태로 변경을 하도록 되어 있습니다.
open class Vehicle {
    var currentSpeed = 0

    fun start() {
        println("I'm moving")
    }

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

open class FlyingVehicle : Vehicle() {
    fun takeOff() {
        println("Taking off")
    }

    fun land() {
        println("Landed")
    }
}

class Aircraft(val seats: Int) : FlyingVehicle()

// This type is final, so it cannot be inherited from
class Airbus(val seats: Int) : Aircraft(seats)

fun main() {
    val aircraft = Aircraft(100)
    val vehicle: Vehicle = aircraft // 상위 타입으로 암시적으로 변환
    vehicle.start() // Vehicle의 메서드 호출
    vehicle.stop() // Vehicle의 메서드 호출
    aircraft.stop() // Vehicle의 메서드 호출
    aircraft.takeOff() // FlyingVehicle의 메서드 호출
    aircraft.land() // FlyingVehicle의 메서드 호출
    aircraft.stop() // Vehicle의 메서드 호출
    println(aircraft.seats) // Aircrafe 자체 프로퍼티 접근
}

특정 클래스는 상속을 제한하기 때문에 open으로 변경할 수 없습니다.

// Modifier 'open' is incompatible with 'data'
open data class Person(val name: String, val age: Int)

인라인 클래스는 상속할수도 없고 상속될수도 없습니다.

open class MyBase

// Value classes can be only final
@JvmInline
open value class MyString(val value: String)

// Value class cannot extend classes
@JvmInline
value class MyStringInherited(val value: String): MyBase()

상속이 제공하는 강력한 기능은 임의 다형성(ad-hoc polymorphism)이다. 상위 클래스의 멤버의 여러 다른 구현을 하위 클래스에서 제공할 수 있게 합니다. 재정의해서 다르게 구현 한 것은 실제 인스턴스가 가지고 있는 멤버의 것이 실행됩니다.

open class Vehicle {
    open fun start() {
        println("I'm moving")
    }

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

class Car : Vehicle() {
    override fun start() {
        println("I'm riding")
    }
}

class Boat : Vehicle() {
    override fun start() {
        println("I'm sailing")
    }
}

fun startAndStop(vehicle: Vehicle) {
    vehicle.start()
    vehicle.stop()
}

fun main() {
    startAndStop(Car())
    // I'm riding
    // Stopped
    startAndStop(Boat())
    // I'm sailing
    // Stopped
}

하지만 확장은 override개념이 아니기 때문에 현재의 타입에 맞는 멤버가 호출 됩니다.

메서드 호출은 런타임에 의해 동적으로 결정되고, 확장은 정적 타입에 의해 결정됩니다.

open class Vehicle {
    open fun start() {
        println("I'm moving")
    }
}

fun Vehicle.stop() {
    println("Stopped moving")
}

class Car : Vehicle() {
    override fun start() {
        println("I'm riding")
    }
}

fun Car.stop() {
    println("Stopped riding")
}

fun main() {
    val vehicle: Vehicle = Car()
    vehicle.start() // I'm riding <- 실제 객체인 Car의 함수 호출
    vehicle.stop() // Stopped moving <- Vehicle의 stop() 함수 호출
}

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

open class Vehicle {
    open fun start(speed: Int) {
        println("I'm moving at $speed")
    }
}

class Car : Vehicle() {
    // 'start' overrides nothing
    // 파라미터가 달라져서 override 할 수 없음, 다른 함수로 인식함
    override fun start() {
        println("I'm riding")
    }
}

반환 타입을 더 하위 타입으로 바꿀 수 있습니다.

open class Vehicle {
    open fun start(): String? = null
}

open class Car : Vehicle() {
    override fun start(): String {
        return "I'm riding a car"
    }
}

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

open class Vehicle {
    open fun start() {
        println("I'm moving")
    }
}

open class Car : Vehicle() {
    final override fun start() {
        println("I'm riding a car")
    }
}

class Bus : Car() {
    // 'start' in 'Car' is final and cannot be overridden
    override fun start() {
        println("I'm riding a bus")
    }
}

프로퍼티도 오버라이드 할 수 있습니다.

val로 선언된 프로퍼티는 var로 오버라이드 할 수 있지만 var로 선언된 프로퍼티는 val로 오버라이드 할 수 없습니다.

open class Entity {
    open val familyName: String = ""
    open val firstName: String = ""
    open var age: Int = 0
}

class Person(override val familyName: String) : Entity() {
    override val firstName: String
        get() = ""
    // Val-property cannot override var-property
    // public open var age: Int defined in Entity
    override val age: Int
        get() = 0
}

특정 멤버의 영역을 하위 클래스의 영역으로만 제한하는 protected 접근 제어자가 있습니다.

open class Vehicle {
    protected open fun onStart() {
        // no-op
    }

    fun start() {
        println("Starting up...")
        onStart()
    }
}

class Car : Vehicle() {
    override fun onStart() {
        println("It's a car")
    }
}

fun main() {
    val car = Car()
    car.start()
    // Cannot access 'onStart': it is protected in 'Car'
    car.onStart()
}

하위 클래스에서 오버라이드를 했을 때 원본을 사용해야 하는 경우가 있는데 이 때 super 키워드를 사용해서 접근 할 수 있습니다.

open class Vehicle {
    open fun start(): String? = "I'm moving"
}

open class Car : Vehicle() {
    override fun start(): String = super.start() + " in a car"
}

fun main() {
    println(Car().start()) // I'm moving in a car
}

하위 클래스 초기화

하위 클래스의 인스턴스를 생성하는 동안 상위 클래스에 정의된 초기화 코드를 호출해야 상위 클래스의 상태를 하위 클래스에서 사용할 수 있습니다. 그러기 때문에 상위 클래스가 먼저 초기화 되고 하위 클래스가 초기화 되어야 합니다.

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

open class Car : Vehicle() {
    init {
        println("Initializing Car")
    }
}

class Truck : Car() {
    init {
        println("Initializing Truck")
    }
}

fun main() {
    Truck()
    // Initializing Vehicle
    // Initializing Car
    // Initializing Truck
}

상위 클래스 생성자에 파라미터가 있는 경우에는 상위 클래스 생성자에 파라미터를 같이 넣어줘야 합니다.

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

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

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

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

}

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

// 3
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
    }
}

하지만 상위 클래스에 주생성자가 있다면 하위 클래스에서는 부생성자가 상위 클래스를 위임 호출 할 수 없습니다.

만약 상위 클래스의 생성자가 여러개가 필요하다면 주생성자는 만들지 않고 부생성자만 만들어서 사용하면 됩니다.

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

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

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

하위 클래스를 초기화 하는 단계에서 this 누출(leaking this)가 발생 할 수 있습니다.

상위 클래스를 초기화 하는 단계에서 특정 함수가 하위 클래스에서 재정의 되면서 하위 클래스의 멤버에 접근하면 아직 초기화 되지 않은 하위 클래스의 멤버 때문에 leaking이 발생합니다.

open class Person(val name: String, val age: Int) {
    init {
        // Calling non-final function showInfo in constructor
        showInfo()
    }

    open fun showInfo() {
        println("$name, $age")
    }
}

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

fun main() {
    Student("SeokJun Jeong", 31, "university") // SeokJun Jeong 31 (student at null)
}

타입 검사와 캐스팅

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

타입 검사를 위해서 is를 사용하고 반대로 타입이 아닌지 검사하기 위해서 !is를 사용합니다.

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

    for (obj in objects) {
        println(obj is Int) // false, true, false, true
    }

    null is Int // false
    null is String? // true
    
    val o: Any = ""
    o !is Int // true
    o !is String // false
    
    12 is String // Incompatible types: String and Int <- 컴파일 시점에 알 수 있는 오류
}

if나 when으로 type을 검사하면 내부 분기에서는 스마트 캐스팅 되어서 별도로 캐스팅 하지 않아도 해당 타입으로 사용할 수 있습니다.

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

    var sum = 0
    for (obj in objects) {
        if (obj is Int) {
            sum += obj // <- obj를 is로 검사했기 때문에 if 내부에서는 Int type으로 스마트 캐스팅 되어 사용할 수 있음
        }
    }
    println(sum) // 6

    var sum2 = 0
    for (obj in objects) {
        when (obj) {
            is Int -> sum2 += obj
            is String -> sum2 += obj.toInt()
        }
    }
    println(sum) // 10
}

스마트 캐스팅은 컴파일 시점에 타입이 변경되지 않을 것을 확신 할 때만 가능합니다.
중간에 값이 변하거나 타입이 변경될 수 있는 경우에는 스마트 캐스팅이 동작하지 않습니다.

class Holder {
    val o: Any
        get() = ""
}

fun main() {
    val o: Any by lazy { 123 }
    if (o is Int) {
        println(o * 2) // 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) // Smart cast to 'String' is impossible, because 'holder.o' is a property that has open or custom getter
    }
}

강제로 타입을 변경하기 위해서는 as를 사용하고 안전한 타입 변경을 위해서는 as?를 사용합니다.

as로 타입을 변경을 못하는 경우 ClassCastException이 발생하고 as?로 타입을 변경하지 못하는 경우 null을 반환합니다.

fun main() {
    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) // Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
    println(o as? String) // null
    println(null as String) // Exception in thread "main" java.lang.NullPointerException: null cannot be cast to non-null type kotlin.String
}

공통 메서드

kotlin.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가 ==나 !=로 표현될 수 있다는 뜻입니다.

hashCode는 HashSet이나 HashMap등 일부 컬렉션 타입에 사용되고 ===로 참조 동등성을 위해 사용합니다.

toString은 class를 String으로 변환하는 기본 함수 입니다.

class Person(val firstName: String, val familyName: String, val age: Int) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Person) return false

        if (firstName != other.firstName) return false
        if (familyName != other.familyName) return false
        if (age != other.age) return false

        return true
    }

    override fun hashCode(): Int {
        var result = firstName.hashCode()
        result = 31 * result + familyName.hashCode()
        result = 31 * result + age
        return result
    }

    override fun toString(): String {
        return "Person(firstName='$firstName', familyName='$familyName', age=$age)"
    }
}

추상 클래스와 인터페이스

인스턴스를 만들 수는 없지만 추상적인 개념으로 클래스를 정의 할 수 있는 형태가 있습니다.

추상 클래스와 추상 멤버

추상 클래스는 직접 인스턴스화 할 수 없고 다른 클래스의 상위 클래스 역할만 할 수 있는 클래스입니다. 추상 클래스를 만들기 위해서는 abstract 키워드를 붙이면 됩니다.

abstract class Entity(val name: String)

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

fun main() {
    val entity = Entity("Unknown") // Cannot create an instance of an abstract class
    val person = Person("정석준", 31)
}

추상 클래스에는 추상멤버를 정의 할 수 있습니다. 하위 클래스에서는 반드시 오버라이드 해서 구현해야 합니다.

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: Double
        get() = 2 * radius
    override val width: Double
        get() = diameter
    override val height: Double
        get() = diameter

    override fun area(): Double {
        return PI * radius * radius
    }
}

class Rectangle(
    override val width: Double,
    override val height: Double,
) : Shape() {
    override fun area(): Double {
        return 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
}

추상 멤버 자체는 구현을 가질 수 없으므로 몇가지 제약이 있습니다.

  • 추상 프로퍼티를 초기화 할 수 없고 명시적인 접근자나 by 절을 추가할 수 없습니다.
  • 추상 함수에는 본문이 없어야합니다.
  • 추상 프로퍼티와 추상 함수 모두 명시적으로 반환 타입을 적어야 합니다. 본문이나 초기화 코드가 없으므로 타입을 추론할 수 없기 때문입니다.

추상 멤버는 암시적으로 열려있어서 open을 지정하지 않아도 됩니다.

인터페이스

인터페이스는 메서드나 프로퍼티를 포함하지만 자체적인 인스턴스 상태나 생성자를 만들 수 없는 타입입니다.

클래스와 다르게 interface 키워드를 사용합니다.

클래스와 동일하게 인터페이스도 :를 사용해서 구현을 하고 다른점은 ()가 없다는 것 입니다.

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

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

class Car : Vehicle {
    override var currentSpeed: Int = 0
        private set

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

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

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

    override var currentHeight = 0
        private set

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

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

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

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

인터페이스 안의 함수와 프로퍼티에 구현부분을 추가할 수 있습니다.

interface Vehicle {
    val currentSpeed: Int
    val isMoving: Boolean
        get() = currentSpeed != 0

    fun move()

    fun stop()

    fun report() {
        println(if (isMoving) "Moving at $currentSpeed" else "Still")
    }
}

interface는 final 멤버를 가지면 컴파일 에러가 발생합니다.

하지만 확장 함수나 확장 프로퍼티를 사용하면 final 멤버를 대신할 수 있습니다.

interface Vehicle {
    // Modifier 'final' is not applicable inside 'interface'
    final fun move() {}

    val currentSpeed: Int
}

fun Vehicle.relativeSpeed(vehicle: Vehicle) = currentSpeed - vehicle.currentSpeed

인터페이스를 인터페이스로 상속하는 경우에도 함수를 오버라이드 할 수 있습니다.

interface Vehicle {
    fun move() {
        println("I'm moving")
    }
}

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

인터페이스 내부에 상태를 정의할 수 없으므로 인터페이스 안에는 뒷받침하는 필드가 들어있는 프로퍼티를 정의할 수 없습니다.

interface Vehicle {
    val currentSpeed = 0 // Property initializers are not allowed in interfaces
    val maxSpeed by lazy { 100 } // Delegated properties are not allowed in interfaces
}

인터페이스는 암묵적으로 추상 타입입니다. 하지만 추상 클래스와 달리 인터페이스에는 생성자가 금지되어 있습니다.

// An interface may not have a constructor
interface Person(val name: String)

interface Vehicle {
    // An interface may not have a constructor
    constructor(name: String)
}

인터페이스는 다중 상속을 지원합니다. 

interface Car {
    fun ride()
}

interface Aircraft {
    fun fly()
}

interface Ship {
    fun sail()
}

interface FlyingCar : Car, Aircraft

class Transformer : FlyingCar, Ship {
    override fun ride() {
        println("I'm riding")
    }

    override fun fly() {
        println("I'm flying")
    }

    override fun sail() {
        println("I'm sailing")
    }
}

만약 인터페이스에 함수가 구현되어 있고 함수이름이 같은 인터페이스를 다중 구현한 경우 super<T>의 방식으로 접근할 수 있습니다.

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

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

class Amphibia : Car, Ship {
    override fun move() {
        super<Car>.move() // Car의 move()함수 호출
        super<Ship>.move() // Ship의 move()함수 호출
    }
}

fun main() {
    Amphibia().move()
    // I'm riding
    // I'm sailing
}

인터페이스의 다중상속으로 인해 다이아몬드 상속이 생기는 문제가 발생할 수 있습니다. 코틀린은 인터페이스에서 상태를 허용하지 않음으로써 이런 문제를 회피할 수 있습니다.

interface Vehicle {
    val currentSpeed: Int
}

interface Car : Vehicle
interface Ship : Vehicle

class Amphibia : Car, Ship {
    override var currentSpeed: Int = 0
        private set
}

sealed class와 sealed interface

코틀린에서는 하위 클래스의 범위를 제한 할 수 있는 sealed class와 sealed interface가 존재합니다.

이것은 enum class와 비슷하게 when의 else를 줄일 수 있는 방식입니다.

sealed class Result {
    class Success(val value: Any) : Result()
    class Error(val message: String) : Result()
}

fun getMessage(result: Result): String {
    return when (result) {
        is Result.Success -> "Completed successfully: ${result.value}"
        is Result.Error -> result.message
        // else -> {} else가 필요없음
    }
}

sealed class는 abstract class이기도 해서 그 자체로 객체를 만들 수 없습니다.

sealed class Result {
    class Success(val value: Any) : Result()
    class Error(val message: String) : Result()
}

fun main() {
    val result = Result() // Cannot access '<init>': it is protected in 'Result', Sealed types cannot be instantiated
}

sealed class의 하위 클래스가 final이 아닌 경우 이를 상속한 하위 클래스가 있을 수 있습니다.

sealed class Result {
    class Success(val value: Any) : Result()
    open class Error(val message: String) : Result()
}

class FatalError(message: String) : Result.Error(message)

sealed class는 다른 sealed class를 상속 할 수 있습니다.

sealed class Result

class Success(val value: Any) : Result()

sealed class Error : Result() {
    abstract val message: String
}

class ErrorWithException(val exception: Exception) : Error() {
    override val message: String
        get() = exception.message ?: ""
}

class ErrorWithMessage(override val message: String) : Error()

fun getMessage(result: Result): String {
    return when (result) {
        is ErrorWithException -> result.message
        is ErrorWithMessage -> result.message
        is Success -> "Completed successfully: ${result.value}"
    }
}

위임(Delegate)

코틀린은 final로 변경할 수 없는게 기본인데 이런 것은 클래스를 설계 할 때 좀 더 안전하게 만들수 있게 합니다.

하지만 특정 경우에 클래스를 확장하거나 변경할 필요가 있는데 디자인패턴의 위임패턴을 사용하게 되면 가능합니다.

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

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

class Alias(
    private val realIdentity: PersonData,
    private val newIdentity: PersonData,
) : PersonData {
    // 기존 스타일의 delegate pattern
    override val name: String
        get() = newIdentity.name
    override val age: Int
        get() = newIdentity.age
}

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

fun main() {
    val valWatts = Person("Val Watts", 30)
    val johnDoe = Alias(valWatts, Person("John Doe", 25))
    val introKotlin = Book("Introduction to Kotlin", johnDoe)
    println(introKotlin) // 'Introduction to Kotlin' by John Doe
}

하지만 직접 interface의 멤버들을 override하고 개발자가 직접 넣어주는 방식은 휴먼 에러를 발생 시킬수 있습니다.

코틀린에서는 by 키워드를 이용한 delegate 기능을 제공합니다.

class Alias(
    private val realIdentity: PersonData,
    newIdentity: PersonData,
) : PersonData by newIdentity
728x90
반응형

'개발 > 코틀린' 카테고리의 다른 글

(코틀린) 도메인 특화 언어(DSL)  (0) 2022.11.27
(코틀린) 제네릭스  (0) 2022.11.12
(코틀린) 컬렉션  (0) 2022.10.30
(코틀린) 특별한 클래스 사용하기  (0) 2022.10.23
(코틀린) 함수형 프로그래밍  (0) 2022.10.10