시배's Android

Kotlin | Generic 본문

Kotlin/Kotlin

Kotlin | Generic

si8ae 2023. 8. 27. 00:02
 

Kotlin Generic

Generic What is Generic in Java or Kotlin? What is Invariance, Convariance, and Contravariance? I have been asking those things for a long day. So, we’re gonna dive into “Generic” in Kotlin. There are a lot of concepts that we have to know. We’re g

tmdgusya.github.io

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)은 다음과 같이 설명됩니다.

제네릭 프로그래밍은 컴퓨터 프로그래밍 스타일로, 알고리즘을 나중에 지정될 데이터 타입을 기반으로 작성하며, 필요한 경우 특정 타입으로 인스턴스화됩니다.

즉, 우리는 제네릭 매개변수에 특정한 타입을 인자로 전달할 수 있습니다. 제네릭을 매개변수로 간주하는 것은 그 작동 방식의 개념을 이해하는 데 중요합니다.

아래의 코드를 작성한다고 상상해보세요.

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

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

class Box<T>(
    val list: List<T> = listOf()
) {

    fun add(ele: T) = 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)
    }
}

만약 위와 같은 코드를 작성한다면 어떨까요? 이 코드는 정상적으로 오류 없이 작동할 것입니다. 왜냐하면 Any 타입을 사용하고 있기 때문입니다. 그러나 리스트에서 꺼내진 요소에 원래 클래스에 포함되지 않은 파생 클래스의 특정 메서드를 사용한다면 어떨까요?

우리는 위와 같은 코드를 작성해야 합니다. 이것이 정말로 타입 안전한 것일까요? 답은 아닙니다! 이것이 바로 코틀린에서 "제네릭"을 사용해야 하는 이유입니다. 그러므로 다음 단계에서 우리는 제네릭에 대해 자세히 알아볼 것입니다.

Generic

제네릭을 사용하면, 우리는 아래의 코드처럼 컴파일 오류 없이 코드를 작성할 있습니다. 또한 IDE 자동 수정 기능을 활용하여 어떤 메서드가 있는지 찾을 있습니다. 이것은 안전한 코드 작성 방식일 것입니다.

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 제네릭 타입만 변경했습니다. Java Q&A 따르면, 컨테이너의 제네릭 타입은 컴파일 시간 이후에 특정 타입 또는 와일드카드 타입으로 대체된다고 합니다. 그래서 이것은 컴파일러가 어떤 타입이 저장되는지를 정확히 있는 방식입니다. 이것은 컴파일 시간이 아닌 런타임이 아닌, 컴파일 시간에 시간을 절약할 있는 가장 중요한 특징입니다. 이것은 "실패 빠른(fail fast)" 접근법이라고 불립니다.

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

아마도 당신은 "부모-하위 타입 관계가 없다면 괜찮을 것이라고 있겠지만, 컴포넌트 타입 간에 상속-하위 타입 관계가 없다는 사실을 알지 못한다면"이라고 말씀하실 있을 것입니다. 그러나 컴파일 시간에 오류가 발생합니다. 아래 사진과 같이요.

왜 위와 같이 작동했을까요? 왜 오류가 발생할까요? 이는 상위-하위 타입 관계가 없기 때문입니다. 제네릭 시스템을 이해하는 것이 매우 중요합니다. 따라서 우리는 아래의 코드와 같이 타입 파라미터에 'extends Object'를 추가해야 합니다. 이는 타입 파라미터가 Object 타입을 상속한다는 것을 나타냅니다. 두 개의 타입 간에 관계를 만들기 위해서입니다.

타입 간에 관계가 없음을 나타내는 용어는 "불변성(invariance)"이라고 합니다. 따라서 제네릭은 불변성을 기반으로 작동합니다.

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' 수정자를 추가해야 합니다. 아래 코드와 같이요.

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' 추가함으로써 타입 관계를 형성할 있습니다. 이렇게 작동하는 걸까요? 이해하기 위해서, 아래의 코드와 같이 다른 타입의 인스턴스를 컨테이너에 넣는 다른 경우를 상상해보아야 합니다.

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

코드는 컴파일될 없습니다. 저가 언급한 대로 제네릭 시스템의 작동 방식 때문입니다. 그래서 우리는 부모 변수의 제네릭 타입을 'out Parent' 변경해야 합니다.

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

이를 통해 다른 타입이 이것의 슈퍼 타입이라 할지라도 다른 타입이 추가되는 것을 금지할 있습니다. 이제 우리는 자식 변수가 항상 자식 타입이 있음을 보장할 있습니다. 우리는 아래의 코드와 같이 코드를 수정해야 합니다.

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

앞서 말씀드린 대로, 불변 타입인 List에는 'out' 한정자가 있습니다. 그러나 MutableList 확인해보면 어떤 한정자도 없음을 있습니다. 위에서 언급한 모든 이유로 인해 Joshua Bloch 규칙을 따르고 있습니다.

"최대한 유연성을 위해, 생산자 또는 소비자를 나타내는 입력 매개변수에 와일드카드 타입을 사용하세요"라고 권장하며, 다음과 같은 기억규칙을 제안합니다: PECS는 생산자(Producer)-확장(Extends), 소비자(Consumer)-상위(Super)를 나타냅니다.

코틀린에서 List는 심지어 소비자(Consumer)보다는 생산자(Producer)입니다. 그래서 List는 생산자로서 'out' 수정자를 사용할 수 있습니다. 이는 일반적으로 사용하기 위해 제네릭을 사용하여 생산자 클래스를 생성한다면, 우리의 클래스에 'out' 수정자를 추가할 수 있다는 것을 의미합니다. 이제 완전히 이해하셨을 것으로 생각합니다.

'out' 수정자를 사용하는 것은 코틀린에서 이루어집니다. 선언된 타입은 클래스의 멤버 내에서 'out' 위치에서만 사용됩니다. 이는 이 타입을 멤버 함수의 매개변수로 사용할 수 없다는 것을 의미합니다. 이 타입은 반환 타입으로만 사용할 수 있습니다.

Contravariant

반공변성(Contravariant) 상보적인 변성 주석입니다. 나는 이미 공변성(Covariant) 개념을 많이 설명해 드렸으므로, 부분에서는 이전보다 많이 설명하지 않겠습니다.

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?
}

보시다시피, 거기에는 요소를 소비하지 않는 이유가 있습니다. 어떤 종류의 요소가 팝되어 나올지 예측할 수 없기 때문입니다. 그러나 이 개념을 설명하지 않으면 Kotlin에서 왜 이것이 필요한지 궁금해하실 것입니다.

다음과 같이 동일한 클래스를 상속하는 다양한 유형의 인스턴스를 정렬해야 한다고 상상해보세요.

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' 한정자를 쉽게 사용할 있습니다. 왜냐하면 이미 부모로부터 받은 메서드를 가지고 있기 때문입니다. 따라서 우리는 타입 파라미터로 선언한 유형만 소비할 'in' 한정자를 사용해야 합니다. 'in' 한정자 대신 Comparable 클래스에 'out' 한정자를 작성할 있다고 상상해보세요. 그런 다음 아래 코드처럼 자식들을 비교하기 위해 GrandParent 메서드를 사용할 없을 것입니다.

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