[Kotlin] 제네릭 : 변성(variance), 타입 프로젝션(type projection)
``kt List``는 클래스(기저 클래스)이고, ``kt List<Int>``는 타입이다.
타입 ``kt A``의 값이 필요한 모든 장소에 타입 ``kt B``의 값을 넣어도 아무 문제가 없다면 ``kt B``는 ``kt A``의 하위 타입(subtype)이다.
하위 클래스와 하위 타입은 미묘한 차이가 있다. ``kt A?``와 ``kt A``는 같은 클래스에 속하지만, ``kt A``는 ``kt A?``의 하위 타입이고 그 역은 성립하지 않는다
무공변성, 불변성(invariance)
제네릭 타입을 인스턴스화할 때 서로 다른 타입 인자가 들어가는 경우 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 무공변이라 한다.
e.g., ``kt MutableList<T>``에서 ``kt T``가 서로 다르다면 무조건 하위 타입 관계가 성립하지 않으므로 ``kt MutableList``는 무공변이다.
Note ) 자바와 코틀린 모두 따로 지정해주지 않으면 기본적으로 모든 제네릭 클래스는 무공변이다.
- 선언 지점 변성 : 코틀린
- 사용 지점 변성 : 코틀린, 자바
공변성(convariance) out : producer
타입 인자 사이의 하위 타입 관계가 성립하고, 그 하위 타입 관계가 그대로 인스턴스 타입 사이의 관계로 이어지는 경우 공변적이라 한다.
e.g., ``kt B``가 ``kt A``의 하위 타입일 때, ``kt List<B>``는 ``kt List<A>``의 하위 타입이므로 ``kt List``는 타입 인자 T에 대해 공변적이다.
공변적으로 선언해야 하는 상황은 다음과 같다. 다음과 같은 클래스와 함수들이 있을 때,
// 동물 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()
}
}
``kt Cat``이 ``kt Animal``을 상속하기는 했지만 어떤 변성도 지정하지 않았기 때문에(무공변) ``kt Herd<Cat>``은 ``kt Herd<Animal>``의 하위 타입이 아니다.
* 물론 이를 강제 캐스팅으로 해결할 수는 있으나 그렇게 하는 것이 올바른 방법은 아니다.
```kt
>>> val cats = Herd<Cat>()
>>> feedAll(cats) // Type mismatch. require : Herd<Animal>
```
제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 ``kt out``을 지정한다.
class Herd<out T: Animal>(vararg animals: T) {
fun addAnimal(animal: T) { } // in 위치라 이렇게는 사용할 수 없다.
fun getBestAnimal(): T { } // out 위치에는 사용 가능.
이는 다음 두 가지를 의미한다.
- 이제 ``kt feedAll()``은 ``kt Animal``의 하위 타입으로 이루어진 컬렉션도 받을 수 있다.
- ``kt out``이 지정된 공변적 파라미터는 out 위치(e.g., 리턴 타입)에만 사용할 수 있다.
- 이는 ``kt T`` 타입의 값을 생산한다는 의미다. (생산이란?)
- 만약 in 위치의 사용을 제한하지 않는다면, ``kt addAnimal(tiger1)``도 가능하다는 얘기가 되므로 ``kt Herd<Cat>`` 이라는 컬렉션의 ``kt animals: Cat``에 Tiger가 들어가는 상황이 생길 수 있다.
생성자 파라미터에는 in/out 위치 관계 없이 그냥 사용 가능하다. 이는 생성자의 경우 굳이 위치를 제한할 필요가 없기 때문이다.
변성은 위험할 여지가 있는 메소드를 호출할 수 없게 만듦으로써 외부에서 제네릭 타입의 기저 클래스 인스턴스를 잘못 사용하는 일이 없도록 방지하는 역할인데, 생성자는 생성 시점에만 호출되는 메소드이므로 이런 방지 조치가 필요 없기 때문.
그러나 ``kt val / var``을 지정하는 경우 게터 세터가 같이 생성되기 때문에 이런 경우 in/out을 따져보아야 한다.
비공개 파라미터 메소드도 같은 맥락에서 in/out 위치 관계 없이 사용 가능하다. 외부에서 애초에 접근이 불가능하기 때문.
반공변성(contravariance) in : consumer
타입 프로젝션 ( type projection )
코틀린도 사용 지점 변성을 지원하며, 자바의 한정 와일드카드와 동일하다.
MutableList<out T> == MutableList<? extends T>
MutableList<in T> == MutableList<? super T>
- `` src``가 `` dst``에 대한 하위 타입 호환성을 가지도록 만들고 싶으면서
- `` src``에 대한 수정을 방지하고 싶은 경우.
이 경우 ``kt MutableList<T>``는 in/out 위치에서 모두 사용되기 때문에 ``kt out``을 지정해줄 수 없다.
fun <T> copyData(src: MutableList<T>,
dst: MutableList<T>) {
for (item in src) {
dst.add(item)
}
}
1번만 해결해야 하고 2번이 필요 없는 경우, 즉 `` T``를 지정한 위치와는 반대되는 위치에 사용하는 메소드도 호출해야 한다면 다음과 같이 타입 파라미터를 하나 더 쓰는 방법으로 작성하는게 좋다.
fun <T: R, R> copyData(src: MutableList<T>,
dst: MutableList<R>) {
for (item in src) {
dst.add(item)
}
}
사용 지점 변성을 사용하면 1, 2를 모두 만족하면서 더 안전하게 처리할 수 있다. ( C#도 같은 방식이다. )
fun <T> copyData(src: MutableList<out T>,
dst: MutableList<T>) {
for (item in src) {
dst.add(item)
}
}
이렇게 지정하면 `` src``의 타입이 `` MutableList``를 프로젝션한(제약을 가한) 타입이 된다. 이를 타입 프로젝션이라 한다.
따라서 이 경우 `` src``는 `` MutableList``의 메소드 중 타입 파라미터 `` T``를 ``kt in`` 위치에 사용하는 메소드는 사용할 수 없다.
이런 식으로 제너릭 클래스 생성 시점에도 변성``kt in/out``을 지정할 수 있다.
>>> val list: MutableList<out Number> = ...
>>> list.add(1)
Error: Out-projected type ....
이런식으로 사용할 수 있는 위치를 제한하는 이유는, type safety를 보장하기 위해서다.
예를 들어, ``kt Number`` 타입의 하위 타입이면서 서로 같은 두 인자를 받고 싶을 때 이런 식으로 코딩하게 되면 예상과 다르게 동작한다.
```java
public static void copyData(List<? extends Number> src, List<? extends Number> dst)
```
이런 코드는 `` src``의 타입과 `` dst``의 타입이 같으리라고 보장할 수 없기 때문에 type safe하지 않다.
코틀린으로 위와 같은 코드를 짜려고 하면, ``java <? extends T>``와 대응되는 것은 ``kt <out T>``이므로 `` dst``에 이를 지정하는 순간 에러가 발생해 다른 식으로 접근해야 한다는 것을 알려준다.
(이 경우 `` src``만 ``kt out``으로 지정하는게 올바른 방법이다.)
또한 자바에서는 ``java ?``를 사용해도 되고 `` T``를 사용해도 되는 상황에서 어떤 것을 선택할지를 유저에게 맡겼는데, 코틀린은 이를 조금 더 빡빡하게 제한해 일관성있는 코드를 작성할 수 있다.
* 보통 자바에서 ``java ?``와 `` T``를 모두 사용할 수 있을 때 인자들과 리턴타입이 서로 관계를 맺고 있으면 `` T``, 아니면 ``java ?``를 사용해야 조금 더 의도가 명확해지는 코드가 된다.
스타 프로젝션 : *
1. *와 Any?는 다르다.
왜냐면, ``kt MutableList<T>``는 `` T``에 대해 무공변이기 때문.
``kt MutableList<Any?>``는 모든 타입의 원소를 담을 수 있음을 의미하는 반면,
``kt MutableList<*>``은 어떤 타입이라도 들어올 수 있으나, 구체적인 타입이 결정되는 과정이 진행되고, 일단 타입이 결정되면 그 타입(과 하위 타입)의 원소만 담을 수 있다. 즉, 구체적인 타입이 결정된다는 점이 중요하다.
2. *와 자바의 ?는 다르다.
MutableList<*> == MutableList<?>
자바로 표현하면 위와 같겠으나, bounded wildcard는 사실 ``kt in/out``에 대응되기 때문에, unbounded만 표현한다고 볼 수 있다.
게다가, 코틀린 컴파일러는 ``kt <*>``를 맥락에 따라서 해석한다.
그러니까, 다음과 같이 정의되어 있는 인터페이스에 대해서
interface Function(in T, out U> {
. . .
}
이 인터페이스를 구현한 것들을 인자로 받고 싶다면 다음과 같이 적을텐데,
fun foo(bar: Function<*, *>) {
// == bar: Function<in Nothing, out Any?>
}
``kt in``으로 정의되어 있는 인자를 ``kt *``로 받으면 ``kt in Nothing``인 것으로 간주한다.
``kt out``으로 정의되어 있는 인자를 ``kt *``로 받으면 ``kt out Any?``인 것으로 간주한다.
그래서 ``kt *``를 사용하더라도 함수 내부에서 `` T, U``의 위치에 따라 메소드 호출이 제한될 수 있다.
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?에 있는 메소드를 호출하거나 하는건 잘 된다.
}
'Java Stack > Kotlin' 카테고리의 다른 글
[Kotlin] java의 static final 변수에 대응되는 것은? (0) | 2021.02.11 |
---|---|
[Kotlin] Serialization: Gson, Jackson (0) | 2020.03.19 |
[Kotlin] 제네릭 : 타입 파라미터 소거(erasure), inline 실체화(reified) (1) | 2017.12.08 |
[Kotlin] 컬렉션과 배열 (0) | 2017.12.07 |
[Kotlin] 타입 시스템 (Any, Unit, Nothing) (0) | 2017.12.06 |