Kotlin

[Kotlin] variance(변성), in, out, covariant, contravariant, invariant

sh1mj1 2024. 2. 6. 19:31

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

variance(변성) 개념은 `List<String>` 와 `List<Any>` 와 같이 Base 타입이 같고, 타입 인자가 다른 타입들이 서로 어떤 관계가 있는지 설명하는 개념이다.

변성: 인자를 함수에 넘기기

`List<Any>` 타입의 파라미터를 받는 함수에 `List<String>` 을 넘기면 안전할까?

fun printContents(list: List<Any>) {
    println(list.joinToString())
}
printContents(listOf("abc", "bac")) /* abc, bac */

 이 경우에는 `List<String>` 을 사용해도 잘 작동한다.

위 함수는 각 원소를 `Any` 로 취급하며 모든 `String` 은 `Any` 타입이기도 하므로 완전히 안전하다.

 

리스트를 변경하는 다른 함수

fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}

val strings = mutableListOf("abc", "bac")
addAnswer(strings) // 이 줄이 컴파일된다면 아래 라인, // [COMPILE ERROR] Type mismatch: inferred type is MutableList<String> but MutableList<Any> was expected
println(strings.maxBy { it.length }) // 런타임에 여기서 예외가 발생할 것임

 `MutableList<Any>` 가 필요한 곳에 `MutableList<String>` 을 넘기면 안된다.

  • 어떤 함수가 리스트의 원소를 추가/변경한다면 타입 불일치가 생길 수 있어 `List<Any>` 대신, `List<String>` 을 넘길 수 없다.
  • 원소 추가/변경이 없는 경우에는 `List<String>` 을 `List<Any>` 대신 넘겨도 안전하다.

함수가 read-only 리스트를 받는다면, 더 구체적인 타입의 원소를 갖는 리스트를 함수에 넘길 수 있다.

만약 리스트가 mutable 하다면, 그럴 수 없다.

 

먼저 variance(변성)의 역할을 간단히 설명하자면,

코드에서 위험할 여지가 있는 메서드를 호출할 수 없게 만들어서 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없게 방지하는 역할을 한다.

클래스 vs 타입

엄밀히, 타입(type) 과 클래스(class) 는 다르다. 

  • 제네릭 클래스가 아닌 클래스에서는 클래스 이름을 바로 타입으로 쓸 수 있다.
    • `var x: String` 이라고 쓰면 `String` 클래스의 인스턴스를 저장하는 변수를 정의 가능.
  • 제네릭 클래스에서는 올바른 타입을 얻으려면 제네릭의 타입 파라미터를 구체적인 타입 인자로 바꾸어야 한다.
    • `List` 는 타입이 아니지만, 클래스이다.
    • `List<Int>`, `List<String>` 등은 모두 제대로 된 타입이다.
    • 제네릭 클래스는 무수히 많은 타입을 만들어 낼 수 있다.

subtype(하위 타입)

어떤 타입 `A` 의 값이 필요한 모든 장소에 어떤 타입 `B` 를 넣어도 아무 문제가 없다면, `B` 는 `A` 의 subtype(하위 타입)이다.

그렇다면 A 는 B 의 supertype(상위 타입)이다.

A 가 필요한 모든 곳에 B 를 사용할 수 있다면 B 는 A 의 하위 타입이다.

  • 컴파일러는 변수 대입이나 함수 인자 전달 시 매번 하위 타입 검사를 한다.

어떤 타입이 다른 타입의 하위 타입인지 검사

fun test(i: Int){
    val n: Number = i // Int 가 Number 의 하위 타입이어서 컴파일된다.
    fun f(s: String) { /* ... */}
    f(i) // Int 가 String 의 하위 타입이 아니어서 컴파일 안됨. [COMPILE ERROR] Type mismatch: inferred type is Int but String was expected
}

 간단한 경우 하위 타입은 subclass(하위 클래스)와 같다.

  • nullable 타입에 대해서는 하위 타입과 하위 클래스가 같지 않다.

not-null 타입 A 는 nullable 타입 A? 의 하위 타입이지만, A? 는 A 의 하위 타입이 아니다

  • 제네릭 타입에 대해서는 하위 클래스와 하위 타입의 차이가 중요해진다. 
    • (위에서 살펴보았듯) `MutableList<String>` 은 `MutableList<Any>` 의 하위 타입이 아니다.
    • 또한 `MutableList<Any>` 도 `MutableList<String>` 의 하위 타입이 아니다.

제네릭 타입을 인스턴스화 할 때 타입 인자로 서로 다른 타입이 들어가고, 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 invariant(무공변)이라고 말한다.

자바에서는 모든 클래스가 invariant 이다.

covariance(공변성): 하위 타입 관계를 유지: out

코틀린의 `List` 인터페이스는 read-only 컬렉션이다.

`A` 가 `B` 의 하위 타입이면, `List<A>` 도 `List<B>` 의 하위 타입이다. 이를 covariant(공변적)이라고 한다.

