개발/코틀린

(코틀린) 제네릭스

DinoDev 2022. 11. 12. 17:45
728x90
반응형

제네릭 선언을 정의하고 사용하는지 보고 런타임 타입 소거(type erasure)와 구체화(reification)로 인해 제네릭스 사용 시 어떤 문제가 생기는지 보고 같은 제네릭 타입에 대해 타입 파라미터 값이 달라지는 경우까지 하위 타입 관계를 확장함으로써 제네릭스의 유연성을 향상시켜주는 중요한 개념인 변성(variance)에 대해 알아봅니다.

타입 파라미터

제네릭 선언

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

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

    // 컴파일러가 문맥에서 타입 인자를 추론할 수 있으면 생략 가능
    val map2: HashMap<Int, String> = HashMap()
    val list2: ArrayList<String> = arrayListOf()

    // 전달된 인자로 타입 추론 가능
    val map3 = mapOf(1 to "one")
    val list3 = arrayListOf("abc", "def")
}

어떤 값을 저장하는 트리를 표현하는 클래스를 만들어 봅니다.

클래스의 타입 파라미터를 각 괄호 안에 써야하는데 타입 파라미터는 클래스 이름 바로 뒤에 옵니다.

클래스 안에서는 타입 파라미터를 변수나 프로퍼티, 함수의 타입이나 다른 제네릭 선언의 타입 인자로 사용할 수 있습니다.

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<T> {
        return TreeNode(data).also {
            _children += it
            it.parent = this
        }
    }

    override fun toString(): String {
        return _children.joinToString(prefix = "$data {", postfix = "}")
    }
}

fun main() {
    val root = TreeNode("Hello").apply {
        addChild("World")
        addChild("!!!")
    }

    println(root) // Hello {World {}, !!! {}}
}

제네릭 클래스의 생성자를 호출 할 때는 타입 인자가 불필요한 경우가 있지만 상위 클래스 생성자에 대한 위임 호출은 필수 입니다.

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

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

// 타입 인자를 상위 타입의 타입 인자로 넘김
class TreeNode<T>(data: T) : DataHolder<T>(data)

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

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<T> {
        return TreeNode(data).also {
            _children += it
            it.parent = this
        }
    }

    override fun toString(): String {
        return _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 {
        addChild("World")
        addChild("!!!")
    }

    println(root.depth) // 2
}

클래스 멤버 프로퍼티에 타입 파라미터를 가질 수 없고, object에도 가질 수 없습니다. 

// Type parameter of a property must be used in its receiver type
var <T> root: TreeNode<T>? = null
// Type parameters are not allowed for objects
object EmptyTree<T>

바운드와 제약

기본적으로 타입 인자로 들어갈 수 있는 타입에는 아무런 제약이 없습니다. 그래서 사용할 때는 타입 파라미터의 인자는 Any?로 취급됩니다. 하지만 내부 구현에서 조금 더 디테일한 타입이 필요한 경우에는 타입 파라미터의 상위 바운드(upper bound)가 필요합니다.

fun <T : Number> TreeNode<T>.average(): Double {
    var count = 0
    var sum = 0.0
    walkDepthFirst {
        count++
        sum += it.toDouble()
    }
    return sum / count
}

fun main() {
    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 {
        addChildren("World", "!!!")
    }
    // Unresolved reference.
    // None of the following candidates is applicable because of receiver type mismatch:
    // public fun <T : Number> TreeNode<TypeVariable(T)>.average(): Double defined in root package in file Main.kt
    println(stringTree.average())
}

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

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")
    }
    println(stringTree.maxNode().data) // xyz
}

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

fun <T, U : T> TreeNode<U>.toList(): List<T> {
    return mutableListOf<T>().apply {
        walkDepthFirst(this::add)
    }
}

fun main() {
    TreeNode(1).apply {
        addChild(2)
        addChild(3)
    }.toList()
}

타입 파라미터에 여러 제약을 해야하는 경우가 있는데 where 키워드를 사용하면 가능합니다.

interface Named {
    val name: String
}

interface Identified {
    val id: String
}

