kotlin

코틀린 이모저모 - 3

e4g3r 2025. 4. 12. 20:02

확장 함수

fun String.twicePrint() {
    println(this)
    println(this)
}

fun main() {
    val str = "Hello World"
    str.twicePrint()
}

// Hello World
// Hello World

 

일반적으로 클래스, 인터페이스에 기능을 추가하고 싶다면 상속 혹은 디자인 패턴을 통해 처리를 해줘야했습니다.

하지만 위 예제는 String이라는 클래스에 twicePrint라는 메서드를 추가했습니다.

직접적으로 String 클래스를 수정한 것이 아닌 추가적인 선언만 했습니다. 이러한 기능을 코틀린에서는 확장 함수라고 합니다.

open class Shape
class Rectangle: Shape()

fun Shape.getName() = "Shape"
fun Rectangle.getName() = "Rectangle"

fun printClassName(s: Shape) {
    println(s.getName())
}

printClassName(Rectangle())

// Shape

 

이러한 확장 함수는 공식문서에서 정적(statically)로 처리된다고 합니다. 위 예제의 경우 printClassName 메서드에 Rectangle을

전달하였지만 매개변수 타입이 Shape이기 때문에 Shape.getName을 호출하게 됩니다.

즉 확장 함수는 실제 클래스, 인터페이스를 수정하는 게 아니며 컴파일 시점 타입에 의해 호출 대상이 정해집니다.

class Example {
    fun printFunctionType() { println("Class method") }
}

fun Example.printFunctionType() { println("Extension function") }

Example().printFunctionType()

// Class method

 

또한 확장 함수가 기존 클래스에 존재하는 함수 시그니처와 동일하면 기존 클래스의 함수가 호출됩니다.

fun String?.twicePrint() {
    if (this != null) {
        println(this)
        println(this)
    }
}

fun main() {
    val str = null
    str.twicePrint()
}

 

확장 함수는 nullable 대상으로도 사용이 가능하기 때문에 nullable인 경우 null 검사를 필요로 합니다.

class Car() {
    fun String.onlyCallInCar() {
        println("String.onlyCallInCar()")
    }

    fun method() {
        val a: String = "abcdef"
        a.onlyCallInCar()
    }
}

fun main() {
    val b: String = "abcdef"
    b.onlyCallInCar() // 불가능
}

 

대부분의 예제들은 최상위 레벨에서 확장 함수를 선언하였습니다. 따라서 import만 한다면 어느곳에서든 사용할 수 있었습니다.

하지만 클래스 내부에서 선언 된 확장 함수는 클래스 내부에서만 사용이 가능합니다. 따라서 Car 안에서 정의 된 String의 확장 함수는

Car 내부에서만 사용이 가능하며 main 함수는 Car 영역이 아니기 때문에 사용할 수 없습니다.

 

추가적으로 확장함수에는 dispatch receiver, extension receiver 개념이 존재합니다.

 

dispatch receiver는 확장 함수를 정의하고 있는 클래스를 의미합니다. Car 클래스에서 확장 함수 onlyCallInCar를 정의했기 때문에 

dispatch receiver는 Car 입니다.

 

extension receiver는 확장 함수 대상 클래스를 의미합니다. onlyCallInCar 확장 함수는 String에 추가되기 때문에

extension receiver는 String입니다.

class Car() {
    fun get(index: Int): Char {
        println("Car's get")
        return index.toChar()
    }

    fun String.nice() {
        get(0) // String.get
    }

    fun test() {
        "abcdefghij".nice()
    }
}

fun main() {
    val car = Car()
    car.test()
}

 

Car 클래스에서 정의 된 String 확장 함수 nice 내부에서는 get 메서드가 사용되고 있습니다.

get 메서드는 String에도 존재하고 Car도 존재합니다.

 

이처럼 확장함수 내에서 dispatch receiver(Car)와 extension receiver(String)에 모두 존재하는 메서드가 호출되는 경우

extension receiver가 호출되는 규칙을 가지고 있습니다.

fun String.nice() {
    this@Car.get(this.length - 1)
}

 

만약 Car의 get을 사용하고 싶다면 this@Class를 사용해서 호출할 수 있습니다.

open class Base { }

class Derived : Base() { }

open class BaseCaller {
    open fun Base.printFunctionInfo() {
        println("Base extension function in BaseCaller")
    }

    open fun Derived.printFunctionInfo() {
        println("Derived extension function in BaseCaller")
    }

    fun call(b: Base) {
        b.printFunctionInfo()   // call the extension function
    }
}

class DerivedCaller: BaseCaller() {
    override fun Base.printFunctionInfo() {
        println("Base extension function in DerivedCaller")
    }

