Kotlin 제네릭스(Generic) - 공변(covariant)과 불변(invariant)에 대해 이해

공변인(covariant)은 무슨 뜻인가?

IntAny로 사용할 수 있다(Any는 Java에서는 Object을 말한다.).

그럼

  1. List<Int>List<Any>로 취급할 수 있을까?
  2. MutableList<Int>MutableList<Any>로 취급할 수 있을까?

이에 대한 대답은 사용중인 Generic 클래스가 형식 매개 변수에 대해 공변성(covariant) 또는 불변(invariant)인지 여부에 따라 결정된다.

  1. List<Int>List<Any>로 사용할 수 있다(List는 공변이다(=covariant)).
  2. MutableList<Int>MutableList<Any>로 사용할 수 없다(MutableList는 공변이 아니다(= invariant)).

‘Generic 클래스가 공변이다’라는 것은 타입 인수로 지정한 타입의 부모와 자식 관계가 Generic 클래스에 의해 생성된 타입의 부모와 자식 관계와 같다는 것을 의미한다. 예를 들어, List<E>는 그 타입 매개변수 E에 대해 공변이므로 List<Int>List<Any>로 취급할 수 있다(List<Int>List<Any>의 하위 타입으로 간주된다).

List<Int>List<Any>로 취급 가능하다.

fun showElems(list: List<Any>) {
    for (e in list) {
        println(e)
    }
}

fun main() {
    val a = listOf(0, 1, 2) // List<Int>
    showElems(a)            // OK!
}

반면에 MutableList<E>는 타입 매개변수 E에 대해 공변이 아니므로 MutableList<Int>MutableList<Any>로 취급할 수 없다. 전혀 호환되지 않는 타입으로 취급된다.

왜 이런 차이가 발생하는 것일까? 다음과 같은 코드를 생각하면 쉽게 이해할 수 있다.

MutableList<Int>MutableList<Any>로 처리 할 수 ​​없다.

fun addElems(list: MutableList<Any>) {
    list.add("Hello")
    list.add("World")
}

fun main() {
    val a = mutableListOf(0, 1, 2)  // MutableList<Int>
    addElems(a)                     // Compile Error!
}

만약 위와 같은 코드가 가능하게 된다면, MutableList<Int> 타입의 객체에 Int 이외의 임의의 요소를 추가할 수 있게 되어 버린다. 그래서 MutableList<E>는 타입 매개변수 E에 대해 공변이 아니라고 정의되어 있는 것이다. 따라서 위와 같은 코드는 컴파일 오류가 발생한다.

List<Int>의 요소가 더 범용적인 Any로만 사용된다면 문제가 없지만, MutableList<Int>가 더 범용적인 타입인 Any를 받아들이는 것은 문제가 있다는 뜻이다.

다소 어렵지만 공변(covariant)과 불변(invariant)은 Kotlin의 제네릭스를 다루는데 있어서 매우 중요한 개념이므로, 다시 한 번 정리해 보겠다.

A가 B의 하위 유형일 때, List<A>List<B>의 하위 타입이 된다. 이러한 Generic 클래스를 공변(covariant)이라고 한다. A가 B의 하위 타입이더라도 MutableList<A>MutableList<B>의 서브타입이 될 수 없다. 이러한 Generic 클래스를 불변(invariant)라고 한다(=공변이 아니다).

공변형 Generic 클래스(covariant class) 정의

어떤 타입 파라미터에 대해 공변인 Generic 클래스를 정의하려면 타입 매개변수에 out 키워드를 붙여서 정의한다. 반대로 아무런 수식어를 붙이지 않고 타입 매개변수를 정의하면 해당 Generic 클래스는 기본적으로 불변(invariant)이 된다.

class Holder<out T>(val elem: T) {
    fun get(): T = elem
}