class Registry<T> where T : Named, T : Identified {
    val items = ArrayList<T>()
}

타입 소거와 구체화

타입 파라미터를 실제 구현부분에서 타입검사를 하거나 타입캐스팅해서 사용해야 하는 경우가 있는데 이런 경우에는 reified 키워드를 사용합니다.

fun <T> TreeNode<T>.cancellableWalkDepthFirst(
    onEach: (T) -> Boolean,
): Boolean {
    val nodes = 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
}

변성

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

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

제네릭 클래스와 인터페이스의 타입 파라미터를 다른 타입 인자로 대치함으로써 무한히 많은 타입을 만들 수 있습니다.

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

무공변은 타입 파라미터에서 하위 타입 관계가 성립해도 제네릭 타입 사이에는 하위 타입 관계가 생기지 않는다는 뜻입니다.

Array나 가변 컬렉션 클래스, TreeNode 클래스 모두 무공변입니다. 그래서 TreeNode<String>이 TreeNode<Any>의 하위 타입으로 간주되지 않습니다.

val node: TreeNode<Any> = TreeNode<String>("Hello") // Error: type mismatch

반대로 불변 컬렉션 같은 제네릭 타입은 타입 인자 사이의 하위 타입 관계가 제네릭 타입에서도 유지 됩니다.

 

모든 제네릭 타입은 세가지로 구분 됩니다.

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

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

3. 위 두 가지 경우에 해당하지 않는 나머지 타입들

마지막 그룹에 있는 일반적인 타입(생산자도 소비자도 아닌 타입)의 경우 타입 안전성을 깨지 않고는 하위 타입 관계를 유지할 수 없습니다.

 

List<T> 같은 불변 컬렉션 타입은 add와 같이 값을 소비하는 함수는 없고 값을 반환하는 생산만 하기 때문에 공변성을 유지 할 수 있습니다. 코틀린에서는 생산자 역할을 하는 타입은 모두 공변적입니다.

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

하지만 공변성이 불변성은 아닙니다. T 타입을 파라미터로 받지않고 index를 통해서 원소를 삭제하는 클래스가 있다고 가정하면 이런 경우에도 공변성은 성립합니다.

반대로 불변 객체가 무조건 공변은 아닙니다. T 타입을 파라미터로 받아서 값을 비교하고 boolean을 리턴한다고 하면 이런 경우는 공변성이 성립하지 않습니다.

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

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

Set<Int>와 Set<Number> 두개의 타입을 보면 Int는 Number의 하위 타입이라서 Set<Number>는 Int 값을 처리할 수 있고 Set<Number>는 Set<Int>의 하위 타입처럼 동작합니다. 이런 경우를 반공변적(contravariant)라고 말하며 코틀린에서는 T를 반공변적이라고 선언함으로써 이런 식의 하위 타입 관계를 지정할 수 있습니다.

fun main() {
    val anyConsumer: (Any) -> Unit = { println(it) }
    val stringConsumer: (String) -> Unit = anyConsumer
    stringConsumer("Hello") // Hello
}
  • X가 생산자 역할을 하면 T를 공변적으로 선언할 수 있고 A가 B의 하위 타입이면 X<A>도 X<B>의 하위 타입이 됩니다.
  • X가 소비자 역할을 하면 T를 반공변적으로 선언할 수 있고 B가 A의 하위 타입이면 X<A>가 X<B>의 하위 타입이 됩니다.
  • 나머지 경우는 X는 T에 대해 무공변입니다.

선언 지점 변성

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

List의 타입 파라미터인 T가 무공변이라서 List<Int>는 List<Number>의 하위 타입이 아닌 것으로 간주 됩니다.

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): T {
        return items[index]
    }
}

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))
        }
    }
}

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

타입파라미터를 공변성을 가지게 하려면 T앞에 out을 붙입니다.

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

하지만 타입 파라미터를 인자로 받는 함수가 존재하면 에러가 발생합니다.

interface MutableList<out T> : List<T> {
    fun set(index: Int, value: T) // Type parameter T is declared as 'out' but occurs in 'in' position in type T
}

