반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[코틀린 완벽 가이드] 9장 : 제네릭스 본문

Kotlin/코틀린 완벽 가이드

[코틀린 완벽 가이드] 9장 : 제네릭스

어리둥절범고래 2022. 11. 7. 18:14
반응형

1. 타입 파라미터


1. 제네릭 선언

 

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

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

 

 인자 타입을 추론할 수 있으면 생략할 수 있다.

val map: Map<Int, String> = HashMap() // 타입을 명시했기 때문에 추론 가능
val list = arrayListOf("abc", "def")  // 전달된 인자 타입으로 추론 가능

 

 

다음은 주어진 타입의 값을 저장할 수 있는 트리를 표현하는 클래스이다.

class TreeNode<T>(val data: T) {
    private val _children = arrayListOf<TreeNode<T>>()
    var parent: TreeNode<T>? = null
    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<String>("Hello").apply {
        addChild("World")
        addChild("!!!")
    }
    val root2 = TreeNode(123).apply { // Int 타입 추론 가능
        addChild(456)
        addChild(789)
    }
    println(root)  // Hello{World{}, !!!{}}
    println(root2) // 123{456{}, 789{}}
}

위 예제처럼 클래스의 타입 인자를 T로 받아 클래스 안에서 변수나 프로퍼티, 함수 타입으로 쓸 수 있다.

 

 

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

// 위임 호출 타입 명시: 실제 타입을 상위 타입의 타입 인자로 넘김
class StringDataHolder(data: String) : DataHolder<String>(data)

// error: 위임 호출시 타입 인자 추론 불가
class StringDataHolder(data: String) : DataHolder(data)

// 위임 호출 타입 명시: 타입 인자를 상위 타입의 타입 인자로 넘김
class TreeNode<T>(data: T) : DataHolder<T>(data) { }

컴파일러는 생성자 위임 호출의 타입 인자를 추론해주지 못한다. 따라서 항상 위임 호출의 타입 인자를 명시해야 한다.

 

 

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<String>("World","!!!")
    }
    println(root.depth) // 2
}

클래스 멤버 또는 객체가 아닌 확장 프로퍼티나 확장 함수를 제네릭으로 만들 수 있다. 제네릭 클래스와 달리 타입 파라미터를 fun / val / var 뒤에 위치시킨다.


2. 바운드와 제약

 

fun <T : Number>TreeNode<T>.average(): Double { // String 트리에 호출하면 컴파일 오류가 발생한다.
    var count = 0
    var sum = 0.0
    walkDepthFirst { // 깊이 우선으로 노드를 방문하면서 함수 수행
        count++
        sum += it.toDouble()
    }
    return sum/count
}

트리 원소가 Number 타입 혹은 그 하위 타입의 값으로 한정할 수 있는 상위 바운드로 선언할 수 있다.

상위 바운드는 Any?가 디폴트이며, 컴파일러는 타입 인자의 타입이 상위 바운드의 하위 타입인지 검사한다.

 

 

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() {
    // Double은 Comparator<Double>의 하위 타입임
    val doubleTree = TreeNode(1.0).apply {
        addChild(2.0)
        addChild(3.0)
    }
    println(doubleTree.children.maxByOrNull { it.data }?.data) // 3.0
    println(doubleTree.maxNode().data) // 3.0
}

타입 파라미터 바운드로 타입 파라미터를 사용할 수도 있다. 이런 경우를 재귀적 타입 파라미터라고 말한다.

 

 

바운드가 자신보다 앞에 있는 타입 파라미터를 가리킬 수도 있다. 이런 바운드를 사용해 트리 원소를 가변 리스트에 추가하는 함수를 정의할 수 있다.

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

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)
    
    println(list) // [2, 3, 1, 2.0, 3.0, 1.0]
}

U가 T의 하위 타입이므로 위 함수는 트리 원소의 타입보다 더 일반적인 타입의 리스트를 인자로 받을 수 있다. 위 예제처럼 Int 트리나 Double 트리에 있는 원소들을 Number 타입의 리스트에 추가할 수 있다.

 

 

바운드는 흔하게 널이 아닌 타입으로 제한하는 경우에 사용한다. 이 경우 상위 바운드로 널이 될 수 없는 타입을 지정해야 한다.

fun <T : Any> notNullTree(data: T) = TreeNode(data)

 

 

타입 파라미터 구문을 사용하면 타입 파라미터에 제약을 가할 수 있다. where 절을 클래스 선언 본문 앞에 추가하고 바운드할 타입 목록을 표시한다.

interface Named {
    val name: String
}
interface Identified {
    val id: Int
}
class Registry<T> where T : Named, T : Identified {
    val items = ArrayList<T>()
}

3. 타입 소거와 구체화

 

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

위 예제에서 data is T 는 T가 어떤 타입을 뜻할지 알 방법이 없다. 마찬가지 이유로 제네릭 타입에 is 연산자를 적용하는 것도 의미가 없다.

 

 

