본문 바로가기

코틀린

[코틀린] 제네릭

 

안드로이드 개발하는 kancho입니다.

이번 포스팅에서는 코틀린의 제네릭(Generics)에 대해 알아보고자 합니다.

'코틀린 완벽 가이드' 책을 참고하였습니다.

 

 

제네릭(Generics)

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

알지 못하는 타입의 데이터를 조작하는 코드를 작성할 수 있게 해 준다.

타입 안정성을 높이면서 다양한 타입에서 코드를 작성할 수 있다.

 

 

선언

하나 이상의 타입 파라미터가 요구된다.

타입 파라미터는 선언 내부에서 일반적인 타입 대신 사용할 수 있다.

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

 

// HashMap. K 타입의 key, V 타입의 value
public actual typealias HashMap<K, V> = java.util.HashMap<K, V>

// ArrayList. 타입 T 파라미터
public inline fun <T> arrayListOf(): ArrayList<T> = ArrayList()

// instance 생성 시 Int, String 타입을 지정
val map = HashMap<Int, String>()

// instance 생성 시 String 타입 지정
val list = arrayListOf<String>()

// 타입 인자 생략. 변수의 타입을 명시해 Int, String 타입을 추론할 수 있다
val typeInfMap: Map<Int, String> = HashMap()

 

 

자바 vs 코틀린

자바 (다이아몬드 연산자 <> 사용) -> 제네릭이 추가되기 전(Java 5) 코드와 하위 호환성 유지하기 위해
Map<Int, String> map = new HashMap<>()

코틀린
val map: Map<Int, String> = HashMap()

 

 

기본적인 제네릭 사용 예제

 

/*
* 주어진 타입의 값을 저장할 수 있는 트리를 표현하는 클래스
* 클래스의 타입 파라미터를 클래스 이름 바로 뒤 각괄호 안에 쓴다
* 관습적으로 T, S, U, V 등의 대문자를 사용한다
* 타입 파라미터를 변수, 프로펕, 함수의 타입이나 다른 제네릭 선언의 타입 인자로 사용 가능
* 제네릭 클래스 생성자를 호출할 때 타입 인자 추론
*/
class TreeNode<T>(private val data: T) {
    private val _child = arrayListOf<TreeNode<T>>()
    val child: List<TreeNode<T>>
        get() = _child
    var parent: TreeNode<T>? = null
        private set