반공변성을 가지게 하려면 T앞에 in을 붙입니다.

class Writer<in T> {
    fun write(value: T) {
        println(value)
    }
    
    fun writeList(values: Iterable<T>) {
        values.forEach { println(it) }
    }
}

fun main() {
    val numberWriter = Writer<Number>()
    val integerWriter: Writer<Int> = numberWriter
    
    integerWriter.write(100) // 100
}

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

변성을 지정하는 다른 방법으로 제네릭 타입을 사용하는 위치에서 특정 타입 인자 앞에 in/out을 붙이는 방법이 있습니다. 이런 방식을 프로젝션이라고 합니다.

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<T> {
        return TreeNode(data).also {
            _children += it
            it.parent = this
        }
    }

    override fun toString(): String {
        return _children.joinToString(prefix = "$data {", postfix = "}")
    }
}

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 {}}
    
    val numberRoot = TreeNode<Number>(123)
    val intSubRoot = TreeNode<Int>(456)
    numberRoot.addSubtree(intSubRoot) // Error: type mismatch
}

위 같은 경우에는 numberRoot에 intSubRoot를 addSubtree()로 넣으려고 할 때 타입이 맞지 않다고 나옵니다.

addSubtree에서는 지금 무공변이기 때문에 공변성으로 만들어줘야 합니다.

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 numberRoot = TreeNode<Number>(123)
    val intSubRoot = TreeNode<Int>(456)
    numberRoot.addSubtree(intSubRoot)
}

반대로 파라미터로 받은 TreeNode에 값을 추가하는 경우에는 out 프로젝션을 사용하면 에러가 발생합니다.

fun processOut(node: TreeNode<out Any>) {
    node.addChild("xyz") // Type mismatch. Required: Nothing, Found: String
}

이런 경우에는 in 프로젝션을 통해서 타입을 소비자로만 만들 수 있습니다.

fun processOut(node: TreeNode<in Any>) {
    node.addChild("xyz") // ok
}

스타 프로젝션

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

코틀린 타입 파라미터는 상위 바운드만 허용하기 때문에 타입 인자에 스타 프로젝션을 사용하면 타입 인자가 해당 타입 파라미터를 제한하는 타입의 하위 타입 중 어떤 타입이든 상관없게 됩니다.

즉 스타 프로젝션은 out 프로젝션을 타입 파라미터 바운드에 적용한 것과 같이 동작합니다.

fun main() {
    val anyList: List<*> = listOf(1, 2, 3)
    val anyComparable: Comparable<*> = "abcde"
}

Any?를 다른 타입으로 치환하려고 하면 타입 소거로 인해 타입 체크가 불가능해지므로 컴파일 오류가 발생합니다.

fun main() {
    val any: Any? = ""
    any is List<out Number> // Cannot check for instance of erased type: List<out Number> 
}

또한 스타 프로젝션은 프로젝션 타입이 in/out인지에 따라 타입이 다릅니다.

interface Consumer<in T> {
    fun consume(value: T)
}

interface Producer<out T> {
    fun produce(): T
}

fun main() {
    val starConsumer: Consumer<*> // Consumer<Nothing>과 같음
    val starProducer: Producer<*> // Producer<Any?>와 같음
}

타입 별명

기존 타입의 이름을 대신해서 새로운 이름을 부여할 수 있는 방법입니다.

typealias 키워드를 사용해서 사용합니다.

typealias IntPredicate = (Int) -> Boolean
typealias IntMap = HashMap<Int, Int>

fun readFirst(filter: IntPredicate) =
    generateSequence { readLine()?.toIntOrNull() }.firstOrNull(filter)

fun main() {
    val map = IntMap().also {
        it[1] = 2
        it[2] = 3
    }
}

// sealed class도 가능
sealed class Status {
    object Success : Status()
    class Error(val message: String) : Status()
}

typealias StSuccess = Status.Success
typealias StError = Status.Error

// 제네릭도 가능
typealias ThisPredicate<T> = T.() -> Boolean
typealias MultiMap<K, V> = Map<K, Collection<V>>
728x90
반응형