코틀린에서 제네릭 클래스가 타입 파라미터에 대해 covariant(공변)임을 표시하려면 타입 파라미터 이름 앞에 `out` 을 넣어야 한다.

interface Producer<out T> { // 클래스가 T 에 대해 covariant 하다고 선언
    fun produce(): T
}

 클래스의 타입 파라미터를 covariant 하게 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도, 그 클래스의 인스턴스를 함수 인자나 리턴값으로 사용할 수 있다.

 

invariant 컬렉션 역할을 하는 클래스 정의

open class Animal {
    fun feed() { ... }
}

class Herd<T : Animal> { // 이 타입 파라미터를 invariant 로 지정
    val size: Int get() = 10
    operator fun get(i: Int): T { ... }
}

fun feedAll(animals: Herd<Animal>) {
    for (i in 0..<animals.size) {
        animals[i].feed()
    }
}

 invariant 컬렉션 역할을 하는 클래스 사용

class Cat : Animal() {
    fun cleanLitter() { ... }
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0..<cats.size) {
        cats[i].cleanLitter()
        feedAll(cats) // [COMPILE ERROR] Type mismatch: inferred type is Herd<Cat> but Herd<Animal> was expected
    }
}

 `feedAll` 함수에게 고양이 무리(`Herd<Cat>`) 를 넘기면, type mismatch 오류가 발생한다.

`Herd` 클래스의 T 타입 파라미터에 대해서 어떠한 variance 도 지정하지 않아서 `Herd<Cat>` 은 `Herd<Animal>` 의 하위 클래스가 아니다.

명시적으로 타입 캐스팅을 해도 되지만, 타입 불일치를 해결하기 위해 강제 캐스팅을 하는 것은 옳지 않다.

 

covariant 컬렉션 역할을 하는 클래스 사용

class Herd<out T : Animal> { // T 는 이제 covariant 공변
    ...
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0..<cats.size) {
        cats[i].cleanLitter()
        feedAll(cats) // 정상 작동
    }
}

 타입 파라미터를 covariant 하게 만들면, 해당 타입 파라미터의 사용을 특정한 방식으로 제한하게 된다.

타입 안정성을 보장하기 위해서 이는 'out(출력) 위치'에서만 사용할 수 있다.

즉, 클래스가 타입 T 의 값을 생성할 수 있지만, 소비할 수는 없음을 의미한다.

 

클래스 멤버를 생성할 때 타입 파라미터를 사용할 수 있는 지점은 모두 `in` 과 `out` 위치로 나뉜다.

  • `T` 라는 타입 파라미터를 선언하고, `T` 를 사용하는 함수가 멤버로 있는 클래스
    • `T` 가 함수의 리턴 타입에 쓰인다면, `T` 는 `out`(출력) 위치에 있다.
      1. 이 함수는 `T` 타입의 값을 생산(produce)한다.
    • `T` 가 함수의 파라미터 타입에 쓰인다면, `T` 는 `in`(입력) 위치에 있다.
      1. 이 함수는 `T` 타입의 값을 소비(consume) 한다.

함수 타입 파라미터 타입은 in 위치, 함수 리턴 타입은 out 위치에 있다.

클래스 타입 파라미터 `T` 앞에 `out` 키워드를 붙이면, 클래스 안에서 `T` 를 사용하는 메서드가 `out` 위치에서만 `T` 를 사용하게 허용하고, `in` 위치에서는 `T` 를 사용하지 못하게 막는다.

위의 `Herd` 클래스에서도 타입 파라미터 `T` 를 사용하는 장소는 오직 `get` 메서드의 리턴 타입 뿐이었다.

class Herd<out T : Animal> {
    val size: Int get() = 10
    operator fun get(i: Int): T { ... } // T 를 리턴 타입으로 사용: out 위치에서 사용
}
covariance (공변성) 하위 타입 관계가 유지된다.
(`Producer<Cat>` 은 `Producer<Animal>` 의 하위 타입이다.)
사용 제한 `T` 를 `out` 위치에서만 사용할 수 있다.
(`T` 를 리턴 타입으로 사용)

`List<T>` 인터페이스의 코드 보기

public interface List<out E> : Collection<E> {
    / * ... */
    public operator fun get(index: Int): E // read-only 메서드로 T 를 리턴하는 메서드만 정의. T 는 항상 out 위치에 쓰인다.
    
    override fun iterator(): Iterator<E> // 여기서도 T 는 out 위치에 있다.
    / * ... */
}

 위 `iterator` 처럼 타입 파라미터를 다른 타입의 타입 인자로 사용할 수도 있다.

 

`MutableList<T>` 를 타입 파라미터 `T` 에 대해 covariant 클래스로 선언할 수는 없다.