타입 파라미터에 out을 붙인다는 것은 해당 Generic 클래스의 구현에서 해당 타입 파라미터를 출력용으로만 사용하겠다는 선언이기도 한다. 출력용으로 사용한다는 것은 반환값의 위치에서만 해당 타입 파라미터를 사용한다는 의미아다. 그래서 이런 클래스를 producer라고 읽기도 하고, 샘플 코드의 클래스 이름으로 Producer가 사용되기도 한다. 개인적으로는 이러한 명칭은 샘플 코드로는 이해하기 어렵다고 생각하기 때문에, 여기서는 Holder라는 클래스명으로 사용하였다.

전문 용어로는 getter 등의 출력 위치에서 사용하는 것을 out position에서 사용하고, setter 등의 입력 위치에서 사용하는 것을 in position에서 사용한다고 한다. 타입 매개변수에 out을 붙일 수 있는 것은 해당 타입 매개변수를 out position에서만 사용하는 경우에만 가능하다(정확히는 in position에서 사용하지 않는 경우).

예를 들어, 위에 예제 코드에서는 타입 파라미터 T를 getter의 반환값(out position)의 타입으로만 사용하고 있다(생성자 파라미터로도 사용하지만, 생성자의 val 파라미터는 본질적으로 getter를 정의하는 것이므로, out position으로 간주된다). in position에서는 타입 파라미터 T를 전혀 사용하지 않기 때문에 out 수식어를 추가할 수 있으며, 공변(covariant)인 Generic 클래스로 만들 수 있다.

만약 다음과 같이 out을 붙인 타입 파라미터를 in position으로 사용한다면,

class Holder<out T>(val elem: T) {
    fun dump(t: T) { ... }  // ERROR
}
Type parameter T is declared as 'out' but occurs in 'in' position in type T

이와 같은 컴파일 에러가 발생하게 된다. 왜냐하면, 이런 사용법을 허용하면 앞서 설명한 MutableList 예제와 같이 타입의 안전성을 유지할 수 없게 되는 경우가 발생하기 때문이다.

이제 다음과 같이 상속 관계가 있는 Animal 클래스와 Bird 클래스가 있다고 가정해 보자. BirdAnimal의 하위 타입이다.

open class Animal {
    fun eat() { println("EAT") }
}

class Bird : Animal() {
    fun fly() { println("FLY") }
}

그리고 Holder<Animal>을 파라미터로 받는 doEat 함수를 다음과 같이 정의했다고 하자.

fun doEat(holder: Holder<Animal>) {
    val animal = holder.get()
    animal.eat()
}

Holder 클래스는 공변 제네릭 클래스로 정의(타입 파라미터 Tout을 붙여 정의)되어 있으므로 Holder<Bird> 객체는 Holder<Animal> 객체 대신 사용할 수 있다. 따라서 다음과 같이 doEat 함수에 Holder<Bird> 객체를 전달할 수 있다.

val holder: Holder<Bird> = Holder(Bird())
doEat(holder)

이것이 class Holder<out T>와 같이 타입 파라미터에 out을 붙여 정의한 효과이다. 만약 out을 붙이지 않고 class Holder<T>와 같이 불변(invariant) 클래스로 정의했다면, doEat(holder) 호출은 다음과 같은 에러가 발생한다.

Type mismatch: inferred type is Holder<Bird> but Holder<Animal> was expected

Holder이 공변이 아니기 때문에 Holder과 Holder 사이에는 호환되지 않습니다. 다만, 이것에는 조금만 빠져나가는 길이 있어, 제네릭 클래스를 사용하는 측(여기에서는 doEat 함수를 정의하는 장소)에서, 다음과 같이 out 를 붙여, 「공변(covariant)인 형태 파라미터로서 사용해요」라고 선언해 버리는 방법이 있습니다.

Holder가 공변이 아니기 때문에 Holder<Bird>Holder<Animal> 사이에는 아무런 호환성이 없기 때문이다. 다만, 여기에는 약간의 허점이 있는데, 제네릭 클래스를 사용하는 쪽(여기서는 doEat 함수를 정의하는 곳)에서 다음과 같이 out을 붙여서 ‘공변형(covariant) 타입 파라미터로 사용하겠다’라고 선언하는 방법이 있다.

