이동식 저장소

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

Primary/Kotlin

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

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

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

Variance?

다음과 같은 함수가 있다.

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

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

 

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

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

listMutableList<String>을 넘겨줄 수 있을까?

 

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

 

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

 

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

클래스, 타입, 서브 타입

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

 

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

 

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

 

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

 

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

 

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

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

이 질문을

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

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

 

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

 

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

Covariant

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

 

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>의 서브 타입이다.
  • Tout position으로만 사용되어야 한다.

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

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

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으로만 쓰이고 있다. 따라서 Tin으로 선언할 수 있다. 

 

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

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

 

정리하면 다음과 같다.

inout을 같이 쓸 수도 있다.

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)으로만 쓰였다. 엄밀히 따지자면 destT의 상위 타입을 받아들여도 된다. 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이라고 말한다. 매개변수의 쓰임새를 제한하는 것이다.

 

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

 

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

* (와일드 카드)

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

 

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

 

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

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

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

Comments