public interface MutableList<E> : List<E>, MutableCollection<E> { // MutableList 는 E 에 대해 covarint 일 수 없다.
    override fun add(element: E): Boolean //  E 가 in 위치에 쓰이기 때문이다.
    /* ... */
    public operator fun set(index: Int, element: E): E
}

 `MutableList<E>` 에는 `E` 를 인자로 받아서 그 타입의 값을 리턴하는 메서드가 있다.

따라서 `E` 가 `in` 과 `out` 위치에 동시에 쓰인다.

`MutableList` 는 invariant 하다

생성자 파라미터?

생성자 파라미터는 `in` 이나 `out` 어느 쪽도 아니다.
그래서 타입 파라미터가 `out` 이어도 그 타입을 생성자 파라미터 선언에 사용할 수 있다.

class Herd<out T: Animal>(vararg animals: T) { ... }​

 variance(변성)은 코드에서 위험할 여지가 있는 메서드를 호출할 수 없게 만들어서 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없게 방지하는 역할을 한다고 했다.
생성자는 인스턴스를 생성한 뒤 나중에 호출할 수 있는 메서드가 아니다.

 

하지만 `val` 이나 `var` 키워드를 생성자 파라미터에 적는다면, getter/setter 를 정의하는 것과 같다.

따라서 read-only 프로퍼티는 `out` 위치, mutable 프로퍼티는 `out`,`in` 위치 모두에 해당한다.

class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) { ... }

 여기서는 `T` 타입인 `leadAnimal` 프로퍼티가 `in` 위치에 있기 때문에 `T` 를 `out` 으로 표시할 수 없다.

 

위에서 본 규칙들은 오직 외부에서 볼수 있는 클래스 API 에만 적용할 수 있다.

private 메서드는 외부에서 아예 접근이 불가해 잘못 사용할 일이 없다.

contravariance(반공변성): 뒤집힌 하위 타입 관계

covaraint(반공변) 클래스의 하위 타입 관계는 covariant(공변) 클래스의 경우와 반대이다.

`Comparator` 인터페이스의 `compare` 메서드

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int { ... } // T 를 in 위치에서 사용
}

 이 인터페이스의 메서드는 `T` 타입의 값을 소비(consume)하기만 한다.

이는 `T` 가 `in` 위치에서만 쓰인다는 뜻이다. 

 

어떤 타입에 `Comparator` 를 구현하여 그 하위 타입에 속하는 모든 값 비교

val anyComparator = Comparator<Any> { e1, e2 ->
    e1.hashCode() - e2.hashCode()
}

val strings: List<String> = listOf("cde", "abc")
strings.sortedWith(anyComparator) // 구체 타입의 객체를 비교하기 위해 모든 객체를 비교하는 Comparator 를 사용할 수 있다.
print(strings)

 `sortedWith` 함수는 `Comparator<String>` 을 요구한다. 

어떤 타입의 객체를 `Comparator` 로 비교할 때, 그 타입이나 그 타입의 조상 타입을 비교할 수 있는 `Comparator` 를 사용할 수 있다.

`Comparator<Any>` 가 `Comparator<String>` 의 하위 타입이라는 뜻이다.

즉, 타입 인자에 대해 `Comparator` 의 하위 타입 관계는 타입 인자의 하위 타입 관계와는 정반대 방향이다.

 

타입 `B` 가 타입 `A` 의 하위 타입일 때 `Consumer<A>` 가 `Consumer<B>` 의 하위 타입인 관계가 성립하면 제네릭 클래스 `Consumer<T>` 는 타입 인자 `T` 에 대해 contravariance 이다.

예를 들어 `Consumer<Animal>` 은  `Consumer<Cat>` 의 하위 타입이다.

 covariance 타입 `Producer<T>` 에서는 타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지되지만, contravariance 타입 `Consumer<T>` 에서는 타입 인자의 하위 타입 관계가 제네릭 타입으로 오면서 뒤집힌다.

`in` 이라는 키워드가 붙은 타입이 이 클래스의 메서드 안으로 전달되어(passed in) 메서드에 의해 소비된다는 뜻이다.

covariance (공변성) contravariance (반공변성) invariance (무공변성)
`Producer<out T>` `Consumer<in T>` `MutableList<T>`
타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지된다. 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힌다. 하위 타입 관계가 성립하지 않는다.
`Producer<Cat>` 은 `Producer<Animal>` 의 하위 타입이다. `Consumer<Animal>` 은 `Consumer<Cat>` 의 하위 타입이다.  
`T` 를 `out` 위치에서만 사용할 수 있다. `T` 를 `in` 위치에서만 사용할 수 있다. `T` 를 아무 위치에서나 사용할 수 있다.

함수 타입

함수 타입은 파라미터의 타입과 리턴 타입에 따라서 서로 관계를 갖는다.

(`Int` 를 받고, `Any` 를 리턴하는 함수)를 파라미터로 받는 `printProcessNumber` 함수

fun printProcessedNumber(transition: (Int) -> Any) {
    println(transition(42))
}

