[Kotlin] 코틀린 제네릭에 대한 이해 - Understanding Kotlin Generic

Nilto·2023년 5월 28일
0

이 글은 제네릭을 한 번이라도 사용해본 사람을 대상으로 한다. 사실 제네릭은 개념 이해를 실 예시들을 통해 하는게 좋다고 생각한다. 그래서 이 글에는 제네릭의 좋은 예시들이 생각나거나 발견할 때마다 넣을 생각이다.

제네릭 또한, 사실은 코드의 양을 줄여주는 이른바 sementic sugar, 컴파일러 트릭이라고 볼 수도 있다. 없어도 사용하는 타입에 대해 다 만들면 돌아가니까! 그래서 제대로 사용하지 못할거면 아예 사용하지 마라는 말도 있다.

그렇다면 Variance부터 알아보자.

Variance(공변성)

타입에 대한 제한은 장단이 있다. variance를 잘 사용한다는 것은 그러한 장단을 이해하고 타입을 자유자재로 다룬다는 것과도 같다고 할 수 있을 것이다.

Variance란?

간단하게 동물 클래스와 그걸 상속하는 토끼, 고양이 클래스가 있다고 치자. 또한, 코틀린 사용자에게 가장 익숙한 List와 MutableList로 예시를 쉽게 이해해보자.

open class Animal
class Rabbit : Animal()
class Cat : Animal()

MutableList<Rabbit>은 MutableList<Animal>의 하위 타입인가? 이 질문이 근본적으로 공변성의 핵심 질문이라고 할 수 있다.
기본적으로는, 아래처럼 컴파일 에러가 나오게 된다. 아무 것도 하지 않으면 공변이 없는 무공변(invariant)이기 때문이다.

fun test() {
    var p1 = mutableListOf<Animal>()
    var p2 = mutableListOf<Rabbit>()
    p1 = p2 // 컴파일 에러!
}


사실 이는 어찌보면 당연하다.

fun test() {
    var p1 = mutableListOf<Animal>()
    var p2 = mutableListOf<Rabbit>()
    feedAllAnimals(p2)
}

fun feedAllAnimals(animals: MutableList<Animal>) {
    animals[0] = Animal()
    animals[0] = Cat()
}

이 코드가 컴파일되어 Cat을 대입하게 된다면? MutableList<Rabbit>에 고양이가 들어가버릴 수도 있는 것이다!

그러나 MutableList를 List로 바꾸면 위에서 나온 컴파일 에러가 나오지 않는다! 왜 이런일이 일어날까?

fun test() {
    var p1 = listOf<Animal>()
    var p2 = listOf<Rabbit>()
    p1 = p2
}

실제 선언을 보자.

만약 A가 B의 하위 타입이면, List<A>이 List<B>의 하위 타입이 된다고 할 때,
List라는 클래스, 또는 인터페이스를 covariant(공변적)이라고 한다. 그리고 이걸 위해서는 out이라는 키워드를 사용해야한다. 실제로 MutableList와 List를 보도록 하자. out이라는 차이가 보인다! MutableList는 공변성이 없고 (무공변), List는 공변성이 있다는 것이다.
그리고 또한, 이는 MutableList는 Mutable이고, List는 Immutable인 부분도 연관성이 있다.

생산, 소비의 의미

생산과 소비라는 게 처음에 보면 와닿지 않을 수 있다. 이 부분도 예시로 이해하면 좋다.

제네릭은 선언 부분에서 사용하면 쉽게 적용할 수 있다. 그러나 선언 부분에서 사용하지 못한 클래스에서도 사용하고 싶을 수 있다. 앞서 본 MutableList가 바로 그 케이스가 될 수 있다.

fun test() {
    var p1 = mutableListOf<Animal>()
    var p2 = mutableListOf<Rabbit>()
    feedAllAnimals(p2) // 컴파일 에러!
}

fun feedAllAnimals(fromAnimals: MutableList<Animal>) {
    val toAnimals = mutableListOf<Animal>()
    toAnimals.addAll(fromAnimals)
}

위 예시에서 feedAllAnimals에서 fromAnimals에서는 Animal를 상속한 클래스가 와도 사실상 문제가 없다. 그러나 MutableList<Rabbit>을 넣으면 컴파일 에러가 나와서 사용할 수가 없다.
그럴때는?

fun test() {
    var p1 = mutableListOf<Animal>()
    var p2 = mutableListOf<Rabbit>()
    feedAllAnimals(p2) // 컴파일 에러가 나오지 않음
}

fun feedAllAnimals(fromAnimals: MutableList<out Animal>) {
    val toAnimals = mutableListOf<Animal>()
    toAnimals.addAll(fromAnimals)
}

위처럼 out만 넣어주면 바로 해결이 된다. 이렇게 특정 타입 파라미터가 나타나는 지점에서만 사용하는 방식을 사용 지점 변성(Use-site variance)이라고 하며, 이 방식 이전에 우리가 했던 방식을 선언 지점 변성(Declaration-site variance)이라고 한다.

