Open 및 특수 클래스

Open 및 특수 클래스 (Open and Special Classes)

이 장에서는 open 클래스가 무엇인지, 인터페이스와 어떻게 함께 동작하는지, 그리고 Kotlin에서 사용할 수 있는 다른 특수한 종류의 클래스들을 배워 볼게요.

출처: Kotlin 공식문서

본문

Open 클래스 (Open classes)

인터페이스나 추상 클래스를 쓸 수 없다면, 클래스를 open으로 선언해 상속될 수 있도록 명시적으로 만들 수 있어요. 이렇게 하려면 클래스 선언 앞에 open 키워드를 쓰면 돼요:

open class Vehicle(val make: String, val model: String)

다른 클래스에서 상속받는 클래스를 만들려면 클래스 헤더 뒤에 콜론을 붙이고, 상속받고 싶은 부모 클래스의 생성자를 호출하면 돼요. 이 예시에서 Car 클래스는 Vehicle 클래스에서 상속받아요:

open class Vehicle(val make: String, val model: String)

class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model)

fun main() {
    // Creates an instance of the Car class
    val car = Car("Toyota", "Corolla", 4)

    // Prints the details of the car
    println("Car Info: Make - ${car.make}, Model - ${car.model}, Number of doors - ${car.numberOfDoors}")
    // Car Info: Make - Toyota, Model - Corolla, Number of doors - 4
}

일반 클래스 인스턴스를 만들 때와 마찬가지로, 클래스가 부모 클래스에서 상속받으면 부모 클래스 헤더에 선언된 모든 파라미터를 초기화해야 해요. 그래서 이 예시에서 Car 클래스의 car 인스턴스는 부모 클래스의 파라미터인 makemodel을 초기화해요.

상속받은 동작 오버라이드하기 (Overriding inherited behavior)

클래스에서 상속받되 동작 중 일부를 바꾸고 싶다면, 상속받은 동작을 **오버라이드(override)**할 수 있어요.

기본적으로 부모 클래스의 멤버 함수나 프로퍼티는 오버라이드할 수 없어요. 추상 클래스에서와 마찬가지로 특별한 키워드를 추가해야 해요.

멤버 함수 (Member functions)

부모 클래스의 함수가 오버라이드될 수 있게 하려면, 부모 클래스에서 그 함수 선언 앞에 open 키워드를 쓰면 돼요:

open fun displayInfo() {}

상속받은 멤버 함수를 오버라이드하려면, 자식 클래스에서 함수 선언 앞에 override 키워드를 쓰면 돼요:

override fun displayInfo() {}

예를 들어:

open class Vehicle(val make: String, val model: String) {
    open fun displayInfo() {
        println("Vehicle Info: Make - $make, Model - $model")
    }
}

class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model) {
    override fun displayInfo() {
        println("Car Info: Make - $make, Model - $model, Number of Doors - $numberOfDoors")
    }
}

fun main() {
    val car1 = Car("Toyota", "Corolla", 4)
    val car2 = Car("Honda", "Civic", 2)

    // Uses the overridden displayInfo() function
    car1.displayInfo()
    // Car Info: Make - Toyota, Model - Corolla, Number of Doors - 4
    car2.displayInfo()
    // Car Info: Make - Honda, Model - Civic, Number of Doors - 2
}

이 예시는:

  • Vehicle 클래스에서 상속받는 Car 클래스의 인스턴스 두 개, car1car2를 만들어요.
  • Car 클래스에서 displayInfo() 함수를 오버라이드해 문의 개수도 출력해요.
  • car1car2 인스턴스에서 오버라이드된 displayInfo() 함수를 호출해요.
프로퍼티 (Properties)

Kotlin에서 open 키워드로 프로퍼티를 상속 가능하게 만들고 나중에 오버라이드하는 것은 흔한 관행이 아니에요. 대부분의 경우 프로퍼티가 기본적으로 상속 가능한 추상 클래스나 인터페이스를 사용하죠.

open 클래스 안의 프로퍼티는 자식 클래스에서 접근할 수 있어요. 일반적으로는 새 프로퍼티로 오버라이드하기보다 직접 접근하는 게 더 좋아요.

예를 들어, 나중에 오버라이드하고 싶은 transmissionType 프로퍼티가 있다고 해 볼게요. 프로퍼티를 오버라이드하는 문법은 멤버 함수를 오버라이드하는 것과 완전히 같아요. 이렇게 할 수 있어요:

open class Vehicle(val make: String, val model: String) {
    open val transmissionType: String = "Manual"
}

class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model) {
    override val transmissionType: String = "Automatic"
}

하지만 이는 좋은 관행이 아니에요. 대신 상속 가능한 클래스의 생성자에 프로퍼티를 추가하고, Car 자식 클래스를 만들 때 값을 선언할 수 있어요:

open class Vehicle(val make: String, val model: String, val transmissionType: String = "Manual")

class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model, "Automatic")

프로퍼티를 오버라이드하는 대신 직접 접근하면 코드가 더 단순하고 읽기 쉬워져요. 프로퍼티를 부모 클래스에 한 번 선언하고 생성자를 통해 값을 전달하면, 자식 클래스에서 불필요한 오버라이드를 할 필요가 없어져요.

클래스 상속과 클래스 동작 오버라이드에 대한 자세한 내용은 상속(Inheritance) 문서를 참고하세요.

Open 클래스와 인터페이스 (Open classes and interfaces)

클래스 하나에서 상속받고 여러 인터페이스를 구현하는 클래스를 만들 수 있어요. 이 경우 콜론 뒤에 인터페이스를 나열하기 전에 부모 클래스를 먼저 선언해야 해요:

// Define interfaces
interface EcoFriendly {
    val emissionLevel: String
}

interface ElectricVehicle {
    val batteryCapacity: Double
}

// Parent class
open class Vehicle(val make: String, val model: String)

// Child class
open class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model)

// New class that inherits from Car and implements two interfaces
class ElectricCar(
    make: String,
    model: String,
    numberOfDoors: Int,
    val capacity: Double,
    val emission: String
) : Car(make, model, numberOfDoors), EcoFriendly, ElectricVehicle {
    override val batteryCapacity: Double = capacity
    override val emissionLevel: String = emission
}

특수 클래스 (Special classes)

추상, open, 데이터 클래스 외에도, Kotlin에는 특정 동작을 제한하거나 작은 객체를 만드는 성능 영향을 줄이는 등 다양한 목적을 위해 설계된 특수한 종류의 클래스가 있어요.

Sealed 클래스 (Sealed classes)

상속을 제한하고 싶을 때가 있어요. 이럴 때 sealed 클래스를 쓰면 돼요. Sealed 클래스는 특수한 종류의 추상 클래스예요. 클래스를 sealed로 선언하면, 같은 패키지 안에서만 그로부터 자식 클래스를 만들 수 있어요. 이 범위 밖에서는 sealed 클래스에서 상속받을 수 없어요.

TIP: 패키지는 일반적으로 디렉터리 안에 있는, 관련 클래스와 함수를 담은 코드의 모음이에요. Kotlin의 패키지에 대해 자세히 알아보려면 패키지와 import(Packages and imports) 문서를 참고하세요.

sealed 클래스를 만들려면 sealed 키워드를 쓰면 돼요:

sealed class Mammal

Sealed 클래스는 when 식과 결합할 때 특히 유용해요. when 식을 쓰면 모든 가능한 자식 클래스에 대한 동작을 정의할 수 있어요. 예를 들어:

sealed class Mammal(val name: String)

class Cat(val catName: String) : Mammal(catName)
class Human(val humanName: String, val job: String) : Mammal(humanName)

fun greetMammal(mammal: Mammal): String {
    when (mammal) {
        is Human -> return "Hello ${mammal.name}; You're working as a ${mammal.job}"
        is Cat -> return "Hello ${mammal.name}"   
    }
}

fun main() {
    println(greetMammal(Cat("Snowy")))
    // Hello Snowy
}

이 예시에서:

  • 생성자에 name 파라미터를 가진 Mammal sealed 클래스가 있어요.
  • Cat 클래스는 Mammal sealed 클래스에서 상속받아, 자신의 생성자에 있는 catName 파라미터를 Mammal 클래스의 name 파라미터로 사용해요.
  • Human 클래스는 Mammal sealed 클래스에서 상속받아, 자신의 생성자에 있는 humanName 파라미터를 Mammal 클래스의 name 파라미터로 사용해요. 또한 생성자에 job 파라미터도 있어요.
  • greetMammal() 함수는 Mammal 타입의 인자를 받아 문자열을 돌려줘요.
  • greetMammal() 함수 본문 안에는 is 연산자로 mammal의 타입을 확인해 어떤 동작을 수행할지 결정하는 when 식이 있어요.
  • main() 함수는 Cat 클래스의 인스턴스와 Snowy라는 name 파라미터로 greetMammal() 함수를 호출해요.

