Post

(Kotlin) 제네릭 - 변성(variance), 타입 프로젝션(type projection)

List는 클래스(기저 클래스)이고, List<Int>는 타입이다. 타입 A의 값이 필요한 모든 장소에 타입 B의 값을 넣어도 아무 문제가 없다면 BA의 하위 타입(subtype)이다. 하위 클래스와 하위 타입은 미묘한 차이가 있다. A?A는 같은 클래스에 속하지만, AA?의 하위 타입이고 그 역은 성립하지 않는다

무공변성, 불변성(invariance)

제네릭 타입을 인스턴스화할 때 서로 다른 타입 인자 가 들어가는 경우 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을무공변 이라 한다. e.g., MutableList<T>에서 T가 서로 다르다면 무조건 하위 타입 관계가 성립하지 않으므로 MutableList는 무공변이다.

Note ) 자바와 코틀린 모두 따로 지정해주지 않으면 기본적으로 모든 제네릭 클래스는 무공변이다.

  • 선언 지점 변성 : 코틀린
  • 사용 지점 변성 : 코틀린, 자바
공변성(convariance) out : producer

타입 인자 사이의 하위 타입 관계가 성립하고, 그 하위 타입 관계가 그대로 인스턴스 타입 사이의 관계로 이어지는 경우공변적 이라 한다. e.g., BA의 하위 타입일 때, List<B>List<A>의 하위 타입이므로 List는 타입 인자 T에 대해 공변적이다.

공변적으로 선언해야 하는 상황은 다음과 같다. 다음과 같은 클래스와 함수들이 있을 때,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 동물 1 개체를 의미
open class Animal {
    fun feed() { }
}
// Animal을 상속한 Cat
class Cat : Animal() { }
// 동물 무리 collection을 의미
class Herd<T: Animal> {
    val size: Int get() = TODO()
    operator fun get(i: Int): T { }
}
// 최상위 함수
fun feedAll(animals: Herd<Animal>) {
    for (i in 0 until animals.size) {
        animals[i].feed()
    }
}

CatAnimal을 상속하기는 했지만 어떤 변성도 지정하지 않았기 때문에(무공변) Herd<Cat>Herd<Animal>의 하위 타입이 아니다. * 물론 이를 강제 캐스팅으로 해결할 수는 있으나 그렇게 하는 것이 올바른 방법은 아니다.

1
2
>>> val cats = Herd<Cat>()
>>> feedAll(cats)    // Type mismatch.  require : Herd<Animal>

제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 out을 지정한다.

1
2
3
class Herd<out T: Animal>(vararg animals: T) {
    fun addAnimal(animal: T) { }     // in 위치라 이렇게는 사용할 수 없다.
    fun getBestAnimal(): T { }       // out 위치에는 사용 가능.

이는 다음 두 가지를 의미한다.