fun doEat(holder: Holder<out Animal>) {
    val animal = holder.get()
    animal.eat()
}

이는 Holder 클래스 자체는 공변으로 정의하지 않았지만, 적어도 이 함수의 구현에서는 공변 클래스로 사용한다는 것을 나타낸다(Holder<Animal> 대신 Holder<Bird>로 전달해도 된다). 이렇게 제네릭 클래스를 사용하는 곳에서 타입 인수에 out 한정자를 붙이는 방법을 **use-site variance(사용 위치 변환)**라고 한다. 더 자세한 내용은 아래에서 설명한다.

Kotlin List 및 MutableList 정의

그럼 Kotlin의 List 인터페이스와 MutableList 인터페이스의 정의를 살펴보겠다.

interface List<out E> : Collection<E> { ... }
interface MutableList<E> : List<E>, MutableCollection<E> { ... }

List 인터페이스의 타입 파라미터 E에는 out이 붙어 있고, MutableList 인터페이스에는 붙어 있지 않다. 따라서 List는 타입 파라미터 E에 대해 공변(covariant) 제네릭 클래스이고, MutableList는 불변(invariant) 제네릭 클래스이다.

타입 파라미터의 out 수식어는 주로 팩토리 계열의 클래스나 불변(immutable)인 데이터 홀더 계열의 클래스에서 많이 사용하게 된다. 하지만 clear()와 같이 매개변수를 전혀 받지 않고 객체의 내용을 변경하는 함수도 있을 수 있으므로, 불변(immutable) 클래스만 공변(covariant) 클래스가 될 수 있는 것은 아니다.

반공변 Generic 클래스(contravariant class) 정의

공변(covariant)에 가까운 개념으로 그 반대의 성질을 가진 반공변(contravariant)이 있다. 공변(covariant)인 제네릭 클래스에서 생성된 타입은 해당 타입 인수로 지정한 타입의 부모와 자식 관계가 동일하지만, 반공변(contravariant)인 제네릭 클래스에서 생성된 타입은 이 부모와 자식 관계가 역전된다. 글로만 설명하면 이해하기 어렵기 때문에, 코드로 살펴보자.

먼저 공변(covarinat)에 대한 복습부터 시작하겠다. 공변인 타입 매개변수는 out으로 한정한다.

interface List<out E>

List<Int>List<Any>의 하위 타입이다.

다음으로, 반공변(contravariant)의 예이다. 반공변인 인터페이스의 대표적인 예로 Comparator 인터페이스를 들 수 있다. 반공변 타입 매개변수는 in으로 한정한다.

interface Comparator<in T> {
    fun compare(a: T, b: T): Int
}

Comparator<Int>Comparator<Any>의 상위 타입이 된다. 즉, Comparator<Any>Comparator<Int>의 하위 타입이 된다. 이 부분의 부모와 자식 관계가 공변(covariant)였을 때와 반대로 되어 있기 때문에 반공변(contravariant)이라고 부른다.

왜 이렇게 부모와 자식 관계가 역전되는 것일까? Comparator<Any>Comparator<Int>의 관계를 생각해보자. Comparator<Int>를 매개변수로 받는 함수에는 Comparator<Any> 객체를 전달할 수 있다.

val anyComp = Comparator<Any> { a, b ->
    a.hashCode() - b.hashCode()
}

val intList = mutableListOf(3, 1, 5)
intList.sortWith(anyComp)

왜냐하면 Comparator<Any> 구현에서는 Any 인터페이스만 참조하기 때문에 Int 객체 간의 비교에 해당 구현을 사용해도 아무런 문제가 없기 때문이다.