    fun addChild(data: T) = TreeNode(data).also {
        _child += it
        it.parent = this
    }

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

fun main() {
    // 타입 인자 추론 (String)
    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) { }

 

 

컴파일러가 DataHolder 클래스에 대해 하나의 타입 인자가 예상되어 생성자 위임 호출의 타입 인자를 추론하지 못한다.

 

// error : One type argument expected for class DataHolder<T>
class StringDataHolder(data: String) : DataHolder(data)

// OK. 컴파일러가 타입을 추론
fun stringDataHolder(data: String) = DataHolder(data)

 

 

또한 제네릭에서는 타입 파라미터를 상속하지 않고 타입 파라미터를 상위 타입의 타입 인자로 전달해야 한다.

 

// DataHolder의 T와 TreeNode의 T는 서로 다른 선언이다
open class DataHolder<T>(val data: T)
class TreeNode<T>(data: T) : DataHolder<T>(data) { }

// DataHolder와 TreeNode가 다른 타입 파라미터 이름을 사용해도 된다
open class DataHolder<T>(val data: T)
class TreeNode<U>(data: U) : DataHolder<U>(data) { }

 

 

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

이런 경우 타입 파라미터를 fun 키워드나 val/var 키워드 바로 뒤에 위치시켜야 한다.

 

// 함수를 제네릭으로 생성
fun <T> TreeNode<T>.addChildren(vararg data: T) {
    data.forEach { addChild(it) }
}

// 프로퍼티를 제네릭으로 생성
val <T> TreeNode<T>.depth: Int
    get() = (child.asSequence().map { it.depth }.maxOrNull() ?: 0) + 1

fun main() {
    val root = TreeNode("Hello").apply {
        addChildren("World", "!!!")
    }
    println(root.depth)	// 2
}

 

 

클래스 멤버 프로퍼티와 확장 프로퍼티, object에 대한 제네릭 사용

 

/*
 * error : 클래스 멤버 프로퍼티는 타입 파라미터를 가질 수 없다
 * 확장 프로퍼티만 타입 파라미터를 가질 수 있다
*/ 일반 프로퍼티는 한 값만 제공하고 타입 인자에 따라 여러 값을 제공할 수 없기 때문이다
val <T> root: TreeNode<T>? = null

// 객체(object) 선언에 타입 파라미터 사용 X
object EmptyTree<T>

// 프로퍼티 참조는 타입 인자를 지원하지 않는다
// 제네릭 프로퍼티인 depth의 타입 파라미터는 수신객체 타입으로부터 추론되어야 한다
val minDepth = TreeNode("").depth<String>

 

 

 

바운드와 제약

 

타입 인자에는 타입에 대한 아무런 제약이 없고 Any? 타입과 비슷하게 처리된다.

데이터 타입의 더 많은 정보를 알기 위해 타입 파라미터의 상위 바운드(upper bound)를 사용한다.

 

타입 파라미터에 upper bound가 있으면 컴파일러는 타입 파라미터에 공급된 타입 인자의 타입이 상위 바운드의 하위 타입인지 검사한다.

Any? 가 디폴트 상위 바운드이다. 이런 경우 모든 코틀린 타입을 타입 인자로 받을 수 있다.

 

fun <T> TreeNode<T>.walkDepthFirst(action: (T) -> Unit) {
    child.forEach { it.walkDepthFirst(action) }
    action.invoke(data)
}

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

// 타입 파라미터 바운드로 타입 파라미터를 사용할 수 있다. 재귀적 타입 파라미터
fun <T : Comparable<T>> TreeNode<T>.maxNode(): TreeNode<T> {
    val maxChild = child.maxByOrNull { it.data } ?: return this
    return if (data >= maxChild.data) this else maxChild
}

// 바운드가 자신보다 앞에 있는 타입 파라미터를 가리킬 수도 있다.
// U가 T의 하위 타입
fun <T, U : T> TreeNode<U>.toList(list: MutableList<T>) {
    walkDepthFirst { list += it }
}

// 타입 파라미터를 널이 아닌 타입으로 제한. 상위 바운드로 널이 될 수 없는 타입 지정해야 함
fun <T: Any> notNullTreeOf(data: T) = TreeNode(data)

fun <T> TreeNode<T>.addChildren(vararg data: T) {
    data.forEach { addChild(it) }
}

val <T> TreeNode<T>.depth: Int
    get() = (child.asSequence().map { it.depth }.maxOrNull() ?: 0) + 1

// Int, Double은 Number의 하위타입이기 때문에 OK
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
    // Double은 Comparable<Double>의 하위 타입
    println(doubleTree.maxNode().data)  // 3.0
    
    val stringTree = TreeNode("Hello").apply { 
        addChildren("World", "!!!")
    }
    // error : 리시버 타입이 다르기 때문에 참조할 수 없다
    println(stringTree.average())
    // Ok : String은 Comparable<String>의 하위 타입
    println(stringTree.maxNode().data)  // World
    
    // 상위 바운드로 final 클래스 사용 X. 한 가지 타입만 미리 지정되어 있어서
    fun <T: Int> TreeNode<T>.sum(): Int { ... }
    
        val list = ArrayList<Number>()
    
    // Int 트리의 원소들을 Number 타입 리스트에 추가 가능 
    TreeNode(1).apply { 
        addChild(2)
        addChild(3)
    }.toList(list)

    // Double 트리의 원소들을 Number 타입 리스트에 추가 가능
    TreeNode(1.0).apply { 
        addChild(2.0)
        addChild(3.0)
    }.toList(list)
}

 

 

