아래와 같은 제너릭 클래스가 있습니다.
class Cup<T>
위의 코드에서 타입 파라미터 T는 variance 한정자(out 또는 in)가 없으므로, 기본적으로 invariant(불공변성)입니다.
invariant라는 것은 제너릭 타입으로 만들어지는 타입들이 서로 관련성이 없다는 의미입니다.
예를 들어 Cup<Number>, Cup<Any>, Cup<Nothing>은 어떠한 관련성도 갖지 않습니다.
fun main() {
val anys: Cup<Any> = Cup<Int>() // 오류: Type mismatch
val nothings: Cup<Nothing> = Cup<Int>() // 오류
}
만약 어떤 관련성을 원한다면, out또는 in이라는 variance 한정자를 붙입니다.
out 한정자는 타입 파라미터를 covariant(공변성)로 만듭니다. 이는 A가 B의 서브타입일 때, Cup<A>가 Cup<B>의 서브타입이라는 의미입니다.
class Cup<out T>
open class Dog
class Puppy: Dog()
fun main() {
val b: Cup<Dog> = Cup<Puppy>() // OK
val a: Cup<Puppy> = Cup<Dog>() // 오류
val anys: Cup<Any> = Cup<Int>() // OK
val nothings: Cup<Nothing> = Cup<Int>() // 오류
}
in한정자는 반대 의미입니다. in 한정자는 타입 파라미터를 contravariant(반공변성)으로 만듭니다. 이는 A가 B의 서브타입일 때, Cup<A>가 Cup<B>의 슈퍼타입이라는 것을 의미합니다.
class Cup<in T>
open class Dog
class Puppy: Dog()
fun main() {
val b: Cup<Dog> = Cup<Puppy>() // 오류
val a: Cup<Puppy> = Cup<Dog>() // OK
val anys: Cup<Any> = Cup<Int>() // 오류
val nothings: Cup<Nothing> = Cup<Int>() // OK
}
함수 타입
함수 타입은 파라미터 유형과 리턴 타입에 따라서 서로 어떤 관계를 갖습니다.
예를 들어 Int를 받고, Any를 리턴하는 함수를 파라미터로 받는 함수를 생각해 보면,
fun printProcessedNumber(transition: (Int)->Any) {
print(transition(42))
}
(Int) → Any 타입의 함수는 (Int) → Number, (Number) → Any, (Number) → Number, (Number) → Int 등으로도 작동합니다.
이 그림에서 계층 구조의 아래로 가면, 타이핑 시스템 계층에서 파라미터 타입이 더 높은 타입으로 이동하고, 리턴 타입은 계층 구조의 더 낮은 타입으로 이동합니다.
(즉, 파라미터 타입은 Int → Number → Any로 이동하고, 리턴 타입은 Any → Number → Int로 이동합니다.)\
Kotlin 함수 타입의 모든 파라미터 타입은 contravariant이고, 모든 리턴 타입은 covariant입니다.
variance 한정자의 안전성
자바의 배열은 covariant입니다. 하지만 아래와 같은 문제점이 발생합니다.
Integer[] number = {1, 4, 2, 1};
Object[] objects = numbers;
objects[2] = "B" // 런타임 오류: ArrayStoreException
위 코드는 컴파일 중에 아무런 문제도 없지만, 런타임 오류가 발생합니다.
numbers를 Object[]로 캐스팅해도 구조 내부에서 사용되고 있는 실질적인 타입이 바뀌는 것은 아닙니다. 따라서 이러한 배열에 String타입의 값을 할당하면 오류가 발생합니다.
Kotlin은 이러한 결함을 해결하기 위해서 Array(IntArray, CharArray 등)를 invariant로 만들었습니다.
(따라서 Array<Int>를 Array<Any>등으로 바꿀 수 없습니다.)
파라미터 타입을 예측할 수 있다면, 어떤 서브타입이라도 전달할 수 있습니다. 따라서 아래 코드처럼 아규먼트를 전달할 때, 암묵적으로 업캐스팅할 수 있습니다.
open class Dog
class Puppy: Dog()
class Hound: Dog()
fun takeDog(dog: Dog) {}
takeDog(Dog())
takeDog(Puppy())
takeDog(Hound())
이는 covarient하지 않습니다. covarient 타입 파라미터(out 한정자)가 in 한정자 위치(예를 들어 타입 파라미터)에 있다면, covariant와 업캐스팅을 연결해서, 우리가 원하는 타입을 아무것이나 전달할 수 있습니다. (위의 자바의 예와 같은 경우입니다.) 즉, value가 매우 구체적인 타입이라 안전하지 않습니다.
class Box<out T> {
private var value: T? = null // 오류
// Kotlin에서 사용할 수 없는 코드입니다.
fun set(value: T) {
this.value = value
}
fun get(): T = value ?: error("Value not set")
}
val puppyBox = Box<Puppy>()
val dogBox: Box<Dog> = puppyBox
dogBox.set(Hound()) // 하지만 puppy를 위한 공간입니다.
val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some String") // 하지만 Dog를 위한 공간입니다.
box.set(42) // 하지만 Dog를 위한 공간입니다.
그래서 Kotlin은 public in 한정자 위치(함수 파라미터)에 covariant 타입 파라미터(out 한정자)가 오는 것을 금지하여 이러한 상황을 막습니다.
class Box<out T> {
var value: T? = null // 오류
fun set(value: T) { // 오류
this.value = value
}
fun get(): T = value ?: error("Value not set")
}
class Box<out T> {
private var value: T? = null
private fun set(value: T) {
this.value = value
}
fun get(): T = value ?: error("Value not set")
}
가시성을 private으로 제한하면, 오류가 발생하지 않습니다. 객체 내부에서는 업캐스트 객체에 covariant(out 한정자)를 사용할 수 없기 때문입니다.
covariant(out 한정자)는 public out 한정자 위치에서도 안전하므로 따로 제한되지 않습니다. 이러한 안정성의 이유로 생성되거나 노출되는 타입에만 covariant(out 한정자)를 사용하는 것입니다.
fun append(list: MutableList<Any>) {
list.add(42)
}
val strs = mutableListOf<String>("A", "B", "C")
append(strs) // Kotlin에서 사용할 수 없는 코드
val str: String = strs[3]
print(str)
반대로, 아래와 같은 코드를 살펴봅시다.
open class Car
interface Boat
class Amphibious: Car(), Boat
class Box<in T>(
// Kotlin에서 사용할 수 없는 코드입니다.
val value: T
)
val garage: Box<Car> = Box(Car())
val amphibiousSpot: Box<Amphibious> = garage
val boat: Boat = garage.value // 하지만 Car을 위한 공간
val noSpot: Box<Nothing> = Box<Car>(Car())
val boat: Nothing = noSpot.value
이러한 상황을 막기 위해, Kotlin은 contravariant타입 파라미터(in 한정자)를 public out 한정자 위치에 사용하는 것을 금지하고 있습니다.
class Box<in T> {
var value: T? = null // 오류
fun set(value: T) {
this.value = value
}
fun get(): T = value ?: error("Value not set") // 오류
}
class Box<in T> {
private var value: T? = null
fun set(value: T) {
this.value = value
}
private fun get(): T = value ?: error("Value not set")
}
variance 한정자의 위치
variance 한정자는 크게 두 위치에 사용할 수 있습니다.
- 선언 부분
- 일반적으로 이 위치에 사용합니다.
- 이 위치에서 사용하면 클래스와 인터페이스 선언에 한정자가 적용됩니다.
즉, 클래스와 인터페이스가 사용되는 모든 곳에 영향을 줍니다.
- 클래스와 인터페이스를 활용하는 위치
- 특정한 변수에만 variance 한정자가 적용됩니다.
// 선언 쪽의 variance 한정자
class Box<out T>(val value: T)
val boxStr: Box<String> = Box("Str")
val boxAny: Box<Any> = boxStr
===========================================
class Box<out T>(val value: T)
val boxStr: Box<String> = Box("Str")
// 사용하는 쪽의 variance 한정자
val boxAny: Box<out Any> = boxStr
정리
kotlin은 타입 아규먼트의 관계에 제약을 걸 수 있는 굉장히 강력한 제너릭 기능을 제공합니다. 이러한 기능으로 제네릭 객체를 연산할 때 굉장히 다양한 지원을 받을 수 있습니다.
- 타입 파라미터의 기본적인 variance 동작은 invariance 입니다.
- out 한정자는 타입 파라미터를 covariant하게 만듭니다.
- A가 B의 서브타입이라고 할 때, Cup<B>는 Cup<A>의 서브 타입이됩니다.
- in 한정자는 타입 파라미터를 contravariant하게 만듭니다.
- A가 B의 서브타입이라고 할 때, Cup<B>는 Cup<A>의 슈퍼 타입이됩니다.
Kotlin에서는
- List와 Set의 타입 파라미터는 covariant(out 한정자)입니다.
- 함수 타입의 파라미터 타입은 contravariant(in 한정자)입니다. 그리고 리턴 타입은 covariant(out 한정자)입니다.
- 리턴만 되는 타입에는 covariant(out 한정자)를 사용합니다.
- 허용만 되는 타입에는 contravariant(in 한정자)를 사용합니다.
'Kotlin' 카테고리의 다른 글
[Effective Kotlin] 26 - 함수 내부의 추상화 레벨을 통일하라 (0) | 2023.07.22 |
---|---|
[Effective Kotlin] 25 - 공통 모듈을 추출해서 여러 플랫폼에서 재사용하라 (0) | 2023.07.15 |
[Effective Kotlin] 23 - 타입 파라미터의 섀도잉을 피하라 (0) | 2023.07.02 |
[Effective Kotlin] 22 - 일반적인 알고리즘을 구현할 때 제너릭을 사용하라 (0) | 2023.06.24 |
[Effective Kotlin] 21 - 일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라 (1) | 2023.06.17 |