in 수식어를 붙일 수 있는 타입 파라미터는 in position에서만 사용되는 것으로 제한된다(정확히는 public 함수의 반환값과 같이 out position에서는 전혀 사용되지 않는 것들). 그 제네릭 클래스 내에서 타입 매개변수를 받아들이는(혹은 소비하는) 용도로만 사용하기 때문에 그 제네릭 클래스를 consumer라고 부르기도 한다. 그래서 예제 코드에서는 반공변(contravariant) 제네릭 클래스의 이름이 Comsumer로 되어 있기도 하다.

Kotlin의 Continuation 인터페이스도 반공변 제네릭 인터페이스 중 하나이다.

interface Continuation<in T> {
   val context: CoroutineContext
   fun resume(value: T)
   fun resumeWithException(exception: Throwable)
}

declaration-site variance(선언 위치 변환) 및 use-site variance(사용 위치 변환)

앞에서 Holder 클래스 설명에서도 조금 나왔지만, 제네릭 클래스의 타입 파라미터에 out이나 in과 같은 공변 수식어(variance modifier)를 붙이는 경우, Kotlin에서는 그 추가 시점이 두 가지 패턴으로 나뉜다.

  • declaration-site variance(선언 위치 변환)
    • 클래스나 인터페이스 선언 시 타입 매개변수를 out이나 in으로 수정한다.
  • use-site variance(사용 위치 변환)
    • 이미 정의된 제네릭 클래스를 사용할 때 타입 인수에 out이나 in을 붙인다.

declaration-site variance(선언 위치 변환)

다음은 List 인터페이스와 같이 인터페이스를 선언할 때 타입 파라미터에 out이나 in 수식어를 붙이는 방법이다.

interface List<out E> : Collection<E> { ... }

이 경우 이 List 인터페이스를 사용하는 곳에서는 기본적으로 공변(covariant)으로 처리된다. 예를 들어, List<Number>를 받는 함수에는 List<Int>List<Double>을 전달할 수 있다.

fun showNumbers(nums: List<Number>) {
    println(nums)
}

fun main() {
    showNumbers(listOf(1, 2, 3))       // List<Int>을 전달할 있다.
    showNumbers(listOf(0.1, 0.2, 0.3)) // List<Double>도 전달 할 수 있다.
}

좀 더 쉽게 설명하면, List 인터페이스에서는 다음과 같은 대입이 가능하다는 것이다.

val nums: List<Number> = listOf<Int>(1, 2, 3)  // OK

반대로,MutableList 인터페이스는 공변 (convariant) 으로 정의되어 있지 않기 (= 불변 (invariant)) 때문에, 다음과 같은 대입을 할 수 없습니다.

반대로 MutableList 인터페이스는 공변(convariant)으로 정의되어 있지 않기 때문에(=불변(invariant)) 다음과 같은 대입이 불가능하다.

val nums: MutableList<Number> = mutableListOf<Int>(1, 2, 3)  // NG

use-site variance(사용 위치 변환)

제네릭 클래스를 사용할 때 그 타입 인수에 in이나 out 키워드를 붙이는 방법이다.

예를 들어, Kotlin의 MutableList는 불변(invariant) 제네릭 클래스이므로 다음과 같이 MutableList<Int>MutableList<Number>로 취급할 수 없다.

val list: MutableList<Number> = mutableListOf<Int>(1, 2, 3)  // ERROR

이러한 불변(invariant) 클래스라도 사용하는 곳에 out 키워드를 붙이면 그 부분만 공변으로 간주하여 사용할 수 있다. 그 부작용으로 in position으로 요소를 다룰 수 없게 되므로 add() 함수 등을 호출할 수 없게 된다.

val list: MutableList<out Number> = mutableListOf<Int>(1, 2, 3)
list.add(1.5)  // ERROR

이와 같이 타입 인수를 사용하는 것을 전문 용어로 **타입 투영(type projection)**이라고 부른다. 이 경우 out 키워드를 붙였기 때문에 out-projected 되었다고 한다.

좀 더 실용적인 예로 다음과 같이 배열(Array)의 내용을 복사하는 함수를 생각해 보겠다.