복잡한 타입 제약 구문

 

타입 파라미터 구문은 상위 바운드를 하나만 지정할 수 있다.

여러 제약을 위해서는 복잡한 타입 제약 구문을 사용하면 된다.

 

interface Named {
    val name: String
}

interface Identified {
    val id: Int
}

// where 선언 후 바운드할 타입 목록 표시
class Registry<T> where T : Named, T : Identified {
    val items = ArrayList<T>()
}

 

 

타입 소거와 구체화

타입 파라미터가 항상 실제 타입을 대신할 수 있는 것은 아니다.

 

자바 Generics가 자바 5부터 도입
자바 컴파일러와 JVM은 하위 호환성을 위해 기존 타입 표현 방식을 유지
JVM에서 타입 인자에 대한 정보는 코드에서 삭제 
List<String>, List<Number> 같은 타입은 모두 JVM에서 List 타입으로 합쳐진다

 

is 연산자의 의미가 없다.

 

fun <T> TreeNode<Any>.isInstanceOf(): Boolean =
    // error : Cannot check for instance of erased type: T
    data is T && child.all { it.isInstanceOf<T>() }
    
fun main() {
    val list = listOf(1, 2, 3)		// List<Int>
    println(list is List<Number>)	// always true

    // error : Cannot check for instance of erased type: List<String>
    list is List<String>
}

 

 

타입은 관심이 없고 변수의 값이 무엇인지 확인하고 싶다면 아래 코드와 같이

알지 못하는 타입을 의미하는 *(projection)를 사용한다. 

 

val list = listOf(1, 2, 3)
list is List<*>

val map = mapOf<Int, Int>()
map is Map<*, *>


// 어떤 값을 * 가 아닌 제네릭 타입으로 캐스트하는 것이 허용되지만 위험하다
// 런타임까지 실제 타입 오류를 미루는 위험이 있다
val n = (listOf(1, 2, 3) as List<Number>)[0]	// Ok
// Int 값을 String 타입의 정적 변수에 대입하기 때문에 오류가 생긴다
val s = (listOf(1, 2, 3) as List<String>)[0]    // ClassCastException

 

 

자바에서는 캐스트를 활용하거나 리플렉션(reflection)을 통해 타입 소거를 우회해야 한다.

캐스트는 컴파일이 되지만 나중에 오류가 발생할 수 있다.

리플렉션을 사용하면 성능에 영향이 있다.

 

코틀린은 이 두 가지 약점을 극복할 수 있는 방법을 제공한다.

바로 구체화다.

 

구체화는 타입 파라미터 정보를 런타임까지 유지한다는 의미이다.

인라인 한 함수에 대해서만 구체화한 타입 파라미터를 쓸 수 있다. 

함수 본문을 호출 위치로 인라인 시키기 때문에 컴파일러가 인라인 된 함수에 제공되는 타입 인자의 실제 타입을 항상 알 수 있게 한다.

파라미터를 구체화하려면 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.child.forEach {
            nodes.push(it)
        }
    }

    return true
}

// 인라인되지 않은 cancellableWalkDepthFirst 함수를 사용
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>())
    
    // 컴파일러는 위 코드 inInstanceOf()를 인라인해서 T 대신 실제 타입인 String을 넣는다
    println(tree.cancellableWalkDepthFirst { it is String })
}

 

구체화 방법은 안전하고 빠르다.

하지만 인라인 함수를 사용해서 코드의 크기는 커지고

인라인 함수 안에서만 구체화한 타입 파라미터를 쓸 수 있기 때문에 구체화한 타입을 클래스나 프로퍼티와 함께 쓸 수는 없다.

 

 

이제까지 기본적인 코틀린 제네릭스에 대해 알아보았다.

다음은 변성에 대한 내용이다.

 

변성은 타입이 생산자와 소비자 중에서 어떤 역할을 하는지를 제어함으로써 더 유연하게 제네릭 타입을 선언할 수 있게 해 준다.

 

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

