Rlog

Kotlin Generic 본문

Kotlin

Kotlin Generic

dev_roach 2023. 8. 22. 11:02
728x90

Generic

코틀린에서 Generic 은 왜 있을까요? 왜 무공변, 공변, 반공변 등의 개념이 필요할까요? 이러한 질문을 코틀린을 공부하는 사람이나 저 역시도 코틀린을 공부했을때  많이 들어왔습니다. 그래서 오늘은 코틀린 'Generic' 에 관해 파헤쳐보도록 하겠습니다. Generic 에 대해 우리가 반드시 알아야 하는 몇가지 컨셉이 있습니다.

 

일단 우리가 들어가기전에 반드시 알아야 하는 개념들이 있는데요. Derived Type 입니다.

Derived Type

Derived Type 은 많은 의미가 있지만 여기서는 부모클래스 부터 속성들을 상속받는 클래스라고 이야기 하겠습니다. 

open class OriginalClass(
    val name: String,
    val age: Int,
) {
    open fun getName_(): String = this.name
}

class DerivedClass: OriginalClass(
    name = "roach",
    age = 27
)

fun main() {
    val clazz: OriginalClass = DerivedClass()

    println(clazz.getName_())
}

여기서 볼 수 있듯이, 우리는 상속받음으로써 몇몇 속성들을 생략하고도 쓸 수 있음을 알 수 있습니다. 하지만 이러한 상속의 개념은 우리가 Generic 을 쓸때 문제가 될수도 있는데요. Generic 은 우리가 생각하는 상속의 개념처럼 동작하지 않기 때문입니다. 우리가 이러한 문제를 알아보기 전에 일단 Generic 에 관해 공부해보도록 하겠습니다.

Generic

위키피디아에 따르면 제네릭의 문장은 다음과 같습니다.

Generic programming is a style of computer programming in which algorithms are written in terms of data types to-be-specified-later that are then instantiated when needed for specific types provided as parameters

위 문장의 의미는 우리가 쉽게 말해 구체적인 타입을 제네릭에 타입 파라미터로서 전달할 수 있다는 것인데요. 함수에 인자를 보내는것과 같이 타입을 보내서 좀 더 자유롭게 사용할 수 있는 것이죠. 제네릭을 타입 파라미터로 바라보는 개념은 제네릭을 심층적으로 이해하는데 매우 중요하고, 가장 기본적인 동작원리이므로 꼭 반드시 숙지하시고 글을 따라 읽으시면 좋습니다.

 

만약, 아래처럼 코드를 적으면 어떻게 될까요?

class TrashBox(
    val list: List<Any> = listOf()
) {

    fun add(ele: Any) = Box(list + listOf(ele))
}

fun main() {
    val box: TrashBox = TrashBox()
    val box2: TrashBox = TrashBox()

    box.add(
        OriginalClass(
            name = "roach",
            age = 24
        ),
    ).add(
        DerivedClass()
    ).list.prettyPrint()

    println("====================")

    box2.add(DerivedClass()).list.prettyPrint()
}

private fun List<*>.prettyPrint(): Unit {
    for (ele in this) {
        println(ele)
    }
}

이 코드는 에러 없이 정확하게 잘 동작할것입니다. 왜냐하면 우리가 Box 에 Any 타입을 지정해뒀기 때문입니다. 만약에 우리가 list 에서 나온 원소가 Derived Class 라고 특정하고 Derived Class 에 있는 특정 메소드를 쓰면 어떻게 될까요? 아마 언젠간 런타임 오류를 맞이하게 될 것입니다. 왜냐하면 Any Type 이기 때문에 Int Type 이 나올수도 있는 list 이기 때문입니다.

(box.list[0] as DerivedClass).hello()

아마 우리가 컴파일러의 지원을 받아가면서 메소드를 적기 위해서는 위와 같이 적어야 할 것입니다. 이게 안전한 타입프로그래밍이라고 생각하시나요? 저는 아니라고 생각합니다. 이 이유가 바로 우리가 Generic 을 써야 하는 이유라고 생각합니다. 컴파일 타임에 조금 안전한 프로그래밍을 하기 위해서 말이죠.

Generic

만약 우리가 Generic 을 쓴다면, 우리는 아래 코드를 작성할때 컴파일 에러 없이 작성할 수 있을 것입니다. 심지어는 IDEA 에게 메소드를 자동으로 추천받을 수도 있습니다. 이게 조금 더 컴파일시간에 안전하게 코드를 작성하는 방법이라고 저는 생각합니다.

fun main() {
    val box: Box<OriginalClass> = Box<OriginalClass>()
    val box2: Box<DerivedClass> = Box<DerivedClass>()

    box.add(
        OriginalClass(
            name = "roach",
            age = 24
        ),
    ).add(
        DerivedClass()
    ).list.prettyPrint()

    println("====================")
    val _box2 = box2.add(DerivedClass())
    _box2.list.prettyPrint()

    println(_box2.list[0].hello()) // hello
}

