서른다섯 번째, 빌더 타입 추론과 함께 빌더 사용하기입니다.
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)들을 분석합니다.
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 37. Equality (0) | 2024.03.16 |
---|---|
공식 문서로 배우는 코틀린 - 36. Null safety (0) | 2024.03.15 |
공식 문서로 배우는 코틀린 - 34. Type-safe builders (2) | 2024.03.15 |
공식 문서로 배우는 코틀린 - 33. Operator overloading (2) | 2024.03.15 |
공식 문서로 배우는 코틀린 - 32. Inline functions (0) | 2024.03.14 |