(Kotlin) 제네릭 - 변성(variance), 타입 프로젝션(type projection)
변성 (variance)
List
는 클래스(기저 클래스)이고, List<Int>
는 타입이다. 타입 A
의 값이 필요한 모든 장소에 타입 B
의 값을 넣어도 아무 문제가 없다면 B
는 A
의 하위 타입(subtype)이다. 하위 클래스와 하위 타입은 미묘한 차이가 있다. A?
와 A
는 같은 클래스에 속하지만, A
는 A?
의 하위 타입이고 그 역은 성립하지 않는다
무공변성, 불변성(invariance)
제네릭 타입을 인스턴스화할 때 서로 다른 타입 인자 가 들어가는 경우 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 무공변 이라 한다. e.g., MutableList<T>
에서 T
가 서로 다르다면 무조건 하위 타입 관계가 성립하지 않으므로 MutableList
는 무공변이다.
Note ) 자바와 코틀린 모두 따로 지정해주지 않으면 기본적으로 모든 제네릭 클래스는 무공변이다.
- 선언 지점 변성 : 코틀린
- 사용 지점 변성 : 코틀린, 자바
공변성(convariance) out : producer
타입 인자 사이의 하위 타입 관계가 성립하고, 그 하위 타입 관계가 그대로 인스턴스 타입 사이의 관계로 이어지는 경우 공변적 이라 한다. e.g., B
가 A
의 하위 타입일 때, 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()
}
}
Cat
이 Animal
을 상속하기는 했지만 어떤 변성도 지정하지 않았기 때문에(무공변) 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 위치에는 사용 가능.
이는 다음 두 가지를 의미한다.
- 이제
feedAll()
은Animal
의 하위 타입으로 이루어진 컬렉션도 받을 수 있다. out
이 지정된 공변적 파라미터는 out 위치(e.g., 리턴 타입)에만 사용할 수 있다.- 이는
T
타입의 값을 생산한다는 의미다. (생산이란? ) - 만약 in 위치의 사용을 제한하지 않는다면,
addAnimal(tiger1)
도 가능하다는 얘기가 되므로Herd<Cat>
이라는 컬렉션의kt animals: Cat
에 Tiger가 들어가는 상황이 생길 수 있다.
생성자 파라미터에는 in/out 위치 관계 없이 그냥 사용 가능하다. 이는 생성자의 경우 굳이 위치를 제한할 필요가 없기 때문이다.
변성은 위험할 여지가 있는 메소드를 호출할 수 없게 만듦으로써 외부에서 제네릭 타입의 기저 클래스 인스턴스를 잘못 사용하는 일이 없도록 방지하는 역할 인데, 생성자는 생성 시점에만 호출되는 메소드이므로 이런 방지 조치가 필요 없기 때문. 그러나 val / var
을 지정하는 경우 게터 세터가 같이 생성되기 때문에 이런 경우 in/out을 따져보아야 한다.
비공개 파라미터 메소드도 같은 맥락에서 in/out 위치 관계 없이 사용 가능하다. 외부에서 애초에 접근이 불가능하기 때문.
반공변성(contravariance) in : consumer
하위 타입 관계가 뒤집히면 반공변적 이다. e.g., B
가 A
의 하위 타입일 때, 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>
다음과 같은 상황에서,
src
가dst
에 대한 하위 타입 호환성을 가지도록 만들고 싶으면서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
를 프로젝션한(제약을 가한) 타입이 된다. 이를 타입 프로젝션 이라 한다. 따라서 이 경우 src
는 MutableList
의 메소드 중 타입 파라미터 T
를 in
위치에 사용하는 메소드는 사용할 수 없다.
이런 식으로 제너릭 클래스 생성 시점에도 변성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
에 이를 지정하는 순간 에러가 발생해 다른 식으로 접근해야 한다는 것을 알려준다.
(이 경우 src
만 out
으로 지정하는게 올바른 방법이다.)
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
사용하면 돼서 굳이 이렇게 써야 할 이유가 없기도 하다.