    override fun Derived.printFunctionInfo() {
        println("Derived extension function in DerivedCaller")
    }
}

fun main() {
    BaseCaller().call(Base())   // "Base extension function in BaseCaller"
    DerivedCaller().call(Base())  // "Base extension function in DerivedCaller" - dispatch receiver is resolved virtually
    DerivedCaller().call(Derived())  // "Base extension function in DerivedCaller" - extension receiver is resolved statically
}

 

확장 함수는 하위 클래스에서 상속이 될 수 있습니다.

위 코드에서 BaseCaller에서 Base 및 Derived 클래스의 확장 함수를 정의하였습니다.

DerviedCaller에서는 부모 클래스가 정의 한 확장 함수를 오버라이딩 합니다.

 

BaseCaller().call(Base()) 호출 결과는 당연히 BaseCaller에서 정의된 Base 확장 함수가 실행됩니다.

DerivedCaller().call(Base()) 호출 결과는 DerivedCaller에서 정의된 Base 확장 함수가 실행됩니다.

이처럼 상속 관계 클래스에서 확장 함수를 오버라이딩 하고 있다면 하위 클래스에서 정의 된 확장 함수가 실행되는 것을 볼 수 있습니다.

 

DerivedCaller().call(Derived()) 또한 DerivedCaller에서 정의된 확장 함수가 실행되지만 DerivedCaller().call(Base())와 똑같이

Base 확장 함수가 실행됩니다. 이부분은 초반에 언급되었던 확장 함수는 컴파일 시점의 타입으로 실행된다는 조건 때문입니다.

call 메서드는 Base 타입으로 파라미터가 정해져있기 때문에 Derived가 전달되어도 Base 타입의 확장 함수를 바라보게 됩니다.

확장 프로퍼티

class Car(
    val brand: String
)

val Car.brandLogo: String
    get() {
        return "https://car.com/image/logo/${this.brand}"
    }

fun main() {
    val newCar = Car("KIA")
    println(newCar.brandLogo)
}

// https://car.com/image/logo/KIA

 

프로퍼티도 확장이 가능합니다. 대신 클래스에 프로퍼티가 직접적으로 추가되는 것이 아니라 get, set에서는 backing field

사용이 불가능합니다.

Data Class

data class User(val name: String, val age: Int)

 

클래스를 선언할 때 data 키워드를 사용하면 Data Class로 정의할 수 있습니다.

Data 클래스는 Java 환경에서 Lombok @Data 어노테이션을 사용하는 것과 비슷합니다.

 

1. 모든 프로퍼티를 대상으로 equals() / hashCode()를 생성

2. toString() Overriding -> User(name=John, age=42)

3. 구조 분해 선언이 가능하도록 해주는 componentN 메서드 생성

4. copy() 메서드 생성

 

1번, 2번은 Java와 유사한데 3,4번은 좀 신기한 기능입니다.

data class Car(val name: String, val color: String, val speed: Int) {
}

fun main() {
    val car = Car("SONATA", "BLACK", 220)
    val (carName, carColor, carSpeed) = car

    println(carName) // SONATA
    println(carColor) // BLACK
    println(carSpeed) // 220

    println(car.component1()) // SONATA
    println(car.component2()) // BLACK
    println(car.component3()) // 220
}

 

먼저 구조 분해 선언이라는 것은 위 코드처럼 Car 필드의 순서대로 프로퍼티를 추출하게 하는 기능입니다.

이렇게 구조 분해 선언이 가능한 것은 data class가 자동으로 생성해주는 component메서드 덕분입니다.

 

data class는 다른 클래스를 상속할 수 있습니다.

따라서 부모 클래스에서 equals() / hasCode()를 구현하고 있는 경우 자동 생성되지 않습니다.

또한 부모 클래스가 자체적으로 componentN 메서드를 만들고 open키워드로 열어둔 경우에는 data class가 오버라이딩 합니다.

val jack = User(name = "Jack", age = 1)
val olderJack = jack.copy(age = 2)

 

copy 메서드는 원본 객체의 특정 값만 수정하여 반환해주는 메서드입니다.

위 예제에서는 name 값은 유지하고 age 값만 수정하여 받습니다.

data class Car(val name: String, val color: String, val speed: Int) {
    val logo: String = "LOGO"
    var storage: Int = 100
}

fun main() {
    val car = Car("SONATA", "BLACK", 220)
    println(car.toString()) // Car(name=SONATA, color=BLACK, speed=220)
}

 