이건 어떻게 동작하는 걸까요? 그리고 전과는 다르게 어떻게 컴파일 에러를 컴파일 타임동안 안받을 수 있는 것일까요? 고작 Box 의 Generic 타입을 바꿨을 뿐인데 말이죠. Java Q&A 따르면, 컨테이너 클래스의 제네릭 타입은 컴파일 타임 이후 우리가 제공한 구체적 타입이나 와일드-카드 타입으로 변한다고 합니다. 이러한 과정때문에 컴파일러가 컴파일링 과정에서 어떤것이 저장될 것인지 완벽하게 알고, 컴파일에러를 낼 수 있는 이유입니다. 이러한 프로세스는 코드를 컴파일 시간에 더 안전하게 작성할 수 있도록 도와주며, 이러한 방식을 "빠른 실패" 방식이라고도 합니다.

The problems (문제)

위에서 언급한 문제에 대해 설명드리겠습니다. 아래 코드과 과연 정상적으로 돌아갈까요?

class Wrapper<T>(
    private val contained: T,
) {
    fun next(): T {
        return contained
    }
}

fun helloTo(parent: Wrapper<Any>) {
    println(parent)
}

fun main() {
    val parents = Wrapper<Parent>(Parent())

    val children = Wrapper<Child>(Child())

    helloTo(parents)
}

아마 부모-자식관의 관계(super-subtype-relationship) 이 없다는 걸 모른다면 이게 가능하다고 생각할 것입니다. 하지만 아래와 같이 컴파일 타임에 에러를 맞이합니다. Any 는 모든것의 상위계층, Java 로 치면 Object 의 느낌인데도 말이죠.

Screenshot from 2023-08-22 01-07-54

왜 이렇게 동작할까요? 그 이유는 위에서 언급했듯이 부모-자식관의 관계(super-subtype-relationship) 없기 때문입니다. 관계가 없다는 사실은 Generic 을 이해하는데 상당히 중요합니다. 우리는 이러한 상태를 바로 무공변(Invariant) 라고 합니다. 제네릭은 기본적으로 무공변 상태를 기반으로 동작합니다. 따라서 우리가 원하는 관계는 부모-자식간의 상하관계를 만들어줘야 하기 때문에 Java 였으면 `extends Any` 라는 modifier 를 통해 문제를 해결해줘야 했을 것입니다.

class Wrapper<T extends Object>

만약 위의 글을 보고도 잘 이해가 가지 않는다면 아래 예시를 보신다면 조금 더 명확하게 이해갈 것 입니다.

fun main() {
    val children = mutableListOf(Child())
    val parents: MutableList<Parent> = children
    parents.add(Parent()) // they shared their state
    val child = children.get(1) // You must be having issues when you will get here.
}

Covariant (공변성)

이 문제를 코틀린에서 해결하기 위해서, 우리는 `out` modifier 를 타입 파라미터에 붙여줘야 합니다.

class Wrapper<out T>(
    private val contained: T,
) {
    fun next(): T {
        return contained
    }
}

fun helloTo(parent: Wrapper<Any>) {
    println(parent)
}