  1. 이제 feedAll()Animal의 하위 타입으로 이루어진 컬렉션도 받을 수 있다.
  2. out이 지정된 공변적 파라미터는 out 위치(e.g., 리턴 타입)에만 사용할 수 있다.
  3. 이는 T 타입의 값을 생산한다는 의미다. (생산이란? )
  4. 만약 in 위치의 사용을 제한하지 않는다면, addAnimal(tiger1)도 가능하다는 얘기가 되므로 Herd<Cat>이라는 컬렉션의 kt animals: Cat에 Tiger가 들어가는 상황이 생길 수 있다.

생성자 파라미터에는 in/out 위치 관계 없이 그냥 사용 가능하다. 이는 생성자의 경우 굳이 위치를 제한할 필요가 없기 때문이다.

변성은 위험할 여지가 있는 메소드를 호출할 수 없게 만듦으로써 외부에서 제네릭 타입의 기저 클래스 인스턴스를 잘못 사용하는 일이 없도록 방지하는 역할 인데, 생성자는 생성 시점에만 호출되는 메소드이므로 이런 방지 조치가 필요 없기 때문. 그러나 val / var을 지정하는 경우 게터 세터가 같이 생성되기 때문에 이런 경우 in/out을 따져보아야 한다.

비공개 파라미터 메소드도 같은 맥락에서 in/out 위치 관계 없이 사용 가능하다. 외부에서 애초에 접근이 불가능하기 때문.

반공변성(contravariance) in : consumer

하위 타입 관계가 뒤집히면반공변적 이다. e.g., BA의 하위 타입일 때, Comparator<A>Comparator<B>의 하위 타입이므로 Comparator는 타입 인자 T에 대해 반공변적이다. 즉, Comparator<String>을 요구하는 함수에 Comparator<Any>를 넘길 수 있다는 것을 의미한다. * 어떤 타입의 객체를 비교할 때 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있기 때문. in이 지정된 반공변적 파라미터는 in 위치(e.g., 파라미터 타입)에만 사용할 수 있다. 이는 T 타입의 값을 소비한다는 의미다.

타입 프로젝션 ( type projection )

Note ) 어떤 클래스의 하위 타입 관계를 지정한다는 역할도 포함하고 있지만, 그 보다는 in이 지정된 타입은 in위치에서만 사용할 수 있도록 한다는, 데이터에 대한 수정/읽기 제한의 의미가 더 큰 듯 하다.

클래스를 선언하면서 클래스 자체에 변성을 지정하는 방식 (클래스에 in/out을 지정하는 방식)은선언 지점 변성(declaration-site variance) 이다. 선언 하면서 지정하면, 클래스의 공변성을 전체적으로 지정하는게 되기 때문에 클래스를 사용하는 장소에서는 따로 타입을 지정해줄 필요가 없어 편리하다. 반면사용 지점 변성(use-site variance) 은 메소드 파라미터에서, 또는 제네릭 클래스를 생성할 때 등 구체적인 사용 위치에서 변성을 지정하는 방식이다. 자바에서 사용하는 한정 와일드카드(bounded wildcard)가 바로 이 방식이다. 이는 타입 파라미터가 있는 타입을 사용할 때마다 해당 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지를 명시해야 한다.

코틀린도 사용 지점 변성을 지원하며, 자바의 한정 와일드카드와 동일하다.

1
2
MutableList<out T> == MutableList<? extends T>
MutableList<in T>  == MutableList<? super T>

다음과 같은 상황에서,

  1. srcdst에 대한 하위 타입 호환성을 가지도록 만들고 싶으면서
  2. src에 대한 수정을 방지하고 싶은 경우. 이 경우 MutableList<T>는 in/out 위치에서 모두 사용되기 때문에 out을 지정해줄 수 없다.

Note ) 사실 2번 같은 경우 src: List<T>로 구현하는게 best practice지만, 예제를 설명하기 위함.

1
2
3
4
5
6
fun <T> copyData(src: MutableList<T>,
                 dst: MutableList<T>) {
    for (item in src) {
        dst.add(item)
    }
}

1번만 해결해야 하고 2번이 필요 없는 경우, 즉 T를 지정한 위치와는 반대되는 위치에 사용하는 메소드도 호출해야 한다면 다음과 같이 타입 파라미터를 하나 더 쓰는 방법으로 작성하는게 좋다.

1
2
3
4
5
6
fun <T: R, R> copyData(src: MutableList<T>,
                       dst: MutableList<R>) {
    for (item in src) {
        dst.add(item)
    }
}