예를 들어, String은 Any의 하위 타입이지만, Array<String>은 Array<Any>의 하위 타입으로 간주되지 않는다. 하지만 List나 Set 같은 불변 컬렉션의 경우 타입 파라미터의 하위 타입 관계가 컬렉션 타입에서도 유지된다. List<String>은 List<Any>의 하위 타입이다.

 

변성을 잘 사용하면 타입 안정성을 해치지 않으면서 유연성을 향상할 수 있다.

 

 

변성

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

디폴트로 어떤 제네릭 타입의 타입 인자를 서로 다른 타입으로 대치한 타입들은 서로 하위 타입 관계가 없는 것으로 간주된다.

 

무공변(invariant)

 

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

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

(Array 클래스, mutable 컬렉션 등)

 

공변(variant)

 

타입 파라미터의 상 하위 타입 관계에 따라 제네릭 타입의 상하위 타입 관계가 함께 변하는 것이다.

이런 경우 타입 파라미터의 상하위 타입 관계에 따라 제네릭 타입의 상하위 타입 관계가 정해진다.

(immutable 컬렉션 등)

 

 

Why?

제네릭 타입은 타입 인자 사이의 하위 타입 관계를 그대로 유지하고 어떤 타입은 그렇지 못할까?

 

제네릭 타입의 타입 파라미터(T)

  1. T 타입의 값을 반환하는 연산만 제공하고 T 타입의 값을 입력으로 받는 연산은 제공하지 않는 제네릭 타입인 생산자
  2. T 타입의 값을 입력으로 받기만 하고 결코 T 타입의 값을 반환하지는 않는 제네릭 타입인 소비자
  3. 그 외 타입들

마지막 그룹(3번) 타입의 경우 타입 안전성을 깨지 않고는 하위 타입 관계를 유지할 수 없다.

 

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

TreeNode<String>을 TreeNode<Any>에 대입할 수 있다고 해보자.

fun main() {
    val stringNode = TreeNode<String>("Hello")
    val anyNode: TreeNode<Any> = stringNode
    anyNode.addChild(123)
    // error
    val s = stringNode.child.first()
}

 

stringNode의 child로는 string, int 값이 들어갈 수 있게 되어 first() 메서드를 호출할 때 TreeNode<String>의 계약을 위반한다.

TreeNode<Any> 타입의 값은 어떤 타입의 값이든 자식으로 받을 수 있다.

TreeNode<String>은 String 타입의 값만 자식으로 받을 수 있다. 따라서 TreeNode<String>은 TreeNode<Any>의 하위 타입이 될 수 없다.

 

 

코틀린 배열 타입이 자바 배열가 달리 원소의 하위 타입 관계를 그대로 유지하지 않는 이유는

런타임 오류인 ArrayStore Exception 때문이다.

 

 

불변 컬렉션 타입은 왜 배열과 변성이 다를까?

 

불변 컬렉션에는 addChild 같은 함수가 없기 때문이다. T 타입의 값을 만들어내기만 할 뿐 소비하지 않는다.

그래서 List<Any>는 Any 타입의 값을 돌려주고 List<String>은 String 타입의 값을 돌려준다.

 

String이 Any의 하위 타입이기 때문에 List<String>은 자동적으로 Any 타입의 값을 돌려줄 수도 있게 된다. 즉 컴파일러가 List<String>이 type-safe 하게 List<Any> 대신 쓰일 수 있도록 허용할 수 있다는 것이다.

이를 제네릭 타입이 타입 인자에 대해 공변적이라고 한다.

 

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

 

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

또한 함수 타입은 반환 타입에 대해 공변적이다.

 