private val intToDouble: (Int) -> Number = { it.toDouble() }
private val numberAsText: (Number) -> Any = { it.toShort() }
private val identity: (Number) -> Number = { it }
private val numberToInt: (Number) -> Int = { it.toInt() }
private val numberHash: (Any) -> Number = { it.hashCode() }

printProcessedNumber(intToDouble) // 42.0
printProcessedNumber(numberAsText) // 42
printProcessedNumber(identity) // 42
printProcessedNumber(numberToInt) // 42
printProcessedNumber(numberHash) // 42

 `(Int) -> Any` 타입의 함수는 `(Int) -> Number`, `(Number) -> Any`, `(Number) -> Number`, `(Number)-> Int`  등으로도 작동한다.

이러한 타입들에 대해서는 아래와 같은 관계가 있다.

계층 구조의 아래로 가면, 타입 시스템 계층에서 타입 파라미터 타입이 더 높은 타입으로, 리턴 타입은 더 낮은 타입으로 이동한다.

그림의 왼쪽 파라미터 타입은 `Int` -▷ `Number` -▷ `Any`, 오른쪽 리턴 타입은 `Any` -▷ `Number` -▷ `Int` 로 점점 슈퍼타입이 된다.

  • 코틀린 함수 타입의 모든 파라미터 타입은 contravariant 이다.
  • 코틀린 함수 타입의 모든 리턴 타입은 covariant 이다.

위처럼 클래스/인터페이스가 어떤 타입 파라미터에 대해서는 covariant, 다른 타입 파라미터에 대해서는 contravariant 일 수 있다.

인터페이스: `Function1`

interface Function1<in P, out R> {
    operator fun invoke(p: P): R
}

 코틀린 표기에서 `(P) -> R` 은 `Function1<P, R>` 을 쉽게 적은 것이다.

`P` 는 함수 파라미터 타입이며 오직 `in` 위치에, `R` 은 함수 리턴 타입으로 오직 `out` 위치에 사용된다.

 

`Animal` 을 인자로 받아서 정수를 리턴하는 람다를 `Cat` 에게 번호를 붙이는 고차 함수에 넘기기

fun enumerateCats(f: (Cat) -> Number) { ... }

fun Animal.getIndex(): Int = ...

enumerateCats(Animal::getIndex)

함수 타입 (T) -> R 은 인자 타입에 대해서는 contravariant 하고, 리턴 타입에 대해서는 covariant 하다.

variance 한정자의 안정성:

자바의 배열은 covariant 이다. 

이 때문에 배열을 정렬할 때 문제가 생긴다.

Integer[] numbers = {1, 4, 2, 1};
Object[] objects = numbers;
objects[2] = "B"; // [RUNTIME ERROR] ArrayStoreException

 `numbers` 를 `Object[]` 로 캐스팅해도 구조 내부에서 사용되고 있는 실질적인 타입이 바뀌는 것은 아니다. (여전히 `Integer`)

그래서 이 배열에 `String` 타입의 값을 할당하면 오류가 발생한다.

 

코틀린은 이를 해결하기 위해 `Array`(`IntArray`, `CharArray` 등)을 invariant 로 만들었다.

따라서 `Array<Int>` 를 `Array<Any>` 등으로 바꿀 수 없도록 컴파일 타임에서부터 막는다.

만약 covariant 타입 파라미터가 in 위치에서 사용된다면?

covariant 타입 파라미터가 in 위치에 사용되는 메서드를 선언하면 컴파일 에러가 발생한여 아예 사용할 수 없다.

 

설명을 위한 클래스 `Dog`,`Puppy`,`Hound`

open class Dog
class Puppy : Dog()
class Hound : Dog()

fun takeDog(dog: Dog) {
    // body code
}

 covariant 타입 파라미터를 감싸는 `Box`

class Box<out T> {
    private var value: T? = null

    // 실제로는 이렇게 사용 불가.
    fun set(value: T) {
        this.value = value
    }
    fun get(): T = value ?: error("Value not set")
}

 `T` 가 covariant 이므로 실제로는 `set` 메서드의 파라미터로 들어갈 수 없다.

만약 이것이 된다고 가정해보자.

val puppyNotSafeBox = Box<Puppy>()
val dogBox: Box<Dog> = puppyNotSafeBox
dogBox.set(Hound()) // dogBox 는 실제로는 Puppy 를 위한 공간이다.

val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some string") // box 는 실제로는 Dog 을 위한 공간이다.
box.set(42) // box 는 실제로는 Dog 를 위한 공간이다.

 `Dog` 는 `Puppy`, `Hound` 의 상위 타입이고. `Any` 는 이 모든 것들의 상위 타입이다.

그래서 `dogBox` 나 `box` 처럼  업캐스팅하여 객체를 선언할 수 있다.

그런데 `dogBox` 구조 내부에서 사용되고 있는 실질적인 타입은 `Puppy` 이고, `box` 도 마찬가지로  `Any` 이다.

그런데 `set` 메서드를 통해 각각 `Hound` 타입 객체와 `String`, `Int` 타입 객체를 넣어주고 있다.

 

