본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 35. Using builders with builder type inference

서른다섯 번째, 빌더 타입 추론과 함께 빌더 사용하기입니다.

 

Kotlin은 빌더 타입 추론(또는 빌더 추론)을 지원하는데, 이는 제네릭 빌더와 함께 사용할 때 유용합니다. 빌더 추론은 람다 인수 안의 다른 호출에 대한 타입 정보에 기반하여 빌더 호출의 타입 인수를 추론할 수 있도록 컴파일러를 돕습니다.

 

buildMap()의 사용 예를 살펴 보겠습니다.

fun addEntryToMap(baseMap: Map<String, Number>, additionalEntry: Pair<String, Int>?) {
   val myMap = buildMap {
       putAll(baseMap)
       if (additionalEntry != null) {
           put(additionalEntry.first, additionalEntry.second)
       }
   }
}

 

정규적인 방법으로 타입 인수를 추론할 정보가 부족합니다. 하지만, 빌더 추론은 람다 인수 내부의 호출을 분석할 수 있습니다. putAll()과 put() 호출의 타입 정보에 기반하여, 컴파일러는 자동으로 buildMap() 호출의 타입 인수를 String과 Number로 추론할 수 있습니다. 빌더 추론은 제네릭 빌더를 사용할 때 타입 인수를 생략할 수 있게 해 줍니다.

 

빌더 작성 (Writing your own builders)

빌더 추론 활성화를 위한 요구 사항

Kotlin 1.7.0 이전에는 빌더 함수를 위해 빌더 추론을 활성화 하기 위해서는 -Xenable-builder-inference 라는 컴파일러 옵션이 필요했습니다. 1.7.0에서 이 옵션은 기본적으로 활성화됐습니다.

 

만드는 빌더에 빌더 추론이 적용되도록 하기 위해서는 빌더 선언이 수신자가 있는 함수 타입의 람다 매개변수를 갖도록 해야 합니다. 수신자 타입에는 또한 두 가지 요구 사항이 있습니다.

 

1. 수신자는 빌더 추론이 추론해야 할 타입 인수를 사용해야 합니다. 예를 들면, 다음과 같습니다.

// MutableList는 타입 매개변수 V에 대응하여 넘어오는 타입 인수를 사용합니다.
fun <V> buildList(builder: MutableList<V>.() -> Unit) { ... }

 

※ fun <T> myBuilder(builder: T.() -> Unit) 타입 매개변수의 타입을 직접적으로 전달하는 것은 아직 지원되지 않습니다.

 

2. 수신자의 타입 매개변수를 시그니처에 갖는 public 멤버나 확장 함수를 가져야 합니다.

class ItemHolder<T> {
    private val items = mutableListOf<T>()

    fun addItem(x: T) { // 수신자 타입 ItemHolder의 타입 매개변수 T를 시그니처에 갖는 멤버
        items.add(x)
    }

    fun getLastItem(): T? = items.lastOrNull()
}

// 수신자 타입 ItemHolder의 타입 매개변수 T를 시그니처에 갖는 확장
fun <T> ItemHolder<T>.addAllItems(xs: List<T>) {
    xs.forEach { addItem(it) }
}

fun <T> itemHolderBuilder(builder: ItemHolder<T>.() -> Unit): ItemHolder<T> =
    ItemHolder<T>().apply(builder)

fun test(s: String) {
    val itemHolder1 = itemHolderBuilder { // itemHolder1의 타입은 ItemHolder<String>
        addItem(s)
    }
    val itemHolder2 = itemHolderBuilder { // itemHolder2의 타입은 ItemHolder<String>
        addAllItems(listOf(s))
    }
    val itemHolder3 = itemHolderBuilder { // itemHolder3의 타입은 ItemHolder<String?>
        val lastItem: String? = getLastItem()
        // ...
    }
}

 

지원되는 기능

빌더 추론은 다음 사항들을 지원합니다.

→ 복수의 타입 인수 추론

fun <K, V> myBuilder(builder: MutableMap<K, V>.() -> Unit): Map<K, V> { ... }

 

 상호 의존적인 것을 포함한 하나의 호출에서 복수의 빌더 람다의 타입 인수 추론

fun <K, V> myBuilder(
    listBuilder: MutableList<V>.() -> Unit,
    mapBuilder: MutableMap<K, V>.() -> Unit
): Pair<List<V>, Map<K, V>> =
    mutableListOf<V>().apply(listBuilder) to mutableMapOf<K, V>().apply(mapBuilder)

