빌더 타입 추론과 함께 빌더 사용하기

빌더 타입 추론과 함께 빌더 사용하기

Kotlin은 빌더 타입 추론(builder type inference, 줄여서 builder inference)을 지원합니다. 이 기능은 제네릭 빌더를 다룰 때 특히 유용해요. 빌더 호출의 타입 인자를, 그 람다 인자 안에 있는 다른 호출들의 타입 정보를 바탕으로 컴파일러가 추론할 수 있게 도와줍니다.

출처: Using builders with builder type inference

본문

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() 호출의 타입 인자를 StringNumber로 자동으로 추론해요. 빌더 추론 덕분에 제네릭 빌더를 사용할 때 타입 인자를 생략할 수 있는 거예요.

직접 빌더 만들기

빌더 추론을 활성화하기 위한 요구 사항

📝 참고 Kotlin 1.7.0 이전에는 빌더 함수에서 빌더 추론을 활성화하려면 -Xenable-builder-inference 컴파일러 옵션이 필요했어요. 1.7.0부터는 이 옵션이 기본적으로 활성화됩니다.

자신만의 빌더에서 빌더 추론이 작동하려면, 그 선언이 리시버가 있는 함수 타입의 빌더 람다 파라미터를 갖도록 해야 해요. 리시버 타입에는 두 가지 요구 사항이 더 있어요:

  1. 빌더 추론이 추론해야 하는 타입 인자를 사용해야 합니다. 예를 들어:
fun <V> buildList(builder: MutableList<V>.() -> Unit) { ... }

📝 참고 타입 파라미터의 타입을 직접 전달하는 fun <T> myBuilder(builder: T.() -> Unit) 같은 형태는 아직 지원되지 않는다는 점을 기억해 두세요.

  1. 시그니처에 해당 타입 파라미터가 들어 있는 공개 멤버나 확장을 제공해야 합니다. 예를 들어:
class ItemHolder<T> {
    private val items = mutableListOf<T>()

    fun addItem(x: T) {
        items.add(x)
    }

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

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) }
    )
    // result는 Pair<List<Int>, Map<String, Int>> 타입
}
  • 타입 파라미터가 람다의 파라미터 타입이나 반환 타입인 타입 인자 추론하기
fun <K, V> myBuilder1(
    mapBuilder: MutableMap<K, V>.() -> K
): Map<K, V> = mutableMapOf<K, V>().apply { mapBuilder() }

fun <K, V> myBuilder2(
    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
    }
    val result2 = myBuilder2 {
        put(1, "value 1")
        // 여기서 `it`을 "지연된 타입 변수" 타입으로 사용할 수 있어요
        // 자세한 내용은 아래 섹션을 참고하세요
        put(it, "value 2")
    }
}

빌더 추론의 작동 원리

지연된 타입 변수 (Postponed type variables)

빌더 추론은 지연된 타입 변수(postponed type variables)의 관점에서 작동해요. 이 변수는 빌더 추론 분석 중에 빌더 람다 안에 나타납니다. 지연된 타입 변수는 타입 인자의 타입으로, 추론이 진행 중인 상태를 뜻해요. 컴파일러는 이를 사용해 타입 인자에 대한 타입 정보를 모읍니다.

buildList()를 사용하는 예시를 보면:

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

여기서 x는 지연된 타입 변수의 타입을 가져요. get() 호출은 E 타입의 값을 반환하는데, E 자체는 아직 확정되지 않았습니다. 이 시점에는 E의 구체적인 타입이 알려지지 않은 상태예요.

지연된 타입 변수의 값이 구체적인 타입과 연결되면, 빌더 추론은 이 정보를 모아 분석이 끝날 때 해당 타입 인자의 최종 타입을 추론해요. 예를 들어:

val result = buildList {
    val x = get(0)
    val y: String = x
} // result는 List<String> 타입으로 추론됨

지연된 타입 변수가 String 타입의 변수에 할당되면, 빌더 추론은 xString의 하위 타입이라는 정보를 얻어요. 이 할당이 빌더 람다의 마지막 문장이므로, 빌더 추론 분석은 타입 인자 EString으로 추론하면서 끝납니다.

지연된 타입 변수를 리시버로 해서 equals(), hashCode(), toString() 함수를 항상 호출할 수 있다는 점도 참고해 두세요.

빌더 추론 결과에 기여하기

빌더 추론은 분석 결과에 기여하는 여러 가지 타입 정보를 수집할 수 있어요. 다음과 같은 것들을 고려합니다:

  • 타입 파라미터 타입을 사용하는 메서드를 람다의 리시버에서 호출하기
val result = buildList {
    // 전달된 "value" 인자를 바탕으로 타입 인자가 String으로 추론됨
    add("value")
} // result는 List<String> 타입으로 추론됨
  • 타입 파라미터 타입을 반환하는 호출에 기대 타입 지정하기
val result = buildList {
    // 기대 타입을 바탕으로 타입 인자가 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.isMoreThan3() = 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.isMoreThan3()
    // ...
    } // 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 { // 지연된 타입 변수 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>으로 안의 모든 문장을 분석합니다.

더 알아보기