이는 전혀 안전하지 않다.

타입 캐스팅 후에 실질적인 객체가 그대로 유지되고 정적 타입에서만 다르게 처리되기 때문이다.

(여전히 동일한 객체를 가리키지만, 컴파일러나 타입 시스템은 이 객체를 다른 타입으로 다룰 수 있다. 이는 컴파일러나 타입 시스템에서만 객체의 타입이 변경되었다고 해석되며, 실제로는 해당 객체의 실제 형태나 구조는 변경되지 않았다는 의미이다.)

그래서 코틀린은 public in 위치에 covariant 타입 파라미터가 오는 것을 금지하여 이러한 상황을 막는다.

 

하지만 위 생성자 파라미터에서 설명했듯이 가시성을 private 으로 제한하면 오류가 발생하지 않는다.

객체 내부에서는 업캐스트 객체에 covariant(out 한정자)를 사용할 수 없기 때문이다.

covariant 의 예 Response

variance 한정자 덕분에 아래 내용이 모두 true 가 되어 다양한 이득을 얻을 수 있다.

  • `Response<T>` 라면 `T` 의 모든 서브 타입이 허용된다.
    • `Response<Any>` 가 예상된다면 `Response<Int>`,`Response<String>` 이 허용된다.
  • `Response<T1, T2>` 라면 `T1` 과 `T2` 의 모든 서브 타입이 허용된다.
  • `Failure<T>` 라면 `T` 의 모든 서브 타입 `Failure` 가 허용된다.
    • `Failure<Number>` 라면, `Failure<Int>` 와 `Failure<Double>` 이 모두 허용된다.
    • `Failure<Any>` 라면, `Failure<Int>` 와 `Failure<Double>` 이 모두 허용된다.

아래 코드에서 covaraint 와 `Nothing` 타입으로 인해서 `Failure` 는 오류 타입을 지정하지 않아도 되고, `Success` 는 잠재적인 값을 지정하지 않아도 된다.

sealed class Response<out R, out E>
class Failure<out E>(val error: E) : Response<Nothing, E>()
class Success<out R>(val value: R) : Response<R, Nothing>()

만약 contravariant 타입 파라미터가 out 위치에서 사용된다면?

위에서 covariant 타입 파라미터가 in 위치에서 사용되는 것과 같은 문제가 발생한다.

설명을 위한 클래스 `Dog`, `Puppy`, `Hound`

open class Car
interface Boat
class Amphibious: Car(), Boat

fun getAmphibious(): Amphibious = Amphibious()