TIP: 이 투어에서 is 연산자는 널 안전성(Null safety) 장에서 더 자세히 다뤄요.

sealed 클래스와 권장 사용 사례에 대한 자세한 내용은 sealed 클래스와 인터페이스(Sealed classes and interfaces) 문서를 참고하세요.

Enum 클래스 (Enum classes)

Enum 클래스는 클래스 안에서 유한한 서로 다른 값들의 집합을 표현하고 싶을 때 유용해요. Enum 클래스는 enum 상수(constant)를 담고 있는데, 이 상수들은 각각 enum 클래스 자신의 인스턴스예요.

enum 클래스를 만들려면 enum 키워드를 써요:

enum class State

프로세스의 다양한 상태를 담는 enum 클래스를 만들고 싶다고 해 볼게요. 각 enum 상수는 쉼표 ,로 구분해야 해요:

enum class State {
    IDLE, RUNNING, FINISHED
}

State enum 클래스는 IDLE, RUNNING, FINISHED enum 상수를 가져요. enum 상수에 접근하려면 클래스 이름 뒤에 .과 enum 상수 이름을 붙이면 돼요:

val state = State.RUNNING

이 enum 클래스를 when 식과 함께 쓰면 enum 상수의 값에 따라 수행할 동작을 정의할 수 있어요:

enum class State {
    IDLE, RUNNING, FINISHED
}

fun main() {
    val state = State.RUNNING
    val message = when (state) {
        State.IDLE -> "It's idle"
        State.RUNNING -> "It's running"
        State.FINISHED -> "It's finished"
    }
    println(message)
    // It's running
}

Enum 클래스는 일반 클래스처럼 프로퍼티와 멤버 함수를 가질 수 있어요.

예를 들어 HTML을 다루면서 몇 가지 색을 담는 enum 클래스를 만들고 싶다고 해 볼게요. 각 색이 RGB 값을 16진수로 담는 rgb라는 프로퍼티를 가지길 원해요. enum 상수를 만들 때 이 프로퍼티로 초기화해야 해요:

enum class Color(val rgb: Int) {
    RED(0xFF0000),
    GREEN(0x00FF00),
    BLUE(0x0000FF),
    YELLOW(0xFFFF00)
}

NOTE: Kotlin은 16진수를 정수로 저장하므로 rgb 프로퍼티는 String 타입이 아니라 Int 타입이에요.

이 클래스에 멤버 함수를 추가하려면 세미콜론 ;으로 enum 상수와 구분해야 해요:

enum class Color(val rgb: Int) {
    RED(0xFF0000),
    GREEN(0x00FF00),
    BLUE(0x0000FF),
    YELLOW(0xFFFF00);

    fun containsRed() = (this.rgb and 0xFF0000 != 0)
}

fun main() {
    val red = Color.RED
    
    // Calls containsRed() function on enum constant
    println(red.containsRed())
    // true

    // Calls containsRed() function on enum constants via class names
    println(Color.BLUE.containsRed())
    // false
  
    println(Color.YELLOW.containsRed())
    // true
}

이 예시에서 containsRed() 멤버 함수는 this 키워드를 사용해 enum 상수의 rgb 프로퍼티 값에 접근하고, 16진수 값의 첫 비트가 FF를 포함하는지 확인해 불리언 값을 돌려줘요.

자세한 내용은 Enum 클래스(Enum classes) 문서를 참고하세요.

인라인 값 클래스 (Inline value classes)

때로는 코드에서 클래스로 작은 객체를 만들어 잠깐만 사용하고 싶을 때가 있어요. 이 방식은 성능에 영향을 줄 수 있어요. **인라인 값 클래스(inline value class)**는 이런 성능 영향을 피하는 특수한 종류의 클래스예요. 다만 값만 담을 수 있어요.

인라인 값 클래스를 만들려면 value 키워드와 @JvmInline 어노테이션을 써요:

@JvmInline
value class Email

TIP: @JvmInline 어노테이션은 컴파일될 때 Kotlin이 코드를 최적화하도록 지시해요. 자세한 내용은 어노테이션(Annotations) 문서를 참고하세요.