fun main() {
    val intPair: Pair<Int, Int> = Pair(1, 2)
    val anyPair: Pair<Any, Any> = intPair
    println("${intPair.first}, ${intPair.second}")  // 1, 2

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

 

 

하지만 공변성이 불변성과 같지는 않다.

 

공변성은 단지 T를 입력으로 사용하지 못하게 방지할 뿐이다.

가변 타입을 공변적으로 만들 수도 있다.

 

아래 코드는 가변 타입이지만 공변적으로 동작한다. 

NonGrowingList<String>은 NonGrowingList<Any>가 할 수 있는 일을 모두 할 수 있다.

 

// 원소를 삭제할 뿐 추가할 수 없는 리스트 인터페이스
interface NonGrowingList<T> {
    val size: Int
    fun get(index: Int): Int
    fun remove(index: Int)
}

 

 

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

 

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

Set<Any>는 아무 값이나 입력받을 수 있지만 Set<String>은 문자열만 입력으로 받을 수 있다.

 

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

 

 

소비자와 같은 타입은 어떨까?

 

소비자 역할을 하는 타입은 타입 파라미터의 하위 타입 관계를 유지해주지 못하지만

타입 파라미터의 하위 타입 관계를 역방향으로 유지해준다.

 

Set<Int>와 Set<Number>를 보면

Int는 Number의 하위 타입이고 Set<Number>는 아무 Int나 처리할 수 있다. 

즉, Set<Number>는 Set<Int> 하위 타입처럼 동작한다.

이런 경우를 반공변적(contravariant)이라고 한다.

 

예를 들어, 함수 타입은 인자 타입에 대해 반공변적이다.

 

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

 

즉, 어떤 주어진 제네릭 타입 X<T, ...>이 타입 파라미터 T에 대해

  1. X가 생산자 역할을 한다. 이 경우 T를 공변적으로 선언할 수 있고, A가 B의 하위 타입이면 X<A>도 X<B>의 하위 타입이 된다.
  2. X가 소비자 역할을 한다. 이 경우 T를 반공변적으로 선언할 수 있고, B가 A의 하위 타입이면 X<A>가 X<B>의 하위 타입이 된다.
  3. 나머지 경우, X는 T에 대해 무공변이다.

 

변성의 표현

코틀린에서는 타입 파라미터의 변성을 선언 자체에 지정하거나 타입 인자를 치환하는 사용 지점에서 지정할 수 있다.

 

1. 선언 지점 변성

2. 프로젝션

 

 

선언 지점 변성

 

// 단순화한 List 타입의 interface
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 = items.size
    override fun get(index: Int): T = items[index]
}

// 리스트 2개를 받아 모든 원소를 담은 리스트를 돌려주는 함수
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 =
        if (index < list1.size)
            list1.get(index)
        else
            list2.get(index - list1.size)
}

// numbers, integers 리스트가 타입 파라미터 T에 대해 무공변이다
// List<Int>는 List<Number>의 하위 타입이 아닌 것으로 간주된다
fun main() {
    val numbers = ListByArray<Number>(1, 2.5, 3f)
    val integers = ListByArray<Int>(10, 30, 30)
    val result = concat(numbers, integers)  // error : Type mismatch
}

 

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

따라서 이 타입은 안전하게 T에 대해 공변적이 될 수 있다.

 

out 키워드 사용

 

컴파일러가 List<Int>가 List<Number>의 하위 타입이라는 사실을 이해한다.

 

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

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

 

 

가변인 MutableList 인터페이스

 

interface MutableList<T> : List<T> {
    fun set(index: Int, value: T)
}

 

MutableList의 T를 공변적으로 만들면 오류가 난다.

T 타입의 값을 입력으로 받아 소비자처럼 동작하는 set 함수로 인해 발생하기 때문이다.

 

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

 

 

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

out 위치는 기본적으로 값을 만들어내는 위치다.

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

 

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

 

 

in 위치는 값을 함수 인자나 제네릭 타입의 반공변 타입 인자로 소비하는 경우를 뜻한다.

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

 

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

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

 

class Writer<in T> {
    // 함수 인자로 쓰임
    fun write(value: T) { 
        println(value) 
    }
    
