Primary/Kotlin

[Kotlin] 제네릭과 타입 간의 관계

해스끼 2022. 6. 13. 13:44

Kotlin에서, variance라는 개념은 타입이 같으면서 타입 매개변수는 서로 다른 타입이 어떻게 연관되어 있는지 설명한다. 예를 들어 ``List<Int>``와 ``List<String>`` 등이 해당한다. Variance를 이해하면 타입 안정성을 해치지 않으면서 사용하기 편리한 제네릭 함수를 작성할 수 있다.

Variance?

다음과 같은 함수가 있다.

fun printContents(list: List<Any>) {
    println(list.joinToString(", ")
}

``list``에 ``List<String>``을 전달할 수 있을까? 당연히 있다. ``List<Int>``도 전달할 수 있다. 여기까지는 아무른 문제가 없다.

 

이제 다음의 함수를 보자. 이 함수는 리스트의 맨 뒤에 정수 ``42``를 추가한다.

fun addNumber(list: MutableList<Any>) {
    list.add(42)
}

``list``에 ``MutableList<String>``을 넘겨줄 수 있을까?

 

정답은 아니오. 컴파일 에러가 발생한다. 물론 ``String``은 ``Any``의 하위 타입(서브 타입)이지만, ``MutableList<String>``는 ``MutableList<Any>``의 서브 타입이 아니다. ``String``의 리스트에 ``Int``를 추가할 수는 없지 않겠는가?

 

첫 번째 함수는 리스트를 단순히 참조하기만 하지만, 두 번째 함수는 리스트를 수정하고 있다. 항상 뭔가를 수정하려고 할 때 문제가 발생하곤 하는데, 여기서도 마찬가지다. 따라서 함수가 수행하는 동작에 따라 읽기 전용 인터페이스를 전달할지(``List``), 수정 가능한 인터페이스를 전달할지(``MutableList``) 결정해야 한다.

 

사실 이 문제를 정확히 파악하려면 Kotlin의 제네릭 타입이 어떻게 구현되었는지 알아야 한다.

클래스, 타입, 서브 타입

갑자기 웬 타입 얘기냐고? 일단 들어 보시라.

 

변수의 타입은 그 변수가 참조할 수 있는 값의 범위를 정한다. 엄밀히 말해 타입클래스는 서로 다르다. 예를 들어 ``String`` 클래스를 활용하여 ``String`` 타입과 ``String?`` 타입의 변수를 선언할 수 있다. 즉 하나의 클래스당 적어도 두 개의 타입을 선언할 수 있다는 뜻이다.

 

제네릭 클래스는 어떨까? ``List`` 클래스에 서로 다른 타입 매개변수를 넘기면 다른 타입이 된다. ``List<Int>``, ``List<String>``, ``List<List<Int>>`` 등 무한히 많은 타입을 선언할 수 있다.

 

객체 지향을 공부해본 사람이라면 타입 간의 상하관계를 알 것이다. 타입이 ``B``인 변수를 언제나 타입 ``A``인 것처럼 사용할 수 있다면, ``B``는 ``A``의 서브 타입이다. 예를 들어 ``Double``은 ``Number``의 서브 타입이고, ``String``은 ``CharSequence``의 서브 타입이다.

 

서브 타입은 서브 클래스와 다르다. 예를 들어 ``Int``는 ``Int?``의 서브 타입이지만, 서브 클래스는 아니다. 서로 상속 관계가 없기 때문이다.

 

서브 타입과 서브 클래스 간의 차이점을 이해했다면, 맨 처음에 했던 질문을 다시 정의할 수 있다.

``List<String>``을 ``List<Any>``로 사용할 수 있는가?

이 질문을

``List<String>``은 ``List<Any>?``의 서브 타입인가?

로 바꿀 수 있다. (정답은 다)

 

``MutableList``를 예시로 들면, 서로 다른 임의의 타입 ``A``와 ``B``에 대해 ``MutableList<A>``가 ``MutableList<B>``의 서브 타입 또는 상위 타입이 아닐 때 ``MutableList``는 invariant하다고 한다. ``MutableList``로 만들 수 있는 여러 타입 간에 어떠한 관계도 존재하지 않는 것이다. 사실 자바에서는 모든 제네릭 클래스가 invariant하다.

 

반면 ``List`` 등 읽기 전용 제네릭 클래스는 ``B``가 ``A``의 서브 타입이면 ``List<B>``도 ``List<A>``의 서브 타입이다. 이것을 covariant라고 한다.

Covariant

``Producer`` 클래스를 예시로 들어 설명하겠다. 제네릭 클래스 중 ``B``가 ``A``의 서브 타입일 때 ``Producer<B>``가 ``Producer<A>``의 서브 타입인 클래스를 covariant 클래스라고 한다. ``A``와 ``B``의 타입 관계가 그대로 적용되는 것이다.

 

``Kotlin``에서 covariant 클래스를 정의하려면, 타입 매개변수 앞에 ``out`` 키워드를 붙여야 한다.

interface Producer<out T> {
    fun produce(): T
}

``T``를 covariant로 지정하면, 함수의 매개변수 또는 리턴 값의 타입 매개변수가 정확히 ``T``가 아니어도 된다. 예를 들어 ``Producer<Number>``는  ``Int`` 값을 반환할 수 있다.

 

Covariance vs. Upper bound

Covariance를 타입의 상계(upper bound)와 헷갈리기 쉽다. Upper bound는 ``T``에 들어갈 수 있는 타입을 한정짓고, covariance는 ``Producer<A>``와 ``Producer<B>`` 간의 타입 관계를 결정한다. 

 

예를 들어, ``T``에 넣을 수 있는 타입을 제한하고 싶다면 ``T``의 upper bound를 정해야 한다. 그러나 ``Producer<Number>``를 요구하는 함수에 ``Producer<Int>``를 넘겨주고 싶다면 covariance를 선언해야 한다. Upper bound와 covariance를 함께 사용하는 경우도 종종 있을 것이다. 헷갈리지 말자.

 

보통 제네릭 클래스가 ``T`` 타입을 반환하기만 하면 covariance를 선언해도 된다(``out`` position 허용). 키워드로 ``out``을 쓰는 이유이기도 하다. 반면 covariance를 선언했다면 클래스의 함수에서 ``T`` 타입의 값을 매개변수로 받아들여서는 안 된다(``in`` position 금지).

 

``out T``의 의미를 정리하자면 다음과 같다.

  • 타입 관계가 전이된다. ``Producer<Int>``는 ``Producer<Number>``의 서브 타입이다.
  • ``T``는 ``out`` position으로만 사용되어야 한다.

다시 처음의 ``List``와 ``MutableList`` 얘기로 돌아가자. ``List``는 ``T``를 out-position으로만 사용한다. 반면 ``MutableList``에는 ``T``를 매개변수로 받는 in-position 함수가 존재한다(``append`` 등). 이런 이유 때문에 ``List``의 타입 매개변수는 ``out``을 선언되고, ``MutableList``는 그렇지 않은 것이다.

예외로, 생성자 매개변수와 private 함수 및 변수는 ``in``도 ``out``도 아니다. 그래서 다음 코드는 컴파일 에러가 나지 않는다. 

class Herd<out T: Animal>(vararg animals: T) { ... }

물론 생성자에서 멤버 변수를 동시에 선언한다면 얘기가 달라진다.

class Herd<T: Animal>(var animals: T) { ... } // out으로 선언하면 안 된다.

그런데 타입 매개변수가 in-position으로만 사용되면 무슨 일이 생길까?

Contravariance

Contravariance는 covariance를 정확히 반대로 뒤집은 것이다. 다음 ``Comparator`` 인터페이스를 보자.

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int { ... }
}