out을 넣어줄 수 있는 이유는 fromAnimals가 이미 있던 원소를 내뱉는 행위. 즉, '생산'만 하기 때문이다.

fun feedAllAnimals(fromAnimals: MutableList<out Animal>) {
    val toAnimals = mutableListOf<Animal>()
    toAnimals.addAll(fromAnimals)
    fromAnimals[0] = Animal()
}

만약 위 코드에서 fromAnimals에 대입을 하려고 하면 문제가 생기게 된다. 생산이 아니고 새롭게 원소를 넣는 행위. 즉, 소비를 하려고 시도했기 때문이다. 그러면 소비를 하려면? in을 넣어주면 된다! 이 경우가 바로 contravariance(반공변성)이 되는 것이다.

fun feedAllAnimals(fromAnimals: MutableList<in Animal>) {
    val toAnimals = mutableListOf<Animal>()
    toAnimals.addAll(fromAnimals) // 컴파일 에러!
    fromAnimals[0] = Animal()
}

아니 그런데 in을 사용하는 곳이 있을까? 예시가 잘 떠오르지는 않을 것이다.
예시가 무엇이 있을까? 대표적 예시가 Comparator가 될 수 있을 것이다.

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

fun demo(animalsComparable: Comparable<Animal>) {
    animals.compareTo(Rabbit()) 
    val catComparable: Comparable<Cat> = animalsComparable // 컴파일 에러가 나지않는다!
}

간단하다! 동물을 비교하는 Comparable이 있다면, 고양이끼리 비교하는 것도 이 Comparable으로 당연히 비교할 수 있을 것이란 것이다.

그래서 이러한 원리를 두고 Consumer in, Producer out로 외울 수도 있다. (그냥 예시로 이해해두면 굳이 외울 필요는 없을 것이다.)

Example - UiState