인라인 값 클래스는 클래스 헤더에서 초기화된 프로퍼티를 하나만 가져야 해요.

이메일 주소를 담는 클래스를 만들고 싶다고 해 볼게요:

// The address property is initialized in the class header.
@JvmInline
value class Email(val address: String)

fun sendEmail(email: Email) {
    println("Sending email to ${email.address}")
}

fun main() {
    val myEmail = Email("[email protected]")
    sendEmail(myEmail)
    // Sending email to [email protected]
}

이 예시에서:

  • Email은 클래스 헤더에 프로퍼티 하나(address)를 가진 인라인 값 클래스예요.
  • sendEmail() 함수는 Email 타입의 객체를 받아 표준 출력에 문자열을 출력해요.
  • myEmail이라는 Email 클래스의 인스턴스를 만들어요.
  • myEmail 객체에서 sendEmail() 함수를 호출해요.

인라인 값 클래스를 쓰면 클래스가 인라인화돼서 객체를 만들지 않고도 코드에서 직접 사용할 수 있어요. 이렇게 하면 메모리 사용량을 크게 줄이고 코드의 런타임 성능을 개선할 수 있어요.

인라인 값 클래스에 대한 자세한 내용은 인라인 값 클래스(Inline value classes) 문서를 참고하세요.

연습 (Practice)

연습 1

배송 서비스를 운영하면서 소포의 상태를 추적할 방법이 필요해요. Pending, InTransit, Delivered, Canceled 상태를 나타내는 데이터 클래스들을 담은 DeliveryStatus sealed 클래스를 만드세요. main() 함수의 코드가 성공적으로 실행되도록 DeliveryStatus 클래스 선언을 완성해 보세요:

sealed class // Write your code here

fun printDeliveryStatus(status: DeliveryStatus) {
    when (status) {
        is DeliveryStatus.Pending -> {
            println("The package is pending pickup from ${status.sender}.")
        }
        is DeliveryStatus.InTransit -> {
            println("The package is in transit and expected to arrive by ${status.estimatedDeliveryDate}.")
        }
        is DeliveryStatus.Delivered -> {
            println("The package was delivered to ${status.recipient} on ${status.deliveryDate}.")
        }
        is DeliveryStatus.Canceled -> {
            println("The delivery was canceled due to: ${status.reason}.")
        }
    }
}

fun main() {
    val status1: DeliveryStatus = DeliveryStatus.Pending("Alice")
    val status2: DeliveryStatus = DeliveryStatus.InTransit("2024-11-20")
    val status3: DeliveryStatus = DeliveryStatus.Delivered("2024-11-18", "Bob")
    val status4: DeliveryStatus = DeliveryStatus.Canceled("Address not found")

    printDeliveryStatus(status1)
    // The package is pending pickup from Alice.
    printDeliveryStatus(status2)
    // The package is in transit and expected to arrive by 2024-11-20.
    printDeliveryStatus(status3)
    // The package was delivered to Bob on 2024-11-18.
    printDeliveryStatus(status4)
    // The delivery was canceled due to: Address not found.
}

연습 2

프로그램에서 서로 다른 상태와 종류의 오류를 처리할 수 있기를 원해요. 데이터 클래스나 객체로 선언된 다양한 상태를 담는 sealed 클래스가 있어요. NETWORK, TIMEOUT, UNKNOWN 문제 유형을 나타내는 Problem enum 클래스를 만들어 아래 코드를 완성해 보세요:

sealed class Status {
    data object Loading : Status()
    data class Error(val problem: Problem) : Status() {
        // Write your code here
    }

    data class OK(val data: List<String>) : Status()
}

fun handleStatus(status: Status) {
    when (status) {
        is Status.Loading -> println("Loading...")
        is Status.OK -> println("Data received: ${status.data}")
        is Status.Error -> when (status.problem) {
            Status.Error.Problem.NETWORK -> println("Network issue")
            Status.Error.Problem.TIMEOUT -> println("Request timed out")
            Status.Error.Problem.UNKNOWN -> println("Unknown error occurred")
        }
    }
}

fun main() {
    val status1: Status = Status.Error(Status.Error.Problem.NETWORK)
    val status2: Status = Status.OK(listOf("Data1", "Data2"))

    handleStatus(status1)
    // Network issue
    handleStatus(status2)
    // Data received: [Data1, Data2]
}

더 알아보기