인터페이스 내부의 함수에서 ``T``가 in-position으로만 쓰이고 있다. 따라서 ``T``를 ``in``으로 선언할 수 있다. 

 

보통 타입 위계질서에서 구체적일수록 하위 타입이고 추상적일수록 상위 타입인데, contravariance를 선언하면 이게 반대로 적용된다. 다음의 그림을 보자.

``Cat``의 생산자는 ``Animal``의 생산자로 여겨질 수 있다. 따라서 ``Producer<Cat>``은 ``Producer<Animal>``의 서브 타입이다. 반대로 ``Animal``의 소비자는 ``Cat``의 소비자로 여겨질 수 없다. ``Cat``의 소비자가 더 구체적인 소비자이기 때문이다. 따라서 ``Consumer<Cat>``이 ``Consumer<Animal>``의 상위 타입이다.

 

정리하면 다음과 같다.

``in``과 ``out``을 같이 쓸 수도 있다.

interface MyFunction<in P, out R> {
    operator fun invoke(p: P): R
}

이걸 다르게 표현하면 람다식에서 흔히 보는 ``(P) -> R``이 된다. ``P``는 in-position으로만, ``R``은 out-positon으로만 쓰였다.


지금까지는 variance를 클래스 또는 인터페이스의 선언부에서 일괄적으로 선언했다. 반면 클래스 전체적으로는 variance가 없지만 개별 함수 단위에서는 variance가 존재하는 경우도 있다. 예를 들어 다음의 함수를 보자.