    // in 위치에 사용된 Iterable 제네릭 타입의 out 위치 인자로 T를 사용
    // 이런 경우 위치가 in 위치로 인정
    fun writeList(values: Iterable<T>) {
        values.forEach { println(it) }
    }
}

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

 

 

프로젝션

 

제네릭 타입을 사용하는 위치에서 특정 타입 인자 앞에 in/out을 붙이는 방법이다.

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

 

fun <T> TreeNode<T>.addSubTree(node: TreeNode<T>): TreeNode<T> {
    val newNode = addChild(node.data)
    node.child.forEach { newNode.addSubTree(it) }
    return newNode
}

// root, subRoot 트리의 원소 타입이 서로 같은 타입일 때만 작동한다
fun main() {
    val root = TreeNode("abc")
    val subRoot = TreeNode("def")
    root.addSubTree(subRoot)
    println(root)   // abc {def {}}
}

 

 

하지만 Int 트리를 Number 트리에 추가하고 싶은 경우에는?

 

Int가 Number의 하위 타입이기 때문에 잘 정의된 연산이다. 하지만 TreeNode<T>는 무공변 타입이므로 addSubtree() 함수에서 인자 타입과 수신 객체 타입이 똑같이 T라고 쓸 수밖에 없다.

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

 

fun main() {
    val root = TreeNode<Number>(123)
    val subRoot = TreeNode(456.7)
    root.addSubTree(subRoot)    // error: type mismatch
}

 

TreeNode<T> 타입에는 T 타입의 값을 돌려주는 멤버와 addChild 함수처럼 T 타입의 값을 입력으로 사용하는 멤버가 모두 있기 때문에 TreeNode의 타입 자체는 무공변으로 남을 수 밖에 없다. 

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

이를 통해 필요한 타입 인자를 out으로 표시하면 우리가 원하는 목표를 달성할 수 있다.

 

fun <T> TreeNode<T>.addSubTree(node: TreeNode<out T>): TreeNode<T> {
    val newNode = addChild(node.data)
    node.child.forEach { newNode.addSubTree(it) }
    return newNode
}

fun main() {
    val root = TreeNode<Number>(123)
    val subRoot = TreeNode(456.7)
    root.addSubTree(subRoot)
    println(root)   // 123 {456.7 {}}
}

 

 

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

프로젝션인 out T는 TreeNode의 실제 타입 인자를 알지는 못하지만 이 타입 인자가 T의 하위 타입이어야만 한다는 뜻이다. 

TreeNode<out T>를 TreeNode<T>에 속하지만 T에 대해 생산자 역할만 하는 연산만 노출시킨 타입이라고 생각할 수도 있다.

 

TreeNode 클래스의 addChild 함수나 확장 함수인 addChildren 처럼 소비자 역할을 하는 연산이 포함되어도 사용할 수는 없다.

 

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

 

 

in 프로젝션을 통해 타입을 소비자로만 사용하게 할 수도 있다.

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

 

// 트리 추가 함수
fun <T> TreeNode<T>.addTo(parent: TreeNode<in T>) {
    val newNode = parent.addChild(data)
    child.forEach {
        it.addTo(newNode)
    }
}

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

 

 

스타 프로젝션

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

 

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

 

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

 

fun main() {
    // List의 원소 타입은 Any?에 의해 제한되어 아무 타입이나 가능하다
    val anyList: List<*> = listOf(1, 2, 3)
    
    // 자기 자신과 비교 가능한 아무 객체나 가능
    val anyComparable: Comparable<*> = "abcde"
}

 

 

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

 

'코틀린' 카테고리의 다른 글

[코틀린] DSL (도메인 특화 언어)  (0) 2022.11.27
[코틀린] Annotation  (0) 2022.11.21
[코틀린] 상속(Inheritance)  (0) 2022.11.06
[코틀린] Inline 클래스  (0) 2022.10.23
[코틀린] Data 클래스  (1) 2022.10.23