흔히 Ui를 나타내기 위해 안드로이드에서 사용하는 패턴중 하나이다. (물론, 이 UiState를 표현하는 방식도 여러가지이고 관련해서 이야깃거리도 많다.)

  sealed interface UiState<out T>
  
  object LoadingState : UiState<Nothing>
  
  class ErrorState(val throwable: Throwable) : UiState<Nothing> {
      val message: String?
          get() = throwable.message    
          
  class SuccessState<T>(val value: T) : UiState<T>

여기서 주목할 것은 Nothing이다. Nothing은 위에서 언급했듯, 모든 타입의 서브타입으로 취급할 수 있기 때문에 <out T>에 T로 들어갈 수가 있다.

inline

Type erasure 의미

너무 중요한 부분이다. 먼저 inline 이전에 Type erasure를 알고 넘어가야한다. JVM에서 제네릭스는 Type erasure를 사용한다. runtime에서 타입 인자 정보가 들어있지 않다는 말이다.

그러면 왜 이런식으로 구현되어있을까? Generic은 자바 1.5에서 나왔다. 자바 제네릭을 디자인할 때 중요하게 여겨진 제약사항은 새로운 코드가 제네릭을 사용하지 않은 코드와 완전히 호환이 되어야한다는 것이었다. 즉, 제네릭 관련 정보는 소스코드 상에서만 존재해야하며, 바이트코드가 되면 1.5 이전의 버전과 다를게 없어야한다는 말이 된다.

바이트코드에서 제네릭과 관련된 정보를 기억하게 하려면 JVM, JIT 컴파일러 같은 핵심 부위를 뜯어고쳐야해서 그럴 수가 없었다고 한다. 그래서 자바가 Type erasure 때문에 다른 플랫픔에 비웃음을 당한다고도 한다...

아무튼 환경이 이런 바, 나름의 해결책을 찾아야했다.

inline의 효과

먼저 inline에 대해 이해하자. (inline fun)

람다를 설명한 이전 글에서 설명했듯이, 클로저는 캡쳐링 했을 때 매번 인스턴스를 생성하게 된다. 그러나 inline은 이러한 런타임 오버헤드를 줄일 수 있다. (이 경우 말고 나머지 런타임 오버헤드 이점은 JVM에서 이미 자체적으로 하고 있는 inlining이 있어 미미하다고 한다.) 어떻게?

inline을 적용하면 inline 함수의 내용 자체가 바이트코드에 그대로 들어가게 된다. 이 부분이 헷갈릴 수 있는데, 다음 코드를 보자.

fun main() {
    test1 {
        println("test1")
    }
    test2 {
        println("test2")
    }
}

fun test1(testLambda: () -> Unit) {
    testLambda()
}

inline fun test2(testLambda: () -> Unit) {
    testLambda()
}

이를 디컴파일해보면 대략 다음과 같다.

언뜻 봐도 main에서 test2는 본문에 바로 print가 들어간 것을 볼 수 있다. 이처럼 껍질을 벗겨주는 역할을 한다고 보면 되는 것이다. (위 코드는 그것만 이해해도 충분하다.)

그렇다면 이게 인스턴스를 생성하는 코드를 없애서 효율적이라는 것을 이해했다. 그러면 어떻게 제네릭에서 이걸 활용할 수 있을까?

reified

우리는 특정 타입 자체를 파라미터로 넘기고 싶을 때가 있다.

// 코틀린 공식 문서의 예시
fun <T> TreeNode.findParentOfType(clazz: Class<T>): T? {
    var p = parent
    while (p != null && !clazz.isInstance(p)) {
        p = p.parent
    }
    @Suppress("UNCHECKED_CAST")
    return p as T?
}

treeNode.findParentOfType(MyTreeNode::class.java)

이런 경우 타입 자체를 넘길 수도 있긴 하다. 그러나 이쁘지는 않다. 또한, 리플렉션을 사용하는 것이므로 성능도 좋지 않을 것이다.

당연히 우리가 바라는 형태는 다음과 같다

treeNode.findParentOfType<MyTreeNode>()

이런 식으로 사용하기 위해서는 reified 라는 키워드를 사용해야한다.

inline fun <reified T> TreeNode.findParentOfType(): T? {
    var p = parent
    while (p != null && p !is T) {
        p = p.parent
    }
    return p as T?
}

이때, inline이어야하는데 inline이면 함수에 들어가는 파라미터들이 그대로 들어가기 때문이다.

inline fun <reified T> test3(test: Any): Boolean {
    return test is T
}

이 코드는

test3<String>("abc") // inline 변환 이전

"abc" is String  // inline 변환 이후

이렇게 타입 자체가 남도록 변하는 것이다!

조금 더 어려운 예시

inline fun <R : T, T> MutableStateFlow<T>.updateTyped(state: (R) -> T) {
    update { currentState ->
        (currentState as? R)?.let(state) ?: currentState
    }
}

다음 코드에서는 as?에서 @Suppress("UNCHECKED_CAST")를 요구한다. 심지어 ClassCastException으로 인해 터질 수도 있다.

inline fun <reified R : T, T> MutableStateFlow<T>.updateTyped(state: (R) -> T) {
    update { currentState ->
        (currentState as? R)?.let(state) ?: currentState
    }
}

그러나 다음과 같이 reified를 붙이면 문제가 생기지 않는다! 왜 이런 일이 일어날까?
다음 스택오버플로우 질문을 보도록 하자.
https://stackoverflow.com/questions/53407633/getting-exception-when-safe-casting-to-generic-type-in-kotlin
Type Erase 때문에 Object가 되어버리고, 성공해버린다.

noinline

간단하다. noinline 키워드는 위에 적용된 인스턴스 생성을 하지 않는 것을 no, 하지 않는 것이다.
그러면 이걸 왜, 언제 쓸까? 일반적으로 생각해서는 쓸 필요가 없어보인다.
다음과 같은 이유들이 있다.

  1. 기본적으로 Kotlin의 람다 표현식은 캡처된 변수에 접근할 수 있는데, 이때, 람다가 인라인될 경우, 캡처된 변수의 수명이 해당 람다의 수명과 동일하게 처리된다. 그러나 noinline을 사용하면 람다가 인라인되지 않으므로, 캡처된 변수의 수명이 다를 수 있게 된다. 즉, 캡쳐된 변수을 따로 관리하고 싶다면 noinline을 사용하면 된다.
  2. 람다를 매개변수로 다른 함수에 전달하기. api에 따라 람다 자체가 필요할 수도 있다.

그러나 이 기능 자체를 실무에서 그렇게 많이 사용하지는 않을 것이다.

crossinline

crossinline이 없을 때, 람다 내부에 return을 사용할 수 있다.

fun main() {
    test4 {
        return
    }
}

inline fun test4(testLambda: () -> Unit) {
    testLambda()
}

이러면 main이 return 되버리고 만다! 이런 형태를 non-local control flow 라고 한다.
crossinline을 넣어주면 이렇게 non-local control flow를 할 수 없게 한다.

fun main() {
    test4 {
        return // 컴파일 에러
    }
}

inline fun test4(crossinline testLambda: () -> Unit) {
    testLambda()
}

이렇게 컴파일 에러가 나오게 된다!

그러면 noinline이랑 차이는 무엇일까? 이 crossinline은 인스턴스를 생성한다는 차이가 있다! 인라인을 하되 local retrun을 못하게 하는게 중요한 것이다.

컴포즈에서 inline Composable fun을 최적화하지 못하는 이유

(추가예정)

Reference

https://kotlinlang.org/docs/generics.html
https://kotlinlang.org/docs/inline-functions.html#noinline
https://www.baeldung.com/kotlin/crossinline-vs-noinline
도서 - 코틀린 인 액션
도서 - 폴리글랏 프로그래밍

profile
안드로이드 개발자. 컴포즈, 코루틴, 코틀린, 유니티에 관심이 많습니다.

0개의 댓글