ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 9-1. 제네릭스 : 타입 파라미터
    Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 11. 10. 23:57

    9장

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

    9-2. 제네릭스 : 변성

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


    내가 알고 있는 제네릭스는 

    " <Type> "  형태로 선언하는 방식으로 Type 부분에는 정수형 Int, 소수형 Double, 문자형 String 등의 타입이

    들어갈 수 있다고 알고있다. 사용할 때는 예를들면 어떤 가변 길이의 데이터를 저장하고 싶을 때

    List를 사용하면 원하는 타입을 지정해 사용 가능하고 제공해주는 함수를 통해

    삽입 삭제 검색 등의 기능도 쉽게 가능하므로 아주 편리한 방식이라고 생각하고 있다.

    하지만 실제로 구현을 할 때는 Type 종류별로 모두 고려해야 하므로

    구현은 아주 어려울 것이라고 막연하게 생각하고 있다.

     

    책에서는 제네릭스를 이렇게 설명한다.

    제네릭스는 여러분이 알지 못하는 타입의 데이터를 조작하는 코드를 작성할 수 있게 해주는 

    코틀린 타입 시스템이 제공하는 강력한 기능이다.

     

    9장에서는 어떻게 제네릭스 선언을 정의하고 사용하는지 살펴보고

    런타임 타입 소거(type erasue)와 구체화(reification)로 인해 제네릭 사용 시 어떤 문제가 생기는지

    논의한 후타입 파라미터 값이 달라지는 경우까지 하위 타입 관계를 확장함으로써

    제네릭스의 유현성을 향상시켜주는 중요한 개념인 변성(variance)을 설명한다.

    이와 연관된 주제로 기존 타입에 대해 새로운 이름을 부여할 수 있는 타입 별명(type alias) 개념도 다룬다.

     

    9-1. 타입 파라미터

    1) 제네릭 선언

    어떤 선언을 제네릭 선언으로 만들려면 하나 이상의 타입 파라미터를 추가해야 한다.

    이렇게 추가한 타입 파라미터를 선언 내부에서는 일반적인 타입 대신 사용할 수 있다.

    선언을 사용할 때 타입 파라미터를 대신할 실제 타입을 지정해야 한다.

    val map = HashMap<Int, String>()
    val list = arrayListOf<String>()

    컴파일러가 문맥에서 타입 인자의 타입을 추론할 수 있다면 타입 인자를 생략 가능한 경우도 있다.

    val list = arrayListOf(123, 456)

    어떤 주어진 타입의 값을 저장할 수 있는 트리를 표현하는 클래스를 정의하고 싶다고 해보자.

    class TreeNode<T>(val data: T){
        private val _children = arrayListOf<TreeNode<T>>()
        var parent: TreeNode<T>? = null
        private set
    
        val children: List<TreeNode<T>> get() = _children
    
        fun addChild(data: T) = TreeNode(data).also {
            _children += it
            it.parent = this
        }
    
        override fun toString() =
            _children.joinToString ( prefix = "$data {", postfix = "}" )
    }
    
    fun main() {
        val root = TreeNode("Hello").apply {
            addChild("World")
            addChild("!@!")
        }
    
        println(root)
        // Hello {World {}, !@! {}}
    }

    클래스의 타입 파라미터를 각괄호( < > ) 안에 써야 한다.

    타입 파라미터는 클래스 이름 바로 뒤에 오며 아무 이름이나 가능하지만, 관습적으로 T, U, V등의

    짧은 대문자를 사용한다고 한다.

    그래서 위 코드에서 TreeNode<T>를 TreeNode<U> 등으로 바꿀 수 있다.

    (이후 코드에서도 T를 U로 바꿔주어야 한다 val data: U, TreeNode<U> ...)

     

    제네릭 클래스나 인터페이스를 사용해 데이터의 타입을 지정할 때는 반드시 타입 인자를 명시해야 한다.

    위 코드처럼 코틀린은 TreeNode<String>처럼 구체적인 타입을 지정하거나,

    TreeNode<U>처럼 타입 인자로 받은 타입을 반드시 지정해야 한다.

    자바는 타입 파라미터를 지정하지 않은 raw type을 사용할 수 있지만, 코틀린은 이를 허용하지 않는다.


    제네릭 클래스 생성자를 호출할 때 타입 인자가 불필요한 경우가 자주 있다.

    대부분의 경우 컴파일러가 문맥에서 타입 인자를 추론해준다.

    따라서 위 예제 코드에서 사용했던 것 처럼 TreeNode("Hello")라고 작성해 생성자를 호출할 수 있다.

     

    다만 상위 클래스 생성자에 대한 위임 호출은 이런 방식을 통해서 생성자를 호출할 수 없다.

    open class DataHolder<T>(val data: T)
    
    class StringDataHolder(data: String) : DataHolder<String>(data)
    // 실제 타입을 상위 타입의 인자로 넘김
    
    class TreeNode<T>(data: T) : DataHolder<T>(data)
    // 타입 인자를 상위 타입 인자로 넘김

    위와 같은 코드를 작성한 뒤 일반 생성자 호출과 위임 호출의 차이점을 알아보자

    class StringDataHolder(data: String) : DataHolder(data)
    // ERROR : One type argument expected for class DataHolder<T>
    
    fun StringDataHolder(data: String) = DataHolder(data) // OK
    // DataHolder<String>을 컴파일러가 추론

    이와같은 결과가 나타나게 되는데, 컴파일러는 위임 호출의 타입 인자를 추론해주지 못한다.

    따라서 항상 위임 호출의 타입 인자를 명시해야 한다.

     

    또한 타입 파라미터를 상속하지 않는다는 사실에 유의해야 한다.

    생성자 파라미터를 상위 타입 인자로 전달하는 방식과 유사한 방식으로

    타입 파라미터를 상위 타입의 타입 인자로 전달해야 한다.

    따라서 TreeNode에 있는 T와 DataHolder에 있는 T는 서로 다른 선언이므로 아래와 같이 작성해도 된다.

    open class DataHolder<T>(val data: T)
    class TreeNode<U>(data: U) : DataHolder<U>(data)

    앞에서 사용했던 addChild()나 children 정의를 보면 알수 있는 것 처럼

    제네릭 클래스에 정의된 함수와 프로퍼티에서 클래스의 타입 파라미터를 사용할 수 있다.

    또한 프로퍼티나 함수에 타입 파라미터를 추가하면 프로퍼티나 함수 자체를 제네릭으로 만들 수 있다.

    class TreeNode<T>(val data: T){
        private val _children = arrayListOf<TreeNode<T>>()
        var parent: TreeNode<T>? = null
        private set
    
        val children: List<TreeNode<T>> get() = _children
    
        fun addChild(data: T) = TreeNode(data).also {
            _children += it
            it.parent = this
        }
        override fun toString() =
            _children.joinToString ( prefix = "$data {", postfix = "}" )
    }
    
    fun <T> TreeNode<T>.addChildren(vararg data: T){
        data.forEach { addChild(it) }
    }
    
    fun <T> TreeNode<T>.walkDepthFirst(action: (T) -> Unit){
        children.forEach { it.walkDepthFirst { action } }
        action(data)
    }
    
    val <T> TreeNode<T>.depth: Int
        get() = (children.asSequence().map { it.depth }.maxOrNull() ?: 0) + 1
    
    fun main() {
        val root = TreeNode("Hello").apply {
            addChildren("World", "!@!")
        }
    
        println(root) // Hello {World {}, !@! {}}
        println(root.depth) // 2
    }

    제네릭 클래스에서와 달리 프로퍼티나 함수를 제네릭으로 선언할 때는

    타입 파라미터를 fun이나 val / var 바로 뒤에 위치시킨다.( val <T> / fun <T> )

     


    2) 바운드와 제약

    기본적으로 타입 인자로 들어갈 수 있는 타입에는 아무런 제약이 없다.

    따라서 타입 파라미터들은 Any? 타입과 동의어인 것 처럼 처리된다.

    하지만 제네릭 클래스를 구현하면서 다뤄야 할 데이터의 타입에 좀 더 많은 정보가 필요한 경우가 많다.

     

    위에서 작성한 TreeNode 예제를 확장해서 모든 트리 노드에 저장된 값들의 평균을 계산하고 싶다면

    이런 종류의 연산은 수를 저장한 트리에만 적용할 수 있기 때문에

    트리 원소가 Number(또는 그 하위 타입)이길 바란다.

    이런 특성을 표현하기 위해 타입 파라미터의 상위 바운드(upper bound)로 Number를 선언할 수 있다.

    fun <T : Number>TreeNode<T>.average(): Double {
        var count = 0;
        var sum = 0.0
        walkDepthFirst { 
            count++
            sum += it.toDouble()
        }
        return sum / count
    }
    val intTree = TreeNode(1).apply {
        addChild(2).addChild(3)
        addChild(4).addChild(5)
    }
    println(intTree.average()) // 3.0
    
    val doubleTree = TreeNode(1.0).apply {
        addChild(2.0)
        addChild(3.0)
    }
    println(doubleTree.average()) // 2.0
    
    val stringTree = TreeNode("Hello").apply {
        addChild("apple")
        addChild("banana")
    }
    println(stringTree.average())
    //    ERROR : None of the following candidates is applicable because of receiver type mismatch:

    final 클래스를 상위 바운드로 사용하면 이런 바운드는 쓸모가 없으므로 컴파일러가 경고를 표시한다.

    final 클래스의 예시로 Int 클래스를 사용해 코드를 작성해보자.

    fun <T: Int>TreeNode<Int>.sum(): Int{ 
    // warning : Int is a final type, and thus a value of the type parameter is predetermined
        var sum = 0
        walkDepthFirst { sum += it }
        return sum
    }

    타입 파라미터 바운드로 타입 파라미터를 사용할 수 있으며, 이를 재귀적 타입 파라미터라고 한다.

    예를 들어 트리 안에 Comparable 인터페이스의 인스턴스만 들어있다면,

    최댓값이 들어있는 노드를 찾을 수 있다.

    fun <T : Comparable<T>> TreeNode<T>.maxNode(): TreeNode<T> {
        val maxChild = children.maxByOrNull { it.data } ?: return this
        return if(data >= maxChild.data) this else maxChild
    }
    
    fun main() {
        val doubleTree = TreeNode(1.0).apply {
            addChild(2.0)
            addChild(3.0)
        }
        println(doubleTree.maxNode().data) // 3.0
    
        val stringTree = TreeNode("abc").apply {
            addChildren("xyz", "def", "qqq")
        }
        println(stringTree.maxNode().data) // xyz
    }

    바운드가 자신보다 앞에 있는 타입 파라미터를 가리킬 수도 있다.

    이런 바운드를 통해 트리 원소를 가변 리스트에 추가하는 함수를 정의할 수도 있다.

    fun <T, U : T> TreeNode<U>.toList(list: MutableList<T>){
        walkDepthFirst { list += it }
    }

    위 코드에서 U가 T의 하위 타입이므로

    위 함수는 트리 원소의 타입보다 더 일반적인 타입의 리스트를 인자로 받을 수 있다.

     

    예를들어 Int 트리나 Double 트리에 있는 원소들을 Number 타입의 리스트에 추가하는 방식이 가능하다.

    fun main() {
        val list = ArrayList<Number>()
    
        TreeNode(1).apply {
            addChild(2)
            addChild(3)
        }.toList(list)
    
        TreeNode(1.0).apply {
            addChild(2.0)
            addChild(3.0)
        }.toList(list)
    }

    3) 타입 소거와 구체화

    앞에서 타입 파라미터를 사용해 제네릭 선언 내부에 변수, 프로퍼티, 함수 타입을 지정하는 방법을 살펴봤는데,

    타입 파라미터가 항상 실제 타입을 대신할 수는 없다.

    fun <T>TreeNode<Any>.isInstanceOf(): Boolean = 
            data is T && children.all { it.isInstanceOf<T>() }
    // ERROR : Cannot check for instance of erased type: T

    코드의 작성 의도는 주어진 트리의 노드나 자식 노드가 모두 지정한 타입 T를 만족하는지 검사하는 내용이지만,

    컴파일러는 data is T 식에 오류를 표시한다. 그 이유는 타입 소거 때문이다.

     

    런타임에 제네릭 코드는 파라미터 타입의 차이를 인식할 수 없고, 그래서 data is T 같은 검사는 아무 의미가 없다.

    즉 isInstanceOf() 함수가 런타임에 호출될 때 T가 어떤 타입을 뜻할지 알 방법이 없다.

    동일한 이유로 제네릭 타입에 대해 is 연산자를 사용하는것 역시 의미가 없다.


    원소 타입에는 관심이 없고, 어떤 값이 리스트인지만 확인하고 싶은 경우

    코틀린의 제네릭 타입은 항상 타입 인자를 포함해야 하므로 이 경우에도 List만 사용할 수는 없다.

    올바른 검사 방식은 아래와 같다.

    list is List<*>
    map is Map<*>

    위 코드에서 *은 알지 못하는 타입을 뜻하며, 타입 인자 하나를 대신한다.

    위 구문은 실제로는 프로젝션이라는 특별한 경우에 속한다.


    어떤 값을 *가 아닌 타입 인자가 붙은 제네릭 타입으로 캐스트하는 것이 허용되지만,

    이런 캐스트에는 위험이 따르기 때문에 항상 경고가 표시된다.

    이런 방법을 사용하면 제네릭스의 한계를 우회할 수는 있지만,

    런타임까지 실제 타입 오류를 미루는 효과가 있다.

    val n = (listOf(1, 2, 3) as List<Number>)[0] // OK
    val s = (listOf(1, 2, 3) as List<String>)[0]
    // ERROR : ClassCastException

    위 코드에서 두 식은 모두 경고가 표시되며 컴파일되지만, 첫 번째 식은 정상작동하고

    두 번째 식은 예외를 던진다. 이 예외는 리스트 원소의 값을 String 변수에 대입하기 때문에 생긴다.

     

    자바에서는 타입 소거를 우회하는 방법으로 캐스트를 활용하거나, 리플렉션(reflection)을 사용하지만

    두 방식 모두 단점이 있다.

    캐스트는 문제를 컴파일이 되도록 덮어버려 런타임 오류가 발생한다.

    리플렉션 API를 사용할 경우 성능에 영향을 미칠 수 있다.

     

    코틀린에서는 위 두 가지 방법의 약점을 극복한 세 번째 해법이 있다

    어떻게 컴파일러가 타입 소거를 우회할 수 있을까? 

    답은 인라인한 함수에 대해서만 구체화된 타입 파라미터를 쓸 수 있다는 점을 활용하면 된다.

    여기서 구체화란 타입 파라미터 정보를 런타임까지 유지한다는 뜻 이다.

    함수 본문을 호출 위치로 인라인시키므로

    컴파일러가 인라인된 함수에 제공되는 타입 인자의 실제 타입을 항상 알 수 있다.


    파라미터를 구체화하려면 reified 키워드로 해당 타입 파라미터를 지정해야 한다.

    이 기능을 이용해 위에서 오류가 났던 isInstanceOf() 함수를 수정해보자.

    이전에 인라인 함수는 재귀 함수일 수 없으므로 구현을 수정해야 한다.

    아래 코드는 별도의 인라인되지 않는 함수(cancellableWalkDepthFirst)로 트리 순회 로직을 추출해 사용한다.

    // fun <T>TreeNode<Any>.isInstanceOf(): Boolean =
    //        data is T && children.all { it.isInstanceOf<T>() }
    // ERROR
    
    fun <T>TreeNode<T>.cancellableWalkDepthFirst(
            onEach: (T) -> Boolean
    ): Boolean{
        val nodes = java.util.LinkedList<TreeNode<T>>()
        nodes.push(this)
    
        while (nodes.isEmpty()){
            val node = nodes.pop()
            if(!onEach(node.data)) return false
            node.children.forEach { nodes.push(it) }
        }
        return true
    }
    inline fun <reified T>TreeNode<Any>.isInstanceOf(): Boolean =
            cancellableWalkDepthFirst{ it is T } // OK

    위 코드를 아래와 같이 호출할 수 있다.

    inline fun <reified T>TreeNode<Any>.isInstanceOf(): Boolean =
            cancellableWalkDepthFirst{ it is T } // OK
    fun main() {
        val tree = TreeNode<Any>("abc").addChild("def").addChild(123)
        println(tree.isInstanceOf<Double>())
    }

    컴파일러는 isInstanceOf()를 인라인해서 T 대신 실제 타입인 String을 넣으므로 실제 코드는 아래와 같아진다.

    println(tree.cancellableWalkDepthFirst { it is String })

    자바에서 사용했던 타입 소거를 우회하는 방식과 달리 위와 같은 방법은

    안전하고(검사하지 않는 캐스트를 쓰지 않으므로) 빠르다.(코드가 인라인되므로)

    하지만 인라인 함수를 사용하면 컴파일 코드의 크기가 커지는 경향이 있다는 점은 유의해야 한다.


    다음글

    9-2. 제네릭스 : 변성

    반응형

    댓글

Designed by Tistory.