반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[코틀린 완벽 가이드] 8-2장 : 추상 클래스와 인터페이스 본문

Kotlin/코틀린 완벽 가이드

[코틀린 완벽 가이드] 8-2장 : 추상 클래스와 인터페이스

어리둥절범고래 2022. 11. 5. 16:43
반응형

2. 추상 클래스와 인터페이스 (클래스 계층 이해하기)


1. 추상 클래스와 추상 멤버

 

추상 클래스는 직접 인스턴스화할 수 없고 다른 클래스의 상위 클래스 역할만 할 수 있는 클래스를 뜻한다. 클래스를 추상 클래스로 만들려면 abstract 라는 변경자 키워드를 붙여야 한다.

abstract class Entity(val name: String)

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

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

 

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

abstract class Entity(val name: String)

// ok: 하위 클래스에서 위임 호출
class Person : Entity {
    constructor(name: String) : super(name)
    constructor(
        firstName: String,
        familyName: String
    ) : super("$firstName $familyName")
}

 

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

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

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

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

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

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

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

2. 인터페이스

 

인터페이스 정의는 interface라는 키워드로 시작한다. 인터페이스는 메서드나 프로퍼티를 포함하지만 추상 클래스와는 다르게 자체적인 인스턴스 상태나 생성자를 만들 수는 없다. 때문에 뒷받침하는 필드가 들어있는 프로퍼티나 초기화 코드, 위임이 붙은 프로퍼티는 금지된다.

interface Vehicle {
    val currentSpeed: Int = 0    // Error
    val maxSpeed by lazy { 100 } // Error
    fun move()
    fun stop()
}

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

interface Car {
    // error: delegated properties are not allowed in interface
    constructor(name: String)
}

인터페이스 멤버는 디폴트가 추상 멤버이다. 명시적으로 abstract를 붙일 수도 있지만 디폴트가 abstract 이기 때문에 불필요하다.

인터페이스는 클래스나 다른 인터페이스의 상위 타입이 될 수 있으며 비추상 클래스가 인터페이스를 상속할 때는 모든 추상 멤버에 대한 구현을 반드시 제공해야 한다. 모든 추상 멤버에 대한 구현을 하지 않았을 경우 Class is not abstract and does not implement abstract member 라는 에러가 발생한다.

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 = 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("Taxing...")
        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
    }
}

인터페이스는 생성자가 없어서 하위 클래스를 초기화할 때 호출해야 할 코드가 없기 때문에 하위 클래스 타입 정의에서 상위 타입에 괄호를 붙이지 않는다.

 

코틀린 인터페이스가 다른 클래스를 상속할 수는 없다. 모든 코틀린 클래스와 인터페이스가 암시적으로 상속하는 것으로 간주되는 Any 클래스는 예외다. 인터페이스 안의 함수와 프로퍼티에 구현을 추가할 수도 있다.

interface Vehicle {
    val currentSpeed: Int
    val isMoving get() = currentSpeed != 0
    fun move()
    fun stop()
    fun report() {
        println(if (isMoving) "Moving at $currentSpeed" else "Still")
    }
}

이런 구현은 암시적으로 열려 있는 것으로 간주되며 상속하는 클래스에서 이런 멤버를 오버라이드할 수 있다. 인터페이스 멤버를 final로 정의하면 컴파일 오류가 발생한다. 하지만 확장 함수나 프로퍼티를 사용하면 final 멤버를 대신할 수 있다.

interface Vehicle {
    val currentSpeed: Int
    val isMoving get() = currentSpeed != 0
    // Modifier 'final' is not applicable inside 'interface
    final fun move()
    fun stop()
    fun report() {
        println(if (isMoving) "Moving at $currentSpeed" else "Still")
    }
}

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 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")
    }
}

둘 이상의 인터페이스를 상속할 때는 ,(콤마)로 구분하여 상속하면 되며 상속받은 멤버를 모두 구현해야 한다.