fun <T> copyArray(src: Array<T>, dst: Array<T>) {
    assert(src.size == dst.size)
    for (i in src.indices) {
        dst[i] = src[i]
    }
}

Kotlin의 Array 클래스는 다음과 같이 타입 파라미터에 variance modifier(out, in)가 붙지 않기 때문에 MutableList와 마찬가지로 불변(invariant)의 제네릭 클래스이다.

class Array<T>

따라서 Array<Int>Array<Number>와는 호환되지 않으며, 다음과 같은 코드는 에러가 발생한다.

val arr1 = arrayOf<Int>(1, 2, 3)
val arr2 = arrayOfNulls<Number>(3)
copyArray(arr1, arr2)  // ERROR

따라서 사용 위치 변환(use-site variance) 메커니즘을 이용하여 dst 파라미터의 타입 파라미터에 in 키워드를 붙여서 반공변량(contravariant)으로 만든다. in 키워드를 붙인다는 것은 이 dst 객체는 T 타입의 요소만 가져온다(consume)는 선언이 된다.

fun <T> copyArray2(src: Array<T>, dst: Array<in T>) {
    assert(src.size == dst.size)
    for (i in src.indices) {
        dst[i] = src[i]
    }
}

그러면 이 함수의 src 파라미터에 Array<Int>를 전달하면 dst 파라미터에 Array<Number> 등을 하위 타입으로 간주하여 전달할 수 있게 된다.

val arr1 = arrayOf<Int>(1, 2, 3)
val arr2 = arrayOfNulls<Number>(3)
copyArray2(arr1, arr2)  // OK

Array 클래스는 원래 불변(invariant) 클래스로 정의된 것인데, 이 함수만 반공변(contravariant)으로 사용할 수 있게 되었다는 뜻이다.

참고로 Java에는 이 use-site variance만 존재하며, <? extends Hoge><? super Hoge>와 같은 상한 경계, 하한 경계를 지정하는 방법이 사용되었다.

in position 과 out position

타입 매개변수를 공변(covariant)으로 만들려면 out 수정자를, 반공변(contravariant)으로 만들려면 in 수정자를 붙여야 한다. 이러한 수식어를 붙일 때의 제약 조건은 다음과 같다.

  • out: in position에서 사용하는 타입 매개변수에는 붙일 수 없다.
  • in: out position에서 사용하는 타입 매개변수에는 붙일 수 없다.

여기서는 어떤 위치에서 타입 파라미터를 참조하는 것이 in position과 out position 중 어느 위치에서 사용하는 것으로 간주되는지 정리해 본다.

  • in position
    • public인 함수의 파라미터
    • public인 프로퍼티의 setter(생성자 파라미터로, var가 붙은 것. 내부적으로 setter가 정의되어 있기 때문에 in position으로 간주됨)
  • out position
    • public 함수의 반환값
    • public인 프로퍼티의 getter(생성자 매개변수로 val 또는 var가 붙은 것. 내부적으로 getter가 정의되어 있기 때문에 out position으로 간주됨)
  • in position도 out position도 아니다(공변이나 반공변도 가능하다).
    • private인 함수나 프로퍼티의 매개변수 및 반환값(private인 함수에서는 타입 오용의 우려가 없기 때문에)
    • 생성자 매개변수로 valvar도 붙지 않은 것(초기화 시에만 호출되며, 타입 매개변수 오용의 위험이 적기 때문에 in position도 out position도 아닌 것으로 간주됨)

Kotlin의 배열은 불변, Java의 배열은 공변

Kotlin의 Array 클래스는 불변(invariant) 제네릭 클래스로 정의되어 있다. 따라서 기본적으로 Array 클래스에서 생성되는 서로 다른 타입 사이에 부모-자식 관계는 발생하지 않는다(앞서 설명한 타입 투영(use-site variance) 메커니즘을 사용하는 경우는 예외이다).