fun main() {
    val parents = Wrapper<Parent>(Parent())

    val children = Wrapper<Child>(Child())

    helloTo(parents)

위에서 볼 수 있듯이, 우리는 `out` 한정자를 붙여준것 만으로 컴파일 에러를 해결할 수 있었습니다. 즉, Generic 간의 상하관계를 만든것이죠. 왜 `out` 만 붙인 것 일 뿐인데 왜 이게 정상적으로 동작할까요? 이 개념을 이해하기 위해서는 List 에 여러가지 타입들을 넣는 상황을 생각해보면 좋습니다. 바로 아래 코드처럼 말이죠.

fun main() {
    val children = mutableListOf(Child())
    val parents: MutableList<Parent> = children // compile error: Type mismatch.
    parents.add(Parent()) // they shared their state
    val child = children.get(1)
}

이 코드는 Generic 시스템의 문제 때문에 당연하게도 컴파일링 될 수 없습니다. 현재는 무공변(Invariance) 상태이기 때문이죠. 그래서 우리는 MutableList 에 out 을 붙여줄 필요가 있습니다.

fun main() {
    val children = mutableListOf(Child())
    val parents: MutableList<out Parent> = children
    parents.add(Parent()) // compile error: Type mismatch.
    val child = children.get(1)
}

Because it can prohibit to be put the other type even if it was a super type of this. Now, we can guarantee the child variable can always be Child Type. We have to fix the code like the code below.

fun main() {
    val children = mutableListOf(Child())
    val parents: MutableList<out Parent> = children
    parents.add(Child())
    val child = children.get(1)
}

이제는 정상적으로 동작하는 모습을 확인할 수 있습니다. 하지만 이 방법이 컴파일 에러를 해결했지만 정말 좋은 방법일까요? val child 는 어떤 타입일까요? 우리가 Child 타입이라고 생각하고 쓸 수 있을까요? 바로 이 이유때문에 Joshua 는 outProducer 타입에만 쓰라고 이야기 하는데요. 아래 글을 함께 보시죠.

 

Screenshot from 2023-08-22 01-48-08

위에서 언급했듯이 불변타입(Immutable Type) 인 List 에는 `out` 한정자가 달려있음을 확인할 수 있습니다. 생산(Produce) 만 할뿐 왜부에서 값을 변경할 순 없기 때문이죠. 하지만 MutableList 를 확인해보면 아무것도 없음을 알 수 있죠. 위에서 언급한 모든 이유때문에 Joshua 가 이야기한 것에 따라 in, out 을 쓰는 것이 좋은 이유 입니다.

"For maximum flexibility, use wildcard types on input parameters that represent producers or consumers", and proposes the following mnemonic: PECS stands for Producer-Extends, Consumer-Super.

코틀린에서는 List 가 불변타입으로 Producer 위치에 해당합니다. 그래서 List 는 out 한정자를 가지는 것이 좋은 판단입니다. 만약 우리가 제네릭을 통해 컨슈머 클래스를 만든다면 out 한정자를 붙일 수 있다는 뜻이겠죠. 이제 아마 이해가 가셨을거라고 생각합니다. `out` 포지션의 제네릭 타입 파라미터는 반드시 out-position 의 용도로만 이용되어야 합니다. 즉, Return Type 으로 사용되어야 한다는 것이죠.

Contravariant (반공변성)

반공변성은 완전히 반대의미의 어노테이션입니다. 위에서 공변성에 관해 엄청나게 길게 이야기 했으니 반대 개념에 관해서는 길게 설명하지는 않겠습니다.

fun main() {
    val children = mutableListOf(Child())
    val grands = mutableListOf(
        Grand(),
        SuperGrand()
    )
    val parents: MutableList<in Parent> = grands
    val parent: Grand = parents.get(1) // Type error: Found: Any?
}

위 코드에서 볼 수 있듯이, 우리가 리스트에서 값을 뽑아서 이용할경우 Type Error 를 맞이하게 되는데요. 그 이유는 어떤 Type 이 List 에서 나올지 알 수 없기 때문입니다. 그렇다면, `in` 은 언제써야 하는걸까요? 만약 우리가 out 으로 한정된 List 의 원소들을 정렬해야 한다고 해봅시다. 어떻게 정렬자(Comparator) 를 구현할 수 있을까요?

public interface Comparable<in T> {
    /**
     * Compares this object with the specified object for order. Returns zero if this object is equal
     * to the specified [other] object, a negative number if it's less than [other], or a positive number
     * if it's greater than [other].
     */
    public operator fun compareTo(other: T): Int
}

open class Parent(
    open val age: Int,
): Grand(), Comparable<Parent> {
    open fun doSomething() {
        print("Parent-do")
    }

    override fun toString(): String {
        return "Parent(age: $age)"
    }

    override fun compareTo(other: Parent): Int = when {
        this.age > other.age -> 1
        this.age == other.age -> 0
        else -> -1
    }
}

fun main() {
    val children = mutableListOf(Child(30), Child(50), Child(10))
    val parents: MutableList<out Parent> = children
    val sorted = parents.sortedWith(Parent::compareTo)

    println(sorted)
}

위의 방법이 가장 간단하게 `in` 을 쓰는 예시를 볼수 있는 코드입니다. 이미 List 안의 원소들은 Parent 의 모든 속성들을 받고 있을 것이므로 우리는 부모의 속성을 통해 `out 부모` 로 한정된 리스트를 쉽게 정렬할 수 있다는 것을 알 수 있습니다. 만약 `out` 을 쓸 수 있다면 어떻게 될까요? 아마 조상의 조상에 있는 메소드를 정렬의 Key 로서 활용할 수 없을 것입니다. 위쪽 바운드가 제한됬기 때문이죠.

fun main() {
    val children = mutableListOf(Child(30), Child(50), Child(10))
    val parents: MutableList<out Parent> = children
    val sorted = parents.sortedWith(Grand::compareTo)

    println(sorted)
}

Conclusion

영어로 글을 쓰고 한국어로 번역하다보니 조금 이상한점이 있을 수도 있으니 댓글로 제보 부탁드립니다. 

'Kotlin' 카테고리의 다른 글

Kotlin NoteBook In Intellij  (0) 2023.07.11
Kotlin 동시성 프로그래밍  (1) 2022.11.07
Kotlin Sequence  (0) 2022.10.25
Kotlin Coroutine Series - 7 ) Dispatcher  (0) 2022.10.22
Kotlin Coroutine Series - 6 ) Coroutine Scope  (0) 2022.10.22