fun main() {
    val result = myBuilder(
        { add(1) },
        { put("key", 2) }
    )
    // 결과는 Pair<List<Int>, Map<String, Int>> 타입을 갖습니다.
    // listBuilder와 mapBuilder의 타입이 모두 추론됩니다.
}

 

→ 타입 매개변수가 람다의 매개변수이거나 반환 타입인 타입 인수 추론

fun <K, V> myBuilder1(
    // K 타입 매개 변수가 반환 타입으로 지정됐습니다.
    // 즉, K에 넘겨지는 타입 인수가 반환 타입입니다.
    mapBuilder: MutableMap<K, V>.() -> K
): Map<K, V> = mutableMapOf<K, V>().apply { mapBuilder() }

fun <K, V> myBuilder2(
   // 타입 매개변수가 람다의 매개변수로 지정됐습니다.
   // 즉, K에 넘겨지는 타입 인수가 람다의 매개변수 타입이 됩니다.
    mapBuilder: MutableMap<K, V>.(K) -> Unit
): Map<K, V> = mutableMapOf<K, V>().apply { mapBuilder(2 as K) }

fun main() {
    // result1은 추론된 Map<Long, String> 타입을 갖습니다.
    val result1 = myBuilder1 {
        put(1L, "value")
        2
    }
    // result2는 추론된 Map<Int, String> 타입을 갖습니다.
    val result2 = myBuilder2 {
        put(1, "value 1")
        // it를 "연기된 타입 변수" 타입으로서 사용할 수 있습니다.
        // 상세 내용은 밑에서 볼 수 있습니다.
        put(it, "value 2")
    }
}

 

빌더 추론 동작 원리

연기된 타입 변수 (postponed type variables)

빌더 추론은 연기된 타입 변수에 대해서 동작합니다. 연기된 타입 변수는 빌더 추론 분석 작업 중 빌더 람다의 내부에 나타납니다. 연기된 타입 변수는 타입 인수의 타입이며 추론 프로세스 안에 있습니다. 컴파일러는 타입 인수에 대한 타입 정보를 수집하기 위해서 이를 사용합니다.

 

buildList()를 사용하는 예를 살펴 보겠습니다.

/*
inline fun <E> buildList(
    builderAction: MutableList<E>.() -> Unit
): List<E>
*/

val result = buildList {
    val x = get(0)
}

 

여기에서 x는 연기된 타입 변수의 타입을 갖습니다. get() 호출은 타입 E의 값을 반환합니다. 하지만, E 자체는 아직 고정되지(be fixed) 않았습니다. 이 시점에서 E의 구체 타입은 알 수 없습니다.

 

연기된 타입 변수의 값이 구체적인 타입과 연관될 때, 빌더 추론은 빌더 추론 분석 마지막에 타입 인수의 결과적인 타입을 추론하기 위해 이 정보를 수집합니다. 예를 들면 다음과 같습니다.

val result = buildList {
    val x = get(0)
    val y: String = x
} // result는 추론된 List<String> 타입을 갖습니다.

 

연기된 타입 변수가 String 타입의 변수에 할당된 후에, 빌더 추론은 x가 String의 하위 타입이라는 정보를 얻게 됩니다. 이 할당(val y: String = x)이 람다의 마지막 문장(statement)이므로, 빌더 추론 분석은 E를 String으로 추론하고 마치게 됩니다.

 

equals(), hashCode(), toString() 함수는 항상 연기된 타입변수 수신자와 같이 사용할 수 있습니다. 

// 이 코드는 연기된 타입 변수를 확정할 수 없어 컴파일 될 수 없습니다.
val result = buildList {
    val x = get(0)
}

// 이 코드는 컴파일 됩니다.
val result = buildList {
    val x = get(0)
    // Kotlin에서 모든 클래스의 수퍼 클래스는 Any이며, 
    // 이 클래스가 가진 toString(), equals(), hashCode() 메소드는 모두가 갖게 됩니다.
    // 그러므로, 이 것들은 어느 수신자 객체에 대해서도 호출할 수 있습니다.
    // 그러므로, 여기서 x에 대해 hashCode()를 호출할 수 있으며
    // 이 지점에서 추론된 연기된 타입 변수의 타입은 Any입니다.
    x.hashCode() 
}

// 이 코드는 컴파일 됩니다.
val result = buildList {
    val x = get(0)
    x.hashCode() // 이 부분까지 분석 됐을 때는 Any이지만 아래 코드로 인해 최종적으로 String으로 확정됩니다.
    val y: String = x // 이 부분을 통해 연기된 타입 변수의 타입은 String으로 확정됩니다.
}

 