동일한 시그니처를 가지는 멤버가 들어있는 인터페이스들을 상속하여 합쳐지는 멤버에 대한 구현이 둘 이상인 경우, 컴파일러는 에러를 던지게된다.

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

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

class Amphibia : Car, Ship {
    override fun move() {
        // error: Many supertypes available,
        // please specify the one you mean in angle brackets, e.g. 'super<Foo>'
        super.move()
    }
}

 

이 경우 super를 상위 타입으로 한정시킨 키워드를 사용해야 한다.

class Amphibia : Car, Ship {
    override fun move() {
        super<Car>.move()
        super<Ship>.move()
    }
}

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

 

 

인터페이스에 생성자나 상태를 사용할 수 없다는 제약은 다중 상속을 지원하기 위한 것으로, 주목적은 다이아몬드 상속 문제를 방지하는데 있다.

interface Vehicle {
    val currentSpeed: Int
}

interface Car : Vehicle {
    override val currentSpeed: Int
}

interface Ship : Vehicle {
    override val currentSpeed: Int
}

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

인스턴스의 상태를 허용하여 Vehicle 인터페이스에서 상태 변수로 currentSpeed를 정의할 수 있게 하면, Amphibia 클래스에는 currentSpeed의 두 가지 복사본이 존재하게 된다. 하나는 Ship에서, 다른 하나는 Car에서 상속한 것인데 두 가지 모두 Vehicle에서 상속받은 것이다. 코틀린에서는 인터페이스에서 상태를 허용하지 않으며, 클래스가 인터페이스를 상속할 때 반드시 추상 멤버의 초기화를 구현해야함으로써 이 문제를 피할 수 있다.


3. 봉인된 클래스와 인터페이스

 

이넘 클래스를 사용해 어떤 계산이 성공인지 실패인지 표현할 수 있다.

enum class Result {
    SUCCESS, ERROR
}

fun runComputation() : Result {
    try {
        val a = readLine()?.toInt() ?: return Result.ERROR
        val b = readLine()?.toInt() ?: return Result.ERROR
        
        println("Sum: ${a + b}")
        
        return Result.SUCCESS
    } catch (e: java.lang.NumberFormatException) {
        return Result.ERROR
    }
}

fun main() {
    val message = when (runComputation()) {
        Result.SUCCESS -> "Completed successfully"
        Result.ERROR -> "Error!"
    }
    println(message)
}

일반적으로 계산의 성공인 경우에는 생성된 결과가 들어있고, 실패인 경우에는 실패 이유에 대한 정보가 들어있을 수 있다. 이와 같은 개념은 클래스 계층을 활용해 모델링할 수 있다. 클래스 계층의 루트에 있는 추상 클래스는 전체 개념(Result)을 표현하고, 하위 클래스들은 특정 변종(Success, Error)을 표현한다. 위 예제를 수정하여 Success와 Error의 경우를 표현하는 클래스를 추가하면 다음과 같다.

abstract class Result {
    class Success(val value: Any) : Result() {
        fun showResult() {
            println(value)
        }
    }
    class Error(val message: String) : Result() {
        fun throwException() {
            throw Exception(message)
        }
    }
}

fun runComputation() : Result {
    try {
        val a = readLine()?.toInt() ?: return Result.Error("Missing first argument")
        val b = readLine()?.toInt() ?: return Result.Error("Missing second argument")

        return Result.Success(a + b)
    } catch (e: java.lang.NumberFormatException) {
        return Result.Error(e.message ?: "Invalid input")
    }
}

fun main() {
    val message = when (val result = runComputation()) {
        is Result.Success -> "Completed successfully: ${result.value}"
        is Result.Error -> "Error: ${result.message}"
        else -> return
    }
    println(message)
}

이 구현은 Result의 변종을 Success과 Error만으로 제한하지 못한다. 새로운 하위 클래스를 추가할 수 있기 때문이다. 이런 서브 클래싱이 가능하다는 점이 when 식에서 else가 필요한 이유이기도 하다. 컴파일러는 result 변수가 Success나 Error 인스턴스만 담는다는 사실을 보장할 수 없기 때문에 위 예제와 같이 else 추가를 강제한다.

 

