ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 9-2. 제네릭스 : 변성 (Variance)
    Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 11. 13. 15:12

    9장

    9-1. 제네릭스 : 타입 파라미터

    9-2. 제네릭스 : 변성 (Variance)

    9-3. 제네릭스 : 타입 별명


    9-2. 변성

     

    변성은 타입 파라미터가 달라질 때 제네릭 타입의 하위 타입 관계가 어떻게 달라지는지

    설명하는 제네릭 타입의 한 측면이다.

    배열과 가변 컬렉션은 타입 인자의 하위 타입 관계를 유지하지 않는다. 좀 더 자세히 설명하자면

    String은 Any의 하위 타입이지만, Array<String>은 Array<Any>의 하위 타입으로 간주되지 않으며,

    반대의 경우인 Array<Any>가 Array<String>의 하위 타입으로도 간주되지 않는다.

     

    반대로 List나 Set같은 불변 컬렉션의 경우, 타입 파라미터의 하위 타입 관계가 컬렉션 타입에서도 유지된다.

    List<String>은 list<Any>의 하위 타입이고, 따라서 아래와 같은 코드 역시 성립한다.

    val obj: List<Any> = listOf("a", "b", "c") // OK

    변성을 합리적으로 사용하면 타입 안정성을 해치지 않으면서 API 유연성을 향상시킬 수 있다고 한다.

     

    1) 변성 : 생산자와 소비자 구분

    디폴트로 어떤 제네릭 타입 인자를 서로 다른 타입으로 대체한 타입들과

    타입 인자들 사이에 하위 타입 관계가 있는 경우 둘 다 아무 관계가 없는 타입으로 간주된다.

    이 경우를 해당 제네릭 타입이 무공변(invariant)이라고 말한다.

    (공변이라는 말은 타입 파라미터의 상하위 타입 관계에 따라 제네릭 타입의 상하위 타입 관계가 함께 변한다는 의미)

     

    반면 불변 컬렉션 같은 어떤 제네릭 타입들은 타입 인자 사이의 하위 타입 관계가

    그대로 제네릭 타입에서도 유지되는데, 공변과 무공변 타입은 어떻게 구분할까?

    이런 구분은 제네릭 타입이 자신의 타입 파라미터(T라고 가정)를 취급하는 방법에 달려있다.

     

    먼저 모든 제네릭 타입은 크게 세 가지로 나뉜다.

    1) 생산자 : T 타입의 값을 반환하는 연산만 제공하고, 입력으로 받는 연산은 제공하지 않는 제네릭 타입

    2) 소비자 : T 타입의 값을 입력으로 받기만 하고 T 타입의 값을 반환하지는 않는 제네릭 타입

    3) 2, 3번에 해당하지 않는 나머지 타입


    3) 2, 3번에 해당하지 않는 나머지 타입

     

    이 세 가지 타입 중 1번에 속하는 일반적인 타입(생산자도 소비자도 아닌 타입)의 경우

    타입 안정성을 깨지 않고는 하위 타입 관계를 유지할 수 없다.

     

    이와같은 사례를 보기 위해 이전 글인 9-1장에서 작성했던 TreeNode를 예시로 들어보겠다.

    타입 파라미터의 하위 타입 관계가 그대로 TreeNode에 타입 인자를 넣은 경우에도 성립한다고 가정하고,

    하위 타입 관계가 성립하므로 TreeNode<String>을 TreeNode<Any>에 대입할 수 있다.

    val stringNode = TreeNode<String>("Hello")
    val anyNode: TreeNode<Any> = stringNode 
    anyNode.addChild(1234)
    val s = stringNode.children.first() // ???

     

    위 코드를 보면 타입 안정성을 깨지 않고 하위 타입 관계를 왜 유지할 수 없는지 알 수 있다.

    Any 타입의 값을 TreeNode<Any>의 자식으로 추가할 수 있기 때문에 stringNode를 anyNode에 대입하고 나면

    Int를 String 타입의 트리에 대입할 수 있게 되고, 이를 허용하면

    stringNode.children.first()를 String으로 캐스트 할때 예외가 발생한다.

    즉 TreeNode<String>의 자식으로 정숫값을 넣을 수 있으므로 TreeNode<String>의 계약을 위반할 수 있게 된다.

     

    위와 같은 경우는 자바에서 배열 대입으로 인해 발생할 수 있는 ArrayStoreException과 유사한 경우이다.

    자바의 경우 아래와 같은 대입을 하면 예외가 발생한다.

    Object array[] = new String[5];
    array[0] = 2 // throw ArrayStoreException

    하지만 코틀린에서는 이러한 예외가 발생할 수 있는 경우를 컴파일러가 검사해준다고 생각하면 될 것 같다.

     

    A 타입을 B 타입의 하위 타입이라고 간주한다는 말은

    A 타입의 값을 B 타입의 값이 쓰일 수 있는 모든 문맥에서 사용할 수 있다는 뜻 이다.

    하지만 TreeNode<Any> 타입의 값은 어떤 타입의 값이든 자식으로 받을 수 있지만,

    TreeNode<String>은 String 타입의 값만 자식으로 받을 수 있으므로

    TreeNode<String>은 TreeNode<Any>의 하위 타입이 될 수 없다.


    2) 생산자 제네릭 타입

     

     

    반면 List<T>같은 불변 컬렉션(immutable collection)은 1)번 타입과의 차이점을 보면,

    불변 컬렉션에서는 TreeNode의 addChild() 같은 함수가 없다.

    불변 컬렉션은 T 타입의 값을 만들어내기만 하고 결코 소비하지 않는다.

     

    List<Any>의 기본적인 계약은 Any 타입의 값을 돌려주는 것이다.

    마찬가지로 List<String>의 계약은 String 타입의 값을 돌려주는 것이다.

    String이 Any의 하위 타입이기 때문에 List<String>은 자동적으로

    Any 타입의 값을 돌려주는 능력도 갖게 된다.

     

    즉 컴파일러가 List<String>이 타입 안전성을 해치지 않고 List<Any> 대신 쓰일 수 있도록 허용할 수 있다.

    이런 경우를 제네릭 타입이 타입 인자에 대해 공변적(convariant)이다고 말한다.

    Pair, Triple, Iterable, Iterator 등과 같은 대부분의 내장 불변 타입(built-in immutable types)은 공변적이다.

    그래서 코틀린에서 생산자 역할을 하는 타입은 모두 공변적이다.

    val stringProducer: () -> String = { "Hello" }
    val anyProducer: () -> Any = stringProducer
    println(anyProducer()) // Hello

    3) 소비자 제네릭 타입

     

    소비자 역할을 하는 타입은 앞에 내용에 따르면, 분명 타입 파라미터의 하위 타입 관계를 유지시키지 못한다.

    하지만 이런 타입은 타입 파라미터의 하위 타입 관계를 역방향으로 유지시켜준다는 사실이 밝혀졌다.

     

    이해를 위해 예시를 들자면, Set<T>의 T를 바꾼 두 타입인 Set<Int>, Set<Nubmer>를 보자

    Set<T>의 계약은 contains() 함수에 의해 T 타입인 원소를 다룰 수 있다(handle)는 점 이고

    그래서 Set<Number>는 Number 타입을, Set<Int>는 Int 타입의 원소를 다룰 수 있다.

    Int는 Number의 하위 타입이므로 Set<Number>는 아무 Int 값이나 다룰 수 있다.

    즉 Set<Number>는 Set<Int>의 하위 타입처럼 동작하는데, 이를 반공변적(contravariant)이라고 한다.

    실제 코틀린에서 T를 반공변적이라고 선언하면서, 이런 식의 하위 타입 관계를 지정할 수 있다.

    val anyConsumer: (Any) -> Unit = { println(it) }
    val stringConsumer: (String) -> Unit = anyConsumer
    stringConsumer("Hello") // Hello

    위 세 가지 내용들을 정리하면 다음과 같다.

    • X가 생산자(producer) 역할인 경우
      • T를 공변적(covariant)으로 선언할 수 있고 A가 B의 하위 타입이면 X<A>도 X<B>의 하위 타입이 된다.
    • X가 소비자(consumer) 역할인 경우
      • T를 반공변적(contravariant)으로 선언할 수 있고, B가 A의 하위 타입이면 X<A>가 X<B>의 하위 타입이 된다.
    • 나머지 경우 X는 T에 대해 무공변(invariant)이다.

    코틀린에서 변성을 표현하는 방법 두 가지에 대해 알아보자.

    • 선언 지점 변성 : 타입 파라미터의 변성을 선언 자체에 지정
    • 프로젝션을 사용한 사용 지점 변성 : 타입 인자를 치환하는 사용 시점에서 지정

     

    2) 선언 지점 변성

    디폴트로 타입 파라미터는 무공변으로 취급된다. 즉 제네릭 타입이 타입 파라미터의 하위 타입 관계를 유지하지 않는다.

    예를들어 List 타입을 단순화한 인터페이스와 배열 기반의 불변 구현을 생각해보자

    interface List<T> {
        val size: Int
        fun get(index: Int): T
    }
    class ListByArray<T>(private vararg val items: T) : List<T> {
        override val size: Int get() = items.size
        override fun get(index: Int) = items[index]
    }

    이제 한 쌍의 리스트를 받아서 두 리스트의 모든 원소를 담은 리스트를 돌려주는 concat 함수를 만든다.

    이 함수는 원소를 따로 저장하지 않고, 원래의 두 리스트를 활용해 원소를 가져온다.

    fun <T>concat(list1: List<T>, list2: List<T>) = object :
            List<T> {
        override val size: Int
            get() = list1.size + list2.size
        override fun get(index: Int): T {
            return if (index < list1.size) {
                list1.get(index)
            } else {
                list2.get(index - list1.size)
            }
        }
    }

    위 함수는 문제가 없어 보이지만, List<Number>와 List<Int>처럼 서러  연관이 있는

    두 타입의 리스트를 합치려는 경우 문제가 생긴다.

    fun main() {
        val numbers = ListByArray<Number>(1, 2.5, 3f)
        val integers = ListByArray(10, 30, 30)
        val result = concat(numbers, integers) // Error : Type mismatch.
    }

    에러가 생기는 이유는 이 리스트가 타입 파라미터 T에 대해 무공변이기 때문이다.

    이로인해 List<Int>는 List<Number>의 하위 타입이 아닌 것으로 간주된다(역방향도 성립하지 않는다)

    그래서 List<Number>를 인자로 받는 함수 인자로 List<Int>를 넘길 수 없다.

     

    리스트 인터페이스를 훑어보면 이 타입이 실제로는 생산자 타입처럼 동작한다는 점을 알 수 있다.

    이 인터페이스의 모든 연산은 T 타입의 값을 반환하기만 할 뿐 입력으로 받지 않는다.

    즉 이 타입은 안전하게 T에 대해 공변적이 될 수 있고 위 코드에서 생긴 문제를 해결할 수 있다.

    이를 위해 타입 파라미터 T 앞에 out 키워드를 붙여준다.

    interface List<out T> {
        val size: Int
        fun get(index: Int): T
    }

    out 키워드를 붙이면, 이제 컴파일러가 List<Int>가 List<Number>의

    하위 타입이라는 사실을 알게 되므로 concat() 호출이 동작하게 된다.

    val numbers = ListByArray<Number>(1, 2.5, 3f)
    val integers = ListByArray(10, 30, 30)
    val result = concat(numbers, integers) // OK

    컴파일러가 다른 장소에서 파라미터를 공변적으로 선언할 수 있게 허용하지 않으므로

    생산자를 지정하는 부분이 중요하다.

     

    out position은 기본적으로 값을 만들어내는 position이다.

    어떤 파입 파라미터가 항상 out position에서 쓰이는 경우에만 이 타입 파라미터를 공변적으로 선언할 수 있다.

    프로퍼티나 함수의 반환값 타입이나 제네릭 타입의 공변작인 타입 파라미터 위치가 out position이다.

    interface LazyList<out T> {
        
        fun get(index: Int): T // 반환 타입으로 사용
        
        fun subList(range: IntRange): LazyList<T>
        // 반환 타입의 out 타입 파라미터로 사용
        
        fun getUpTo(index: Int): () -> List<T>
        // 함수 타입의 반환값 부분도 out 위치
    }

    out과 마찬가지로, 반공변인 타입 파라미터 앞에 in 키워드를 붙일 수 있다.

    in position은 값을 함수 인자나 제네릭 타입의 반공변 타입 인자로 사용(consume)하는 경우를 뜻한다.

    제네릭 타입이 소비자 역할을 할 때 타입 파라미터를 in으로 표시할 수 있다.

    이 말은 타입 파라미터가 out position에 전혀 사용되지 않는다는 뜻이다.

    class Writer<in T> {
        // 함수 인자로 사용
        fun write(value: T) {
            println(value)
        }
        // in position에 사용된 Iterable 제네릭 타입의 out position 인자로 T를 사용
        // 이런 경우 위치가 in position 으로 인정됨
        fun writeList(values: Iterable<T>) {
            values.forEach { println(it) }
        }
    }
    fun main() {
        val numberWriter = Writer<Number>()
        val integerWriter: Writer<Int> = numberWriter
        // OK : Writer<Number>가 Int로도 쓰일 수 있음
        integerWriter.write(100)
    }

    3) 프로젝션을 사용한 사용 지점 변성

    변성을 지정하는 다른 방법으로 제네릭 타입을 사용하는 위치에서

    특정 인자 타입 앞에 in / out을 붙이는 방법이 있다. 이 방식을 프로젝션이라고 하는데,

    일반적으로 무공변인 타입이지만 문맥에 따라 생산자나 소비자로 쓰이는 경우 유용하다.

     

    기존 트리의 복사본을 다른 트리에 추가하는 함수를 구현하는 예시를 보자.

    먼저 무공변인 함수로부터 시작해보자면,

    fun <T>TreeNode<T>.addSubTree(node: TreeNode<T>): TreeNode<T> {
        val newNode = addChild(node.data)
        node.children.forEach { newNode.addSubtree(it) }
        return newNode
    }
    
    fun main() {
        val root = TreeNode("abc")
        val subRoot = TreeNode("def")
        root.addSubtree(subRoot)
        println(root) // abc {def {}}
    }

    위 함수는 잘 작동하지만, 두 트리의 원소 타입이 서로 같은 타입일 때만 작동한다.

     

    두 트리의 원소 타입이 다른 경우를 생각해보자

    Int 트리를 Number로 이뤄진 트리에 추가하고 싶은 경우

    Int가 Number의 하위 타입이기 때문에 Int 타입이 들어있는 트리 노드를 Number 트리에 추가해도

    트리가 원소 타입에 대해 가정하는 내용에 위배되지 않으므로 이 연산은 잘 정의된 연산이다.

    하지만 TreeNode<T>는 무공변 타입이므로

    addSubTree() 함수에서 인자 타입과 수신 객체 타입을 똑같이 T라고 쓸 수 밖에 없다.

    그리고 이로 인해 컴파일러는 Int 트리를 Number 트리에추가하는 연산을 허용하지 않는다.

    val root = TreeNode<Number>(123)
    val subRoot = TreeNode(456.7) 
    root.addSubTree(subRoot) // ERROR : Type mismatch

    위 코드에서 이 문제는 out 키워드를 사용하면 해결할 수 있다.

    TreeNode<T> 타입에는 (data 프로퍼티처럼) T 타입의 값을 돌려주는 멤버와

    (addChild() 함수처럼) T 타입의 값을 입력으로 사용하는 멤버가 모두 들어있기 때문에

    TreeNove<T> 타입 자체는 무공변으로 남을 수 밖에 없다.

    하지만 addSubTree() 함수 내부 맥락에서는 인자로 전달된 트리를 오직 소비자로만 사용한다.

    따라서 필요한 타입 인자를 out으로 표시하면 코드를 동작시킬 수 있다.

     

    위에서 작성했던 addSubTree() 함수에서 타입 인자에 out을 추가하면

    컴파일 에러가 발생하지 않고 정상적으로 동작하는 것을 확인할 수 있다.

    fun <T>TreeNode<T>.addSubTree(node: TreeNode<out T>): TreeNode<T> 
    {...}
    
    fun main() {
        val root = TreeNode<Number>(123)
        val subRoot = TreeNode(456.7)
        root.addSubtree(subRoot) // OK
        println(root) // 123 {456.7 {}}
    }

    또는 out을 사용하지 않고 추가되는 트리의 원소를 표현하기 위해

    첫 번째 타입에 의해 바운드되는 두 번째 타입 파라미터를 추가하는 방법도 있는데, 결과는 동일하다.

    fun <T>TreeNode<T>.addSubtree(node: TreeNode<out T>): TreeNode<T> {
        val newNode = addChild(node.data)
        node.children.forEach { newNode.addSubtree(it) }
        return newNode
    }
    
    fun main() {
        val root = TreeNode<Number>(123)
        val subRoot = TreeNode(456.7)
        root.addSubtree(subRoot) // OK
        println(root) // 123 {456.7 {}}
    }

    out 프로젝션을 사용하면 타입 파라미터를 추가할 필요 없이 문제를 더 간결하게 해결할 수 있다.

     

    TreeNode<out T>를 프로젝션한 타입이라고 부른다. 

    프로젝션인 out T는 TreeNode의 실제 타입 인자는 모르지만,

    이 타입 인자가 T의 하위 타입이어야 한다는 의미이다.

    TreeNode<out T>를 treeNode<T>에 속하지만 T에 대해

    생산자 역할만 하는 연산만 노출시킨 타입이라고 생각할 수도 있다.

    예를들어 data, childern, depth 등의 프로퍼티나, walkDepthFirst() 등의 함수는 입력으로

    T 타입의 값을 받지 않기 때문에 생산자 역할만 한다고 할 수 있다.

     

    이런 프로젝션 타입 내에 addChildren() 멤버나 addchildren() 확장같은

    소비자 역할을 하는 연산이 존재하지만, 사용할 수는 없다.

    이런 연산을 사용하려고 시도하면, 컴파일 오류가 발생한다.

    fun processOut(node: TreeNode<out Any>) {
        node.addChild("xyz") // ERROR: Type mismatch
    }

    이와 비슷하게 in 프로젝션을 통해 타입을 소비자로만 사용하게 하는것도 가능하다.

    아래와 같은 형태의 코드로 추가 함수를 작성할 수도 있다.

    fun <T>TreeNode<T>.addTo(parent: TreeNode<in T>) {
        val newNode = parent.addChild(data)
        children.forEach { it.addTo(newNode) }
    }

     

    이번에는 추가할 노드들이 들어있는 원본 트리가 수신 객체이고,

    노드를 추가할 대상 트리가 함수의 인자이다.

    in 프로젝션으로 인해 TreeNode<T>에 대해 정의된 이 함수는 T의 상위 타입이 들어있는 트리만 허용한다.

    fun main() {
        val root = TreeNode<Number>(123)
        val subRoot = TreeNode(456.7)
        subRoot.addTo(root)
    //    root.addTo(subRoot) // ERROR : Type mismatch
        println(root) // 123 {456.7 {}}
    }

    프로젝션을 사용하면 프로젝션이 적용된 타입 인자에 해당하는 선언 지점 변성이 의미가 없다는 점에 유의해야 한다.

    프로젝션이 타입 파라미터의 변성과 일치하면 프로젝션이 불필요하기 때문에 컴파일러가 경고를 표시한다.

    반대로 프로젝션과 타입 파라미터의 변성이 일치하지 않으면 컴파일 에러가 난다.

    val inProducer: Producer<in String> // ERROR
    val outConsumer: Consumer<out String> // ERROR
    // Projection is conflicting with variance of the corresponding type parameter of Consumer. 
    // Remove the projection or replace it with '*'
    
    
    val outProducer: Producer<out String> // Warning
    val inConsumer: Consumer<in String> // Warning
    //Projection is redundant: the corresponding type parameter of Consumer has the same variance

    코틀린 프로젝션은 근본적으로 자바의 extends/super 와일드카드와 같은 역할을 한다.

    예를들어 코틀린의 TreeNode<out Number>와 TreeNode<in Number>는

    자바의 TreeNode<?extends Number>와 TreeNode<? super Number>에 해당한다.

     

    자바 와일드카드와 마찬가지로 프로젝션을 사용하면 타입을 생산자나 소비자로만 사용하도록

    제약을 걸 수 있어 무공변 타입을 더 유용하게 쓸 수 있다.

     

    추가로 코틀린은 제네릭 타입 파라미터를 아무 타입으로나 치환할 수 있게 해주는

    스타 프로젝션이라는 방법도 제공한다.


    4) 스타 프로젝션

    * 로 표시되는 스타 프로젝션은 타입 인자가 타입 파라미터의 바운드 내에서 아무 타입이나 될 수 있다는 사실을 표현한다.

    코틀린 타입 파라미터는 상위 바운드만 허용하기 때문에 타입 이낮에 스타 프로젝션을 사용하면 타입 인자가

    해당 타입 파라미터를 제한하는 타입의 하위 타입 중 어떤 것이든 관계없다는 뜻이다.

    val anyList: List<*> = listOf(1, 2, 3)
    // List의 원소 타입은 Any? 타입으로 제한되므로 아무 리스트나 가능함
    
    val anyComparable: Comparable<*> = "abcde"
    // T : Comparable<T> 바운드에 의해 자기 자신과 비교 가능한 아무 객체나 가능

    즉 스타 프로젝션은 out 프로젝션을 타입 파라미터 바운드에 적용한 방식처럼 동작한다.

    코틀린의 스타 프로젝션은 자바의 ? 와일드카드에 대응한다. 코틀린TreeNode<*>는 자바의 TreeNode<?>와 동일하다.

     

    스타 프로젝션을 한 타입을 타입 검사 연산에 쓸 수 있다.

    val any: Any = ""
    any is TreeNode<*>

    TreeNode의 타입 파라미터는 Any?에 의해 바운드되므로

    이를 명시적인 out 프로젝션을 써서 아래와 같이 쓸 수도 있다.

    any is TreeNode<out Any?> // OK

    하지만 Any?를 다른 타입으로 치환하려고 하면 타입 소거로 인해

    타입 체크가 불가능해지므로컴파일러가 오류를 표시한다.

    val any: Any = ""
    any is TreeNode<out Number> // ERROR : Cannot check for instance of erased type

    TreeNode<*>와 TreeNode<Any?> 처럼 *를 사용하는 경우와

    타입 파라미터 바운드를 비프로젝션(non-projection)타입으로 타입 파라미터에 사용하는 경우의

    차이를 구분하는것이 중요하다.

    TreeNode<Any>는 아무 타입의 값이나 노드 값으로 들어갈 수 있는 트리를 뜻하지만,

    TreeNode<*>는 모든 노드가 어떤 공통 타입 T에 속하지만, T가 어떤 타입인지 알려지지 않은 트리를 뜻한다.

    이런 이유로 TreeNode 연산은 T 타입의 값을 소비하는 소비자로 동작한다.

    다만 실제 타입을 알지 못하므로 어떤 값을 트리가 받아들일지도 알 수 없다.

     

    즉, 스타 프로젝션을 사용하면 타입 인자가 중요하지 않거나 알려져 있지 않은 제네릭 타입을 간결하게 표현할 수 있다.


    다음글

    9-3. 제네릭스 : 타입 별명

    반응형

    댓글

Designed by Tistory.