빌더 추론 결과에 기여 (Contributing to builder inference results)

빌더 추론은 분석 결과에 기여하는 다양한 유형의 타입 정보를 수집할 수 있습니다. 빌더 추론은 다음과 같은 사항을 살펴봅니다.

 

타입 매개변수의 타입을 사용하는 람다의 수신 객체의 메소드 호출

val result = buildList {
    // add는 수신 객체(MutableList 타입) 의 메소드이며 
    // 시그니처는 override fun add(element: E): Boolean 같은 형태로서,
    // buildList의 타입 매개 변수 타입을 사용합니다(위의 예제 코드에 주석으로 있는 buildeList 소스 참조).
    // add에 전달되는 "value"를 근거로 타입 인수는 String으로 추론됩니다.
    add("value")
} // result는 List<String> 타입으로 추론됩니다.

 

타입 매개변수의 타입을 반환하는 호출의 예상되는 타입을 지정한 부분

val result = buildList {
    // x를 Float 타입으로 지정함으로써 타입 매개변수의 타입을 반환하는 get(0)이 
    // Float를 반환할 것으로 예상한 것입니다.
    // 빌더 추론은 이러한 예상 타입 지정을 살펴서 Float로 확정합니다.
    val x: Float = get(0)
} // result는 List<Float> 타입니다.
class Foo<T> {
    val items = mutableListOf<T>()
}

fun <K> myBuilder(builder: Foo<K>.() -> Unit): Foo<K> = Foo<K>().apply(builder)

fun main() {
    val result = myBuilder {
        val x: List<CharSequence> = items
        // ...
    } // result는 Foo<CharSequence> 타입입니다.
}

 

구체적인 타입을 기대하는 메소드에 연기된 타입 변수의 타입을 전달하는 부분

fun takeMyLong(x: Long) { ... }

fun String.isMoreThat3() = length > 3

fun takeListOfStrings(x: List<String>) { ... }

fun main() {
    val result1 = buildList {
        val x = get(0)
        takeMyLong(x)
    } // result1 타입: List<Long>

    val result2 = buildList {
        val x = get(0)
        val isLong = x.isMoreThat3()
    // ...
    } // result2 타입: List<String>

    val result3 = buildList {
        takeListOfStrings(this)
    } // result3 타입: List<String>
}

 

람다 수신자의 멤버에 대한 호출 가능한 참조를 취하는 부분

fun main() {
    val result = buildList {
        val x: KFunction1<Int, Float> = ::get
    } // result 타입: List<Float>
}
fun takeFunction(x: KFunction1<Int, Float>) { ... }

fun main() {
    val result = buildList {
        takeFunction(::get)
    } // result 타입: List<Float>
}

 

분석의 마지막 시점에 빌더 추론은 수집된 모든 타입 정보를 고려하고, 이를 합쳐서 최종적인 타입을 결정하려고 합니다. 다음 예를 보겠습니다.

val result = buildList { // Inferring postponed type variable E
    // E가 Number나 Number의 하위 타입으로 고려됩니다.
    val n: Number? = getOrNull(0)
    // E가 Int나 Int의 수퍼 타입으로 고려됩니다.
    add(1)
    // E는 Int로 추론됩니다.
} // result 타입: List<Int>

 

결과적인 타입은 분석하는 동안 수집된 타입 정보들 중에 가장 구체적인 타입입니다. 주어진 타입 정보가 모순 되거나 합쳐질 수 없으면, 컴파일러는 오류를 보고합니다.

 

Kotlin 컴파일러는 정규적인 타입 추론이 타입 인수의 타입을 추론하지 못하는 경우에만 빌더 추론을 사용한다는 점을 기억해야 합니다. 이 말은 빌더 람다의 바깥에서 타입 정보를 제공할 수 있고, 그런 경우에는 빌더 추론 분석이 필요 없어진다는 뜻입니다. 다음의 예를 살펴 보겠습니다.

fun someMap() = mutableMapOf<CharSequence, String>()

fun <E> MutableMap<E, String>.f(x: MutableMap<E, String>) { ... }

fun main() {
    val x: Map<in String, String> = buildMap {
        put("", "")
        f(someMap()) // 타입 불일치(요구되는 타입 String, 실제 타입 CharSequence)
    }
}

 

예상되는 맵의 타입이 빌더 람다 바깥에서 지정돼 있기 때문에 타입 불일치가 발생합니다. 컴파일러는 고정된 수신자 타입 Map<in String, String> 가지고, 빌더 람다 내부의 모든 문(statement)들을 분석합니다.