마지막으로 data class가 자동으로 생성해주는 프로퍼티 대상들은 기본 생성자에 정의되어 있는 프로퍼티에만 적용됩니다.

따라서 위 예제에서는 name, color, speed에만 적용되며 logo, storage에는 적용이 되지 않습니다.

따라서 toString의 결과도 name, color, speed만 포함되어 출력됩니다.

Sealed Class, Interface

// Create a sealed interface
sealed interface Error

// Create a sealed class that implements sealed interface Error
sealed class IOError(): Error

// Define subclasses that extend sealed class 'IOError'
class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

// Create a singleton object implementing the 'Error' sealed interface
object RuntimeError : Error

 

코틀린에서는 class, interface를 정의할 때 sealed 키워드를 사용하면 상속, 구현을 동일 패키지 내에서만 할 수 있게 됩니다.

그대로 sealed(봉인된)이라는 표현을 사용한 것 같습니다.

 

만약 위 Error Interface가 특정 라이브러리에서 발생할 수 있는 Error라고 가정하겠습니다.

그런데 sealed가 아니라면 외부에서 상속, 구현을 할 수 있기 때문에 모든 Error를 추적할 수 없게 됩니다.

하지만 Error Interface를 sealed로 선언하면 라이브러리는 모든 오류 유형을 알 수 있게 됩니다.

Generics: in, out, where

// Java
List<String> strs = new ArrayList<String>();

List<Object> objs = strs; // Error

objs.add(1);

String s = strs.get(0);

 

Java의 제네릭에서는 String이 Object의 하위 타입이라 할지라도 List<String>이 List<Object>의 하위타입으로 취급되지 않습니다.

위 코드처럼 기존 String 리스트를 Object로 변환한 뒤 Integer 값을 넣으면 큰일나기 때문입니다.

이러한 강력한 안정성을 제공하는 Java 제네릭은 유연성을 위해 와일드 카드 문법을 사용합니다.

public void print(List<? extends Car> cars) {
    cars.forEach(System.out::println);
}

 

만약 print메서드가 Car를 상속하는 타입의 List를 받고싶다면 extends 와일드 카드를 사용할 수 있습니다. 

(extends X -> X 혹은 X의 하위 타입) 그러나 extends 와일드 카드를 사용한다면 cars는 읽기만 가능합니다. (get Ok, set No)

BeautifulCar, SuperCar 그 무엇이 오던 부모 타입인 Car로 읽으면 문제가 없다는 것이 보장됩니다.

하지만 런타임 시점에는 BeautifulCar가 전달될 지 SuperCar가 전달될지 모르기때문에 어떤 타입을 넣을 수 있는지 알 수 없습니다.

따라서 쓰기 작업은 불가능 합니다.

public static void addIntegersToList(List<? super Integer> list) {
    Integer numberToAdd = new Random().nextInt(100);
    list.add(numberToAdd);
}

 

반면에 쓰기 작업의 경우는 super를 사용합니다. <? super Integer>는 Integer 혹은 그 상위 타입이 올 수 있다는 의미입니다.

따라서 List<Object>, List<Number>, List<Integer> 그 무엇이 오던 Integer 혹은 그 하위 타입을 담을 수 있습니다.

interface Source<out T> {
    fun nextT(): T
}

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

 

반면에 코틀린에서는 제네릭을 선언하는 클래스 혹은 인터페이스에서 in, out이라는 키워드를 사용합니다.

open class Car(val name: String)

class SuperCar(name: String) : Car(name)

class BeautifulCar(name: String) : Car(name)

class CarStorage<out T>(private val car: T) {
    fun get(): T {
        return car
    }
}

 

Car, SuperCar, BeautifulCar가 있습니다. 또한 제네릭 타입을 가지는 CarStorage도 있습니다.

여기서 out의 의미는 CarStorage는 외부로부터 T를 받아들이지 않고 반환만 해준다는 의미입니다. 즉 내보내는 행위만 합니다.

따라서 Storage는 get 메서드처럼 T를 반환하는 메서드만 존재하게 되며, 이로써 공변성을 가지게 됩니다.

fun main() {
    val superCarStorage: CarStorage<SuperCar> = CarStorage(SuperCar("superCar"))
    val carStorage: CarStorage<Car> = superCarStorage
    var anyStorage: CarStorage<Any> = superCarStorage
}

 

공변성이라 함은 Any -> Car -> SuperCar 관계라면 (상위 - 하위)

CarStroage<Any> -> CarStorage<Car> -> CarStorage<SuperCar> (상위 - 하위)도 성립됨을 말합니다.

그래서 위 코드처럼 하위 타입 superCarStorage는 상위 타입 CarStorage<Car>, CarStorage<Any>로도 변환이 가능하게 됩니다.