val car: Car = getAmphibious()
val boat: Boat = getAmphibious()

 contravariant 타입 파라미터를 감싸는 `Box

class Box<in T>(
    // 코틀린에서는 이렇게 사용 불가.
    val value: T
)

 `T` 가 contravariant 이므로 실제로는 out 위치에 사용될 수 없다.

이번에도 위 코드가 가능하다고 가정해보자.

val garage: Box<Car> = Box(Car())
val amphibiousSpot: Box<Amphibious> = garage
val boat: Boat = garage.value // 하지만 garage.value 는 실제로는 Car 를 위한 공간임

val noSpot: Box<Nothing> = Box<Car>(Car())
val boat: Nothing = noSpot.value // boat 에서 아무런 값도 사용할 수 없다.

 `Amphibious` 는 `Car` 와 `Boat` 의 하위 타입이며, `Nothing` 은 모든 것의 하위 타입이다.

그래서 `amphibiousSpot` 이라는 객체에 `Box<Car>` 객체를 `Box<Amphibious>` 타입으로 캐스팅하여 저장했다.

그리고 `boat` 에 `garage` 의 `value` 를 가져왔으나, 실제로 `garage` 는 구조 내부에서 사용되고 있는 실질적인 타입은 `Car` 이다.

 

여기서 `noSpot`은 `Box`의 타입 파라미터가 `Nothing`으로 지정되어 있다.

`Nothing` 은 모든 다른 타입의 하위 타입이므로 `Box<Car>`도 `Box<Nothing>`의 하위 타입으로 간주된다.

하지만 `noSpot`에 실제로는 `Car` 객체가 들어있다.

그리고 `noSpot.value`를 사용하여 `noSpot` 에 저장된 값을 가져오려고 한다.

그러나 `noSpot.value` 는 `Nothing` 타입의 값이므로, 실제로는 어떠한 값도 가질 수 없다.

 

이러한 상황을 막기 위해 코틀린은 contravariant 타입 파라미터(in 한정자)를 out 위치에 사용하는 것을 금지한다.

물론, 이번에도 원소가 private 일 대는 아무 문제가 없다.

contravariant 의 예: Continutation

@SinceKotlin("1.3")
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

use-site variance(사용 지점 변성): 타입이 언급되는 지점에서 변성 지정

위에서는 클래스 정의에 variance 를 직접 기술하면 그 클래스를 사용하는 모든 장소에 그 변성이 적용됨을 보았다.

이를 선언 지점 변성(declaration site variance)이라고 한다.

 

자바에서는 이를 지원하지 않는 대신 클래스를 사용하는 위치에서 와일드카드를 사용하여 그때 그때 variance 를 지정해야 한다.

사용할 때마다 해당 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지 명시해야 한다. 

이런 방식은 사용 지점 변성(use-site variance) 라고 한다.

자바에서의 `Stream.map` 메서드는 아래처럼 정의되어 있다.

public interface Stream {
    <R> Stream <R> map(Function<? super T, ? extends R> mapper);
}

 자바에서는 `Function` 인터페이스를 사용하는 위치에서 와일드카드를 사용하고 있다.

 

코틀린 use-site variance 를 지원한다.

코틀린 `MutableList` 같은 많은 인터페이스는 타입 파라미터로 지정된 타입을 소비하는 동시에 생산할 수 있다.

그래서 covariant 하지도, contravariant 하지 않다.(invariant 하다)

하지만 그런 인터페이스 타입 변수가 한 함수 안에서 producer/consumer 중 한 가지 역할만을 담당하는 경우가 자주 있다.

 

invariant 파라미터 타입을 사용하는 `copyData` 함수 

fun <T> copyData(source: MutableList<T>, destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}

 `source` 컬렉션에서 `destination` 컬렉션으로 데이터를 복사한다.

`source` 에서는 읽기만, `destination` 에는 쓰기만 한다. 

이 경우 두 컬렉션의 원소 타입이 정확히 일치할 필요가 없다. 

예를 들어 원소가 문자열인 컬렉션에서 원소가 객체인 컬렉션으로 데이터를 복사해도 된다.

 

타입 파라미터가 둘인 `copyData` 함수

fun <T : R, R> copyData1(source: MutableList<T>, destination: MutableList<R>) { // source 원소 타입은 destination 원소 타입의 하위 타입
    for (item in source) {
        destination.add(item)
    }
}

val ints = mutableListOf(1, 2, 3)
val anyItems = mutableListOf<Any>()
copyData1(ints, anyItems) // Int 가 Any 의 하위 타입이므로 이 함수를 호출 가능
println(anyItems)
/* print
[1, 2, 3]
 */

 이보다 더 코틀린답게 표현할 수 있다.

 

out-projected 타입 파라미터를 사용하는 `copyData` 함수

// out 키워드를 붙이면 T 타입을 in 위치에 사용하는 메서드를 호출하지 않는다는 뜻
fun <T> copyData2(source: MutableList<out T>, destination: MutableList<T>) { 
    for (item in source) {
        destination.add(item)
    }
}

 타입 프로젝션(type projection)이 일어나, `source` 를 일반적인 `MutableList` 가 아닌 제약을 가한(projected) 타입으로 만든다.

이 경우 `copyData` 함수는 `MutableList` 의 메서드 중 리턴 타입으로 타입 파라미터 `T` 를 사용하는 메서드만 호출할 수 있다.

더 정확히는, 타입 파라미터 `T` 를 `out` 위치에서 사용하는 메서드만 호출할 수 있으며 컴파일러는 타입 파라미터 `T` 를 `in` 위치에 있는 타입으로 사용하지 못하게 막는다.

 

물론 `copyData` 와 같은 함수를 제대로 구현하는 방법은 `source` 를 `List<T>` 타입으로 지정하는 것이다.

`List` 는 위에서 보았듯, 이미 covariance 가 지정되어 있다.

`List` 처럼 이미 `out` 변경자가 지정된 경우에는 out projection 하는 것은 의미 없다.

 

위와 비슷하게 in projection 을 지정할 수 있다.

`in` 을 붙여 그 타입 파라미터가 consumer 역할을 수행한다고 표시할 수 있으며, 그 파라미터를 더 상위 타입으로 대치할 수 있다.

 

in-projection 타입 파라미터를 사용하는 `copyData` 함수

// source 원소 타입의 상위 타입을 destination 원소 타입으로 허용  
fun <T> copyData3(source: MutableList<T>, destination: MutableList<in T>) { 
    for (item in source) {
        destination.add(item)
    }
}
 코틀린의 use-site variance 는 자바의 bounded wildcard 와 같다.
코틀린 `MutableList<out T>` 는 자바 `MutableList<? extends T>` 와 같고 코틀린 `MutableList<in T>` 는 자바 `MutableList<? super T>` 와 같다.

이렇게 use-site variance 를 사용하면 타입 인자로 사용할 수 있는 타입의 범위가 넓어진다.

 

아래처럼 모든 인스턴스에 variance 한정자를 적용하면 안되고 특정 인스턴스에만 적용해야 할 때 이런 코드를 사용한다.

class Box<T>(val value: T)
val boxStr: Box<String> = Box("Str")

// use-site variance
val boxAny: Box<out Any> = boxStr

 

단일 파라미터 타입에 contravariant 를 가지게 하여 여러 타입을 받아들이게 하기

interface Dog
interface Cutie
data class Puppy(val name: String) : Dog, Cutie
data class Hound(val name: String) : Dog
data class Cat(val name: String) : Cutie

fun fillWithPuppies(list: MutableList<in Puppy>) {
    list.add(Puppy("Jim"))
    list.add(Puppy("Beam"))
}

val dogs = mutableListOf<Dog>(Hound("Pluto"))
fillWithPuppies(dogs)
println(dogs) /* print [Hound(name=Pluto), Puppy(name=Jim), Puppy(name=Beam)] */

val animals = mutableListOf<Cutie>(Cat("Felix"))
fillWithPuppies(animals)
println(animals) /* print [Cat(name=Felix), Puppy(name=Jim), Puppy(name=Beam)] */

star projection: 타입 인자 대신 * 사용

'이전 글 [Kotlin] 런타임 시 제네릭의 동작:...'  에서 타입 검사, 캐스트 설명할 때 제네릭 타입 인자 정보가 없음을 표현하기 위해 star projection(스타 프로젝션)을 사용한다고 했다.

원소 타입이 알려지지 않은 리스트는 `List<*>` 라는 구문으로 표현할 수 있다.

  • `MutableList<*>` 는 `MutableList<Any?>` 와 다르다. (`MutableList<T>` 는 invariant 하다) 
    • `MutableList<Any?>`모든 타입 원소를 담을 수 있는 리스트이다.
    • `MutableList<*>`구체적인 타입만을 원소로 담지만, 그 타입을 모르는 것이다.
val list: MutableList<Any?> = mutableListOf('a', 1, "qwe")
val chars = mutableListOf('a', 'b', 'c')
val unknownElements: MutableList<*> = if (Random().nextBoolean()) list else chars
unknownElements.add(42) // [COMPILE ERROR] The integer literal does not conform to the expected type Nothing
// 1.3.72 이전 에러 메시지: Out-projected type 'MutableList<*>' prohibits the use of 'public abstract fun add(element: E): Boolean defined in kotlin.collections.MutableList'
println(unknownElements.first()) /* a */
println(unknownElements[1]) // sometimes 1, sometimes b

 이 맥락에서 `MutableList<*>` 는 `MutableList<out Any?>` 처럼 동작한다. 

어떤 리스트의 원소 타입을 모르더라도 그 리스트에서 안전히 `Any?` 타입의 원소를 꺼내올 수는 있다.

하지만 구체적인 타입을 모르니, 원소를 마음대로 넣을 수는 없다.

코틀린의 `MyType<*>` 는 자바의 `MyType<?>` 와 대응한다.

`Consumer<in T>` 와 같은 contravariance 타입 파라미터에 대한 star projction `Comsumer<*>` 은 `Consumer<in Nothing>` 과 동일하다.
`T` 가 알려지지 않았을 때는 `Consumer<T>` 에 쓰기(write)가 불가능하다. (코틀린 공식 문서 참조)
  • 타입 파라미터를 시그니처에서 언급하지 않거나, 데이터를 읽기는 하지만 그 타입에 관심이 없는 경우처럼 타입 인자 정보가 중요하지 않을 때도 star projection 구문을 사용할 수 있다.
fun printFirst(list: List<*>) { // 모든 리스트를 인자로 받을 수 있다.
    if (list.isNotEmpty()) { // isNotEmpty() 에서는 제네릭 타입 파라미터를 사용 안 함
        println(list.first()) // first() 는 이제 Any? 를 리턴한다. 이 타입으로도 충분하다
    }
}
printFirst(listOf("Svetlana", "Dmitry")) /* Svetlana */

 use-site variance 처럼 star projection(`*`) 도 제네릭 타입 파라미터를 도입할 수 있다.

fun <T> printFirst(list: List<T>) { // 이 경우에도 모든 리스트를 인자로 받을 수 있다.
    if (list.isNotEmpty()) {
        println(list.first())  // first() 는 이제 T 타입의 값을 리턴한다.
    }
}

 star projection 을 쓰는 쪽이 더 간결하지만 제네릭 타입 파라미터가 어떤 타입인지 알 필요가 없을 때만 star projection 을 사용할 수 있다.

star projection 은 값을 만들어내는 메서드 (out 위치)에서만 호출할 수 있고 그 타입에는 신경 쓰지 말아야 한다.

  • 잘못된 star projection 사용의 예

입력 검증을 위한 인터페이스 `FieldValidator`

interface FieldValidator<in T> { // T 에 대해 contravariant 인터페이스 선언
    fun validate(input: T): Boolean // T 를 in 위치에만 사용(T 타입 값을 consume)
}

object DefaultStringValidator : FieldValidator<String> {
    override fun validate(input: String): Boolean = input.isNotEmpty()
}

object DefaultIntValidator : FieldValidator<Int> {
    override fun validate(input: Int): Boolean = input >= 0
}

 `FieldValidator` 인터페이스는 convarvariant 하여 `String` 타입의 필드를 검증하기 위해 `Any` 타입을 검증하는 `FieldValidator` 를 사용할 수 있다.

`KClass` 를 key 로 하고, `FieldValidator<*>` 를 value 로 하는 맵을 선언

val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()
validators[String::class] = DefaultStringValidator
validators[Int::class] = DefaultIntValidator

validators[String::class]!!.validate("") // 맵에 저장된 값의 타입은 FieldValidator<*> 
// [COMPILE ERROR] Type mismatch: inferred type is String but Nothing was expected
// 1.3.72 이전 버전 에러 메시지: Out-projected type 'FieldValidator<*>' prohibits the use of 'public abstract fun validate(input: T): Boolean defined in FieldValidator'

 `KClass` 는 코틀린 클래스를 표현하고, `FieldValidator<*>` 는 모든 타입의 validator 를 표현한다.

 

위처럼 정의하고 나면, 문제가 생긴다. 

FieldValidator<*> 는 실제로는 FieldValidator 의 단일한 타입이다.

따라서 validators 맵에 저장된 각각의 FieldValidator 객체는 컴파일러에게 FieldValidator<*> 타입으로 인식되어서 컴파일러는 이 객체들이 어떤 타입의 FieldValidator인지 알 수 없다.

실제로 DefaultStringValidator와 DefaultIntValidator 객체를 생성할 때 각각의 타입을 명시했지만, 이 정보는 컴파일러에게 전달되지 않는다.

`String` 타입의 필드를 `FieldValidator<*>` 타입의 validator 로 검증할 수 없다.

컴파일러는 `FieldValidator<*>` 가 어떤 타입의 validator  인지 모르기 때문에 `String` 을 검증하기 위해 그 validator 를 사용하면 안전하지 않다고 판단한다.

 

위 오류는 위에서 `MutableList<*>` 에 원소를 넣으려고 했을 때 이러한 오류가 떴었다.

이 오류는 알 수 없는 타입의 validator 에 구체적인 타입의 값을 넘기면 안전하지 못하다는 뜻이다.

validator 를 원하는 타입으로 명시 캐스팅하면 문제를 고칠 수 있지만, 권장하는 방법은 아니다.

 

명시적 캐스팅으로 일차원적 해결

val stringValidator = validators[String::class] as FieldValidator<String> // [WARN] Unchecked cast: FieldValidator<*>? to FieldValidator<String>
println(stringValidator.validate("")) /* false */

// validator 를 잘못(실수로) 가져온 경우
val stringValidator2 = validators[Int::class] as FieldValidator<String> // [WARN] Unchecked cast: FieldValidator<*>? to FieldValidator<String>
stringValidator2.validate("") // [RUNTIME ERROR] java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Number
// COMPILE ERROR 가 아닌 RUNTIME ERROR 발생

 컴파일러의 경고를 볼 수 있다.

또 이 코드를 실행하면, 타입 캐스팅 부분에서 실패하지 않고, 값을 검증하는 메서드 안에서 실패한다.

이런 해법은 타입 안정성을 보장할 수 없고 실수하기도 쉽다.

  • 위 잘못된 사용의 예 해결

validator 컬렉션에 대한 접근 캡슐화하기

object Validators {
    private val validators = mutableMapOf<KClass<*>, FieldValidator<*>>() // 앞과 같은 맵을 사용하지만, 외부에서 이 맵에 접근 불가
    fun <T : Any> registerValidator(kClass: KClass<T>, fieldValidator: FieldValidator<T>) {
        validators[kClass] = fieldValidator // 어떤 클래스와 validator 타입이 맞는 경우만 그 클래스와 validator 정보를 맵에 key/value 쌍으로 넣는다
    }

    @Suppress("UNCHECKED_CAST") // FieldValidator(T) 캐스팅이 안전하지 않다는 경고를 무시하게 함.
    operator fun <T : Any> get(kClass: KClass<T>): FieldValidator<T> =
        validators[kClass] as? FieldValidator<T>
            ?: throw IllegalArgumentException("No validator for ${kClass.simpleName}")
}

Validators.registerValidator(String::class, DefaultStringValidator)
Validators.registerValidator(Int::class, DefaultIntValidator)
println(Validators[String::class].validate("Kotlin")) /* true */
println(Validators[Int::class].validate(42)) /* true */
println(Validators[String::class].validate(42)) // [COMPILE ERROR] The integer literal does not conform to the expected type String

 이 패턴을 모든 커스텀 제네릭 클래스를 저장할 때 사용할 수 있게 확장할 수도 있다.

안전하지 못한 코드를 별도로 분리하면  코드를 잘못 사용하지 못하게 방지할  있고 안전하게 컨테이너를 사용하게 만들  있다.

방금 살펴본 패턴을 자바에도 적용할  있다.

 

 

참조

https://kotlinlang.org/docs/generics.html#star-projections