val arr: Array<Any> = arrayOf<Int>(1, 2, 3) // ERROR

한편, Java의 배열은 공변(covariant)으로 정의되어 있다. 그래서 다음과 같은 대입이 가능했다.

Integer[] nums = {1, 2, 3};
Object[] objs = nums;
objs[0] = "ABC"; // Runtime ERROR

이 스펙의 문제점으로 위와 같이 Integer[] 배열에 String 객체를 저장하는 코드가 컴파일될 수 있다는 점을 들 수 있다. 이러한 문제를 해결하기 위해 Kotlin에서는 Array<E> 클래스를 (원시적인 요소를 담는 IntArrayCharArray와 마찬가지로) 불변(invariant)으로 만들기로 했다. Array<Int>Array<Any> 사이에는 호환성이 없기 때문에 위의 Java 예시처럼 예상치 못한 타입의 요소가 저장될 염려가 없다.

List<Any?>와 List<*> (star projection)의 차이점

제네릭 타입의 객체를 받는 함수를 정의할 때 요소의 타입 정보를 특별히 의식하지 않는 경우에는 타입 매개변수 대신 보다 간결한 스타 프로젝션(star projection) 구문을 사용할 수 있다.

fun dump(list: List<*>) {
    for ((index, elem) in list.withIndex()) {
        println("$index: $elem")
    }
}

fun main() {
    val list = listOf("AAA", "BBB", "CCC")
    dump(list)
}

위의 dump 함수를 다음과 같이 타입 파라미터를 사용하여 정의해도 동일하게 동작하지만, 함수 정의에서 타입 정보를 사용하지 않기 때문에 스타 프로젝션 구문을 사용하는 것이 더 간단하게 작성할 수 있다.

fun <T> dump(list: List<T>)

타입 매개변수의 위치에 <*>을 지정하는 것과 <Any?>를 지정하는 것은 비슷하게 느껴지지만 다음과 같이 분명한 차이가 있다.

  • MutableList<*>: 특정 타입의 요소가 들어있는 리스트
  • MutableList<Any?>: 무엇이든 담을 수 있는 목록

MutableList<*>으로 참조하는 목록의 실제로는 MutableList<Int>이지만, MutableList<Any?>으로 참조하는 목록은 반드시 MutableList<Any?> 이다.

MutableList<*>를 받는 함수는 구체적인 타입은 신경 쓰지 않지만, 호출자에서 구체적인 타입 인자를 지정하여 만든 목록이 전달될 것을 예상하고 있다. 즉, 다음과 같은 코드는 타입 안전성이 없기 때문에 컴파일 에러가 발생한다.

fun addSomething(list: MutableList<*>) {
    list.add("Hello")  // ERROR: 함부로 String을 넣으면 안된다.
}

fun main() {
    var intList = mutableListOf(1, 2, 3)
    addSomething(intList)
}

한편, MutableList<Any?>은 무엇이든 저장할 수 있는 목록임을 나타낸다.

fun addSomething(list: MutableList<Any?>) {
    list.add("Hello")
}

fun main() {
    var list = mutableListOf<Any?>(1, "A", null)
    addSomething(list)
}

MutableList은 불변(invariant)인 클래스이므로 MutableList<Any?>을 받는 함수는 MutableList<Any?> 객체만 전달할 수 있다.

스타 프로젝션은 요소를 읽기 전용으로 만드는 특성을 가지고 있다.

val list1 = mutableListOf(1, 2, 3)
list1.add(100)  // OK

val list2: MutableList<*> = list1
list2.add(100)  // ERROR

참고로 Java에서는 와일드카드 문자로 *가 아닌 ?를 와일드카드 문자로 사용하지만, Kotlin과 마찬가지로 컬렉션 클래스를 읽기 전용으로 만드는 작용이 있다.

List<String> strList = new ArrayList<>();
strList.add("Hello");  // OK

List<?> roList = strList;
roList.add("World");  // ERROR



최종 수정 : 2023-12-17