Post

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

변성 (variance)

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
// 동물 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
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
>>> 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으로 지정하는게 올바른 방법이다.)

1
2
3
4
fun copyData(src: MutableList<out T>, dst: MutableList<T>) {  
    dst.addAll(src)  // OK  
    src.addAll(dst)  // ERR! dst의 타입은 Collection<Noting> 이다.  
}

또한 자바에서는 ?를 사용해도 되고 T를 사용해도 되는 상황에서 어떤 것을 선택할지를 유저에게 맡겼는데, 코틀린은 이를 조금 더 빡빡하게 제한해 일관성있는 코드를 작성할 수 있다.
보통 자바에서 ?T를 모두 사용할 수 있을 때 인자들과 리턴타입이 서로 관계를 맺고 있으면 T, 아니면 ?를 사용해야 조금 더 의도가 명확해지는 코드가 된다.

스타 프로젝션 : *

1. *와 Any?는 다르다.

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

2. *와 자바의 ?는 다르다.

1
MutableList<*> == MutableList<?>

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

1
2
3
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?에 있는 메소드를 호출하거나 하는건 잘 된다.
}

typeReference 에는 * 쓸 이유가 없는 것 같다.

1
2
3
4
5
6
7
8
fun test() {  
    val a = "{\"a\": \"str1\", \"b\": \"str2\"}"  
    val r1 = OBJECT_MAPPER.readValue(a, object : TypeReference<MutableMap<String, Any>>() {})  
    val r2 = OBJECT_MAPPER.readValue(a, object : TypeReference<MutableMap<String, *>>() {})  
  
    val k = r2.get("a") // k는 Any? 타입이다.  
    r2.put("c", "str3" as Nothing) // put 할 때는 Nothing 타입, 즉 put이 불가능하다.  
}

typeRef에 * 사용했다가 성능저하를 경험한 적이 있어, 이렇게는 사용하지 않고 있다.
불변 컬렉션 + Any 사용하면 돼서 굳이 이렇게 써야 할 이유가 없기도 하다.

This post is licensed under CC BY 4.0 by the author.