fun main() {
    val superCarStorage: CarStorage<SuperCar> = CarStorage(SuperCar("superCar"))
    var anyStorage: CarStorage<Any> = superCarStorage // 공변성을 보장받아 OK
    
    // 만약 공변성의 보장이 제대로 되지 않는다면 아래와 같이 BOOM
    anyStorage.change(Bike()) // 외부에서 T 타입을 받아 쓰는 작업 
    val car:SuperCar = superCarStorage.get() // Bike != SuperCar -> BOOM
}

 

공변성을 보장받는 이유는 out로 인해 외부에서 값을 받아오는 작업을 할 수 없으므로 위 코드와 같은 예상치 못한 일은 발생하지 않습니다.

(컴파일 단계에서 오류가 발생합니다.)
따라서 CarStorage<SuperCar>의 제네릭 타입 SuperCar가 항상 Any로 변환될 수 있음을 보장 받습니다.

interface Consumer<in T> {
    fun consume(item: T) // T를 파라미터로 받음 (소비)
}

 

제네릭에서 in 키워드가 사용되는 경우에는 consume 메서드처럼 외부로부터 T를 받아들이는 로직들만 존재합니다.

out 키워드와 달리 T를 반환하는 메서드는 사용되지 않습니다.

class AnyConsumer : Consumer<Any> {
    override fun consume(item: Any) {
        println("Consuming item: $item (Type: ${item::class.simpleName})")
    }
}

class StringLengthConsumer : Consumer<String> {
    override fun consume(item: String) {
        println("String length: ${item.length}")
    }
}

 

AnyCousumer는 Any 타입을 받아서 클래스 이름을 출력하고 StringLengthConsumer는 String 타입을 받아 길이를 출력합니다.

fun main() {
    val anyConsumer: Consumer<Any> = AnyConsumer()
    val stringConsumer: Consumer<String> = StringLengthConsumer()

    val specificConsumer: Consumer<String> = anyConsumer // Consumer<Any>를 Consumer<String>에 할당
    specificConsumer.consume("World") // AnyConsumer의 consume 메소드가 호출됨
}

 

위 코드를 보면 Consumer<Any>를 Consumer<String>에 할당할 수 있습니다.

 

AnyConsumer는 Any 타입 즉 모든 타입을 받아들이는 Consumer였기에 String만 받는 Consumer<String>으로 변환되어도

문제가 없습니다. 이처럼 Any는 String의 상위 타입이지만,  Consumer<Any>는 Consumer<String>의 하위 타입이라고

볼 수 있습니다. 이것을 반공변성이라고 합니다.

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
    // Thus, you can assign x to a variable of type Comparable<Double>
    val y: Comparable<Double> = x // OK!
}

 

또 다른 예시로 위 코드를 보면 Comparable<Double>에 Comparable<Number>를 할당해주는 것을 볼 수 있습니다.

Double은 Number의 하위 타입입니다. 하지만 Comparable<Number>의 compareTo는 Double, Integer, Float 등 Number의

하위 타입 받아서 처리할 수 있습니다. 따라서 Double을 처리할 수 있는 Comparable<Number>는 Comparable<Double>로

변환이 가능합니다.

class Array<T>(val size: Int) {
    operator fun get(index: Int): T { ... }
    operator fun set(index: Int, value: T) { ... }
}

fun copy(from: Array<Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices)
        to[i] = from[i]
}

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" }
copy(ints, any) // Error Array<Int> != Array<Any>

 

Array처럼 T를 외부로부터 받는 기능, T를 외부로 반환하는 기능이 모두 있어야 할 수 있습니다.

이럴 경우 out 혹은 in을 사용하면 get 혹은 set 둘 중 하나를 포기해야합니다.

따라서 위 코드처럼 copy 함수를 사용하는 경우 Int는 Any의 하위 타입에도 불구하고 공변성이 보장되지 않기 때문에 사용할 수 없습니다.

fun copy(from: Array<out Any>, to: Array<Any>) { ... }

 

따라서 코틀린은 제네릭 타입이 사용되는 클래스, 인터페이스를 선언하는 부분 뿐만아니라 메서드에서도 in, out 키워드를 사용할 수

있습니다. from의 Array는 out 키워드가 사용되었으므로 copy 메서드 내부에서는 from에 쓰기 작업이 불가능 해집니다.

따라서 공변성을 보장받을 수 있습니다.

'kotlin' 카테고리의 다른 글

코틀린 이모저모 - 2  (0) 2025.04.11
코틀린 이모저모 - 1  (0) 2025.04.10