원소와 상관 없이 리스트의 타입을 확인할 수는 있다.

val list = listOf(1, 2, 3) // List<Int>
list is List<Number> // OK
list is List<Int> // OK
list is List<String> // Cannot check for instance of erased type: List<String>
list is List<*> // * 은 기본적으로 알지 못하는 타입이다.

val collection: Collection<Int> = setOf(1, 2, 3)
if (collection is List<Int>) {
    println("list")
}

 

*가 아닌 인자가 붙은 제네릭 타입으로 캐스트하는 것은 가능하지만 위험이 따른다.

val n = (listOf(1, 2, 3) as List<Number>)[0] // Ok
val s = (setOf(1, 2, 3) as List<Number>)[0] // java.lang.ClassCastException
val k = (listOf(1, 2, 3) as List<String>)[0] // java.lang.ClassCastException

 

이에 대한 해법으로 코틀린은 구체화를 제공한다. 구체화는 타입 파라미터 정보를 런타임까지 유지한다는 뜻이다.

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

fun <T>TreeNode<T>.cancellableWalkDepthFirst(
    onEach: (T) -> Boolean
): Boolean {
    val nodes = java.util.LinkedList<TreeNode<T>>()
    
    nodes.push(this)
    
    while (nodes.isNotEmpty()) {
        val node = nodes.pop()
        if (!onEach(node.data)) return false
        node.children.forEach { nodes.push(it) }
    }
    return true
}

inline fun <reified T> TreeNode<*>.isInstanceOf() =
    cancellableWalkDepthFirst { it is T }

fun main() {
    val tree = TreeNode<Any>("abc").addChild("def").addChild(123)
    println(tree.isInstanceOf<String>()) // false
    println(tree.cancellableWalkDepthFirst { it is String }) // false
}

 

구체화한 타입 파라미터를 사용하는 해법은 검사를 하지않는 캐스트를 쓰지 않고 코드가 인라인되기 때문에 안전하고 빠르다.

 


2. 변성

변성은 타입 파라미터가 달라질 때 제네릭 타입의 하위 타입 관계가 어떻게 달라지는지를 설명하는 제네릭 타입의 한 측면이다.


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

 

어떤 제네릭 타입은 타입 인자 사이의 하위 타입 관계를 그대로 유지하고 어떤 타입은 그렇지 못한다. 이런 구분은 어떤 제네릭 타입이 자신의 타입 파라미터를 취급하는 방법에 달려있다.

 

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

  1. T 타입의 값을 반환하는 연산만 제공하고 T 타입의 값을 입력으로 받는 연산은 제공하지 않는 제네릭 타입인 생성자
  2. T 타입의 값을 입력으로 받기만 하고 결코 T 타입의 값을 반환하지는 않는 제네릭 타입인 소비자
  3. 위 두 가지 경우에 해당하지 않는 나머지 타입들

생산자도 소비자도 아닌 타입의 경우 하위 타입 관계를 유지할 수 없다. (타입 안전성을 깨지 않고는)

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

위 예제와 같이 이런 대입을 허용하면 TreeNode<String>에 정숫값을 넣을 수 있게 되므로 오류가 발생한다.

 

이 때문에 기본적으로 모든 제네릭 클래스는 무공변이다. 또한, 내장 Array클래스나 가변 컬렉션 클래스는 모두 무공변이다. 무공변이라는 말은 타입 파라미터에서 하위 타입 관계가 성립해도 제네릭 타입 사이에는 하위 타입 관계가 생기지 않는다는 뜻이다.

 

반대로  Pair, Triple, Iterable, Iterator 등과 같은 대부분의 내장 불변 타입은 공변적이다.

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

 

 

공변성과 불변성은 같지 않다. 공변성은 단지 T를 입력으로 사용하지 못하게 방지할 뿐이며 가변 타입도 공변적으로 만들 수 있다.

interface NonGrowingList<T> {
    val size: Int
    fun get(index: Int): Int
    fun remove(index: Int)
}

위의 타입은 가변적이지만 NonGrowingList<String>은 NonGrowingList<Any>가 할 수 있는 일을 모두 할 수 있기 때문에 공변적으로도 동작한다.

 

반대로 불변 객체를 표현하는 타입이 공변적이지 않을 수도 있다.

interface Set<T> {
    fun contains(element: T): Boolean
}

위의 타입은 불변적이지만 생산자가 아니기 때문에 T의 하위 타입 관계를 유지하지 않는다.

Set<T>의 T를 바꾼 두 가지 타입인 Set<Int>와 Set<Number>를 보면, Set<T>의 계약은 contains() 함수에 의해 T타입인 원소를 처리할 수 있어야 한다. Set<Number>와 Set<Int>는 아무 Number, Int값을 처리할 수 있지만, Int는 Number의 하위 타입이다. 따라서 Set<Number>는 아무 Int나 처리할 수 있다. 그러므로 Set<Number>는 Set<Int>의 하위 타입처럼 동작하며 이를 반공적(contravariant)이라고 말한다.

반응형