본문 바로가기
Kotlin

[Effective Kotlin] 24 - 제네릭 타입과 variance 한정자를 활용하라

by 매운돌 2023. 7. 8.

아래와 같은 제너릭 클래스가 있습니다.

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  한정자는 크게 두 위치에 사용할 수 있습니다.

  1. 선언 부분
    1. 일반적으로 이 위치에 사용합니다.
    2. 이 위치에서 사용하면 클래스와 인터페이스 선언에 한정자가 적용됩니다. 
      즉, 클래스와 인터페이스가 사용되는 모든 곳에 영향을 줍니다.
  2. 클래스와 인터페이스를 활용하는 위치
    1. 특정한 변수에만 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 한정자)를 사용합니다.