사용 지점 변성을 사용하면 1, 2를 모두 만족하면서 더 안전하게 처리할 수 있다. ( C#도 같은 방식이다. )

1
2
3
4
5
6
7
fun <T> copyData(src: MutableList<out T>,
                 dst: MutableList<T>) {
    for (item in src) {
        dst.add(item)
    }
}

이렇게 지정하면 src의 타입이 MutableList를 프로젝션한(제약을 가한) 타입이 된다. 이를타입 프로젝션 이라 한다. 따라서 이 경우 srcMutableList의 메소드 중 타입 파라미터 Tin 위치에 사용하는 메소드는 사용할 수 없다.

이런 식으로 제너릭 클래스 생성 시점에도 변성in/out을 지정할 수 있다.

1
2
3
4
>>> val list: MutableList<out Number> = ...  
>>> list.add(1)
Error: Out-projected type ....

이런식으로 사용할 수 있는 위치를 제한하는 이유는, type safety를 보장하기 위해서다.
예를 들어, Number 타입의 하위 타입이면서 서로 같은 두 인자를 받고 싶을 때 이런 식으로 코딩하게 되면 예상과 다르게 동작한다.

1
 public static void copyData(List<? extends Number> src, List<? extends Number> dst)  

이런 코드는 src의 타입과 dst의 타입이 같으리라고 보장할 수 없기 때문에 type safe하지 않다.
코틀린으로 위와 같은 코드를 짜려고 하면, <? extends T>와 대응되는 것은 <out T>이므로 dst에 이를 지정하는 순간 에러가 발생해 다른 식으로 접근해야 한다는 것을 알려준다.
(이 경우 srcout으로 지정하는게 올바른 방법이다.)
또한 자바에서는 ?를 사용해도 되고 T를 사용해도 되는 상황에서 어떤 것을 선택할지를 유저에게 맡겼는데, 코틀린은 이를 조금 더 빡빡하게 제한해 일관성있는 코드를 작성할 수 있다.
* 보통 자바에서 ?T를 모두 사용할 수 있을 때 인자들과 리턴타입이 서로 관계를 맺고 있으면 T, 아니면 ?를 사용해야 조금 더 의도가 명확해지는 코드가 된다.

스타 프로젝션 : *

1. *와 Any?는 다르다.

왜냐면, MutableList<T>T에 대해 무공변이기 때문. MutableList<Any?>는 모든 타입의 원소를 담을 수 있음을 의미하는 반면, MutableList<\*>은 어떤 타입이라도 들어올 수 있으나, 구체적인 타입이 결정되는 과정이 진행되고, 일단 타입이 결정되면 그 타입(과 하위 타입)의 원소만 담을 수 있다. 즉, 구체적인 타입이 결정된다는 점이 중요하다.

2. *와 자바의 ?는 다르다.
1
2
MutableList<*>  == MutableList<?>

자바로 표현하면 위와 같겠으나, bounded wildcard는 사실 in/out에 대응되기 때문에, unbounded만 표현한다고 볼 수 있다. 게다가, 코틀린 컴파일러는 <\*>를 맥락에 따라서 해석한다. 그러니까, 다음과 같이 정의되어 있는 인터페이스에 대해서

1
2
3
4
interface Function(in T, out U> {
    . . .
}

이 인터페이스를 구현한 것들을 인자로 받고 싶다면 다음과 같이 적을텐데,

1
2
3
fun foo(bar: Function<*, *>) {
    // == bar: Function<in Nothing, out Any?>
}

in으로 정의되어 있는 인자를 \*로 받으면 in Nothing인 것으로 간주한다. out으로 정의되어 있는 인자를 \*로 받으면 out Any?인 것으로 간주한다. 그래서 \*를 사용하더라도 함수 내부에서 T, U의 위치에 따라 메소드 호출이 제한될 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VarianceTest<in T, out U>(conT: T, conU: U) {
//    val propT: T = conT
//    type parameter T is declared as 'in' but occurs in 'out' position in type T
    val propU: U = conU
//    fun printAll(t: T, u: U)
//    type parameter U is declared as 'out' but occurs in 'in' position in type U
    fun printAll(t: T) {
        print(t)
    }
}
fun starTestFunc(v: VarianceTest<*, *>) {
//    v.printAll(1)
//    Out-projected type 'VarianceTest<*, *>' prohibits the use of 'public final fun printAll(t: T): Unit defined in VarianceTest'
//    type hinting도 아예 v.printAll(t: Nothing)으로 잡힌다.
    print(v.propU)    // out은 out Any?가 되니까 Any?에 있는 메소드를 호출하거나 하는건 잘 된다.
}
This post is licensed under CC BY 4.0 by the author.