코틀린에서는 봉인된 클래스(sealed class)나 인터페이스를 통해 이런 문제를 극복할 수 있다. 기본적으로 봉인된 클래스는 추상 클래스이며 비공개(private)이다. 그래서  직접 인스턴스를 만들 수 없고 같은 패키지 안에서만 하위 클래스를 생성할 수 있다. 가시성을 다른 값으로 변경하면 컴파일 오류가 난다.

 

코틀린 버전1.4.3 업데이트 내용
"Sealed 클래스는 이제 보다 유연한 계층 구조를 형성할 수 있습니다. 동일한 컴파일 단위 및 동일한 패키지의 모든 파일에 하위 클래스가 있을 수 있습니다. 이전에는 모든 하위 클래스가 동일한 파일에 나타나야 했습니다."
1.4.3 미만 버전에서는 봉인된 클래스 안에서만 하위 클래스를 생성할 수 있어서 강력한 제한을 할 수 있었지만. 2021년2월3일 버전1.4.3이 나온 이후로는 봉인된 클래스 내부가 아닌 같은 패키지 내에서만 생성 가능하도록 변경되었습니다.

 

sealed class Result {
    class Success(val value: Any) : Result() {
        fun showResult() {
            println(value)
        }
    }
    class Error(val message: String) : Result() {
        fun throwException() {
            throw Exception(message)
        }
    }
}

fun runComputation() : Result {
    try {
        val a = readLine()?.toInt() ?: return Result.Error("Missing first argument")
        val b = readLine()?.toInt() ?: return Result.Error("Missing second argument")

        return Result.Success(a + b)
    } catch (e: java.lang.NumberFormatException) {
        return Result.Error(e.message ?: "Invalid input")
    }
}

fun main() {
    val message = when (val result = runComputation()) {
        is Result.Success -> "Completed successfully: ${result.value}"
        is Result.Error -> "Error: ${result.message}"
        // else를 쓰지 않아도 된다.
    }
    println(message)
}

4. 위임

 

위임은 하나의 클래스를 다른 클래스에 위임하도록 선언하여 위임된 클래스가 가지는 인터페이스 메소드를 참조 없이 호출할 수 있도록 생성해주는 기능이다.

interface A {
    fun test() {
        println("test")
    }
}
class B : A {
    override fun test() {
        println("override test")
    }
}
class C : A by B()

fun main() {
    val c = C()
    c.test() // override test
}

interface A를 구현하고 있는 class B가 있고, class B를 C로 위임할 수 있다. 즉, C는 B가 가지는 모든 A의 메소드를 가지며, 이를 클래스 위임(Class delegation)이라고 한다.

 

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 newIdentity: PersonData 
) : PersonData {
    override val name: String
        get() = newIdentity.name
    override val age: Int
        get() = newIdentity.age
}

이 클래스를 사용해 가명을 만들 수 있다. 이를 위임을 사용해 간단하게 할 수 있다. 상위 인터페이스 이름 뒤에 by 키워드를 붙이고 위임할 인스턴스를 쓰면 된다.

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

이렇게 되면 Alias가 PersonData 인터페이스에서 상속한 모든 멤버는 newIdentity 인스턴스에 있는 이름과 시그니처가 같은 메서드를 통해 구현된다. 구현을 바꾸고 싶다면 직접 멤버를 오버라이드하면 된다.

class Alias(
    private val realIdentity: PersonData,
    private val newIdentity: PersonData
) : PersonData by newIdentity {
    override val age: Int
        get() = realIdentity.age
    // newIdentity를 위임받아 this로 받을 수 있다.
    override fun toString() = "${this.name} ${this.age}" 
}

fun main() {
    val valWatts = Person("Val Watts", 30)
    val johnDoe = Alias(valWatts, Person("John Doe", 25))
    println(johnDoe.age)
    println(johnDoe) // John Doe 25
}

 

반응형