fun <T> copyData(source: MutableList<T>,
                 dest: MutableList<T>) {
    source.forEach { dest.add(it) }
}

``source``는 읽기 전용(in-position)으로, ``dest``는 쓰기 전용(out-position)으로만 쓰였다. 엄밀히 따지자면 ``dest``는 ``T``의 상위 타입을 받아들여도 된다. ``MutableList<Int>``의 데이터를 ``MutableList<Number>``에 복사할 수 있지 않는가?

 

따라서 타입 매개변수를 다음과 같이 바꿔볼 수 있다.

fun <T: R, R> copyData(source: MutableList<T>,
                 dest: MutableList<R>) {
    source.forEach { dest.add(it) }
}

하지만 더 짧게 쓸 수도 있다. ``source``가 out-position으로만 사용된다는 점에 주목하면 된다.

fun <T> copyData(source: MutableList<out T>,
                 dest: MutableList<T>) {
    source.forEach { dest.add(it) }
}

이렇게 선언하면 ``source``에서 ``T``가 in-position으로 사용된 함수를 호출할 수 없다. 이것을 type projection이라고 말한다. 매개변수의 쓰임새를 제한하는 것이다.

 

물론 ``source``를 ``List<T>``로 선언하면 한방에 해결된다. Variance를 이렇게 쓸 수도 있다는 걸 보여주고 싶었다.

 

이제 모든 타입을 받아들일 수 있는 경우를 생각해 보자.

* (와일드 카드)

``*``를 사용하면 제네릭 타입에 대한 어떠한 정보도 제공하지 않겠다는 뜻이 된다. 임의의 타입을 나타낸다고 봐도 좋다. 단, ``MutableList<*>``는 ``MutableList<Any?>``와 다르다. 앞의 것은 타입이 고정되어 있지만 그 타입을 매개변수로 나타내지 않는 것이고, 뒤의 것은 모든 값을 넣을 수 있는 리스트를 말한다.

 

``MutableList<*>``의 타입 매개변수를 모르기 때문에 리스트에 어떠한 값도 넣을 수 없다. 대신 값은 ``Any?`` 타입으로 읽을 수 있다. Out-position 타입이라고 봐도 좋다. 특정 변수의 타입을 모른다면 ``Any?``로 읽을 수 있지만, 모르는 타입의 값을 만들 수 없는 것과 같은 이치이다.

 

타입이 중요하지 않은 상황에서 ``*``를 사용할 수 있다. 예를 들어 리스트의 첫 번째 값을 출력하는 ``printFirst`` 함수는 리스트의 타입에 상관없이 (존재한다면) 첫 번째 값을 출력한다.

fun printFirst(list: List<*>) {
    if (list.isNotEmpty()) println(list.first())
}

사실 제네릭은 Kotlin의 여러 기능 중에서도 이해하기 까다로운 편이다. 여러 번 복습하자.