Search

Builders

타입-세이프 빌더

잘 명명된 함수들과 수신자가 있는 함수 리터럴을 결합하여 타입-세이프하고 정적으로 타입이 지정된 빌더를 생성할 수 있다.
도메인 특화 언어(DSL)를 생성하는데 유용하며 복잡한 계층 구조의 데이터를 반-선언적 방식으로 작성할 수 있게 한다.
import com.example.html.* // 아래에 선언된 내용 참조 fun result() = html { head { title {+"XML encoding with Kotlin"} } body { h1 {+"XML encoding with Kotlin"} p {+"이 포맷은 XML의 대안으로 사용할 수 있습니다"} // 속성과 텍스트가 있는 요소 a(href = "https://kotlinlang.org") {+"Kotlin"} // 혼합된 콘텐츠 p { +"여기에" b {+"혼합된"} +"텍스트가 있습니다. 자세한 내용은" a(href = "https://kotlinlang.org") {+"Kotlin"} +"프로젝트를 참조하세요" } p {+"일부 텍스트"} // 생성된 콘텐츠 p { for (arg in args) +arg } } }
Kotlin
복사

작동 방식

html { // ... }
Kotlin
복사
먼저 구축하려는 모델을 정의해야 한다.
이 예시에선 HTML 태그를 모델링해야 하며 <html> 태그는 자식 태그로 <head> 와 <body> 를 정의하는 HTML 클래스이다.
fun html(init: HTML.() -> Unit): HTML { val html = HTML() html.init() return html }
Kotlin
복사
html 은 사실 람다 표현식을 인수로 받는 함수 호출이다.
이 함수는 init 이라는 하나의 인수를 받으며 이는 함수 타입인 HTML.() → Unit 이다.
이는 수신자가 있는 함수 타입으로 HTML 인스턴스를 함수로 전달하며 그 안에서 해당 인스턴스의 멤버를 호출할 수 있다.
html { this.head { ... } this.body { ... } }
Kotlin
복사
수신자는 this 키워드를 통해 접근할 수 있다.
html { head { ... } body { ... } }
Kotlin
복사
this는 생략할 수 있으며 결과적으로 다음과 같이 작성할 수 있다.
이 함수는 HTML 인스턴스를 생성한 후 해당 인스턴스를 초기화하며 마지막으로 그 인스턴스를 반환한다.
fun head(init: Head.() -> Unit): Head { val head = Head() head.init() children.add(head) return head } fun body(init: Body.() -> Unit): Body { val body = Body() body.init() children.add(body) return body }
Kotlin
복사
head 와 body 함수도 html 함수와 유사하게 정의된다.
단, 차이점은 이 함수들이 생성된 인스턴스를 부모 HTML 인스턴스의 자식 컬렉션에 추가한다는 점이다.
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T { tag.init() children.add(tag) return tag }
Kotlin
복사
head 와 body 함수는 사실상 같은 동작을 하므로 이를 일반화한 initTag 함수가 존재한다.
fun head(init: Head.() -> Unit) = initTag(Head(), init) fun body(init: Body.() -> Unit) = initTag(Body(), init)
Kotlin
복사
위처럼 함수들을 간단히 정의할 수 있다.
html { head { title {+"XML encoding with Kotlin"} } }
Kotlin
복사
+ 연산자는 접두사 단항 연산자로 이 연산은 TagWithText 추상 클래스에 정의된 확장 함수 unaryPlus를 호출한다.
operator fun String.unaryPlus() { children.add(TextElement(this)) }
Kotlin
복사
문자열을 TextElement 인스턴스로 감싸서 자식 컬렉션에 추가하는 역할을 한다.

스코프 제어: @DslMarker

@DslMarker annotation class HtmlTagMarker
Kotlin
복사
DSL을 사용할 때 수신자가 너무 많아서 컨텍스트 내에서 호출할 수 있는 함수가 많아지는 문제에 직면할 수 있다.
이를 방지하려면 모든 수신자 타입을 동일한 마커 어노테이션으로 표시해야 한다.
html { head { head {} // 오류: 외부 리시버의 멤버 호출 불가 } }
Kotlin
복사
@HtmlTagMarker로 클래스들을 어노테이트하면 컴파일러는 관련된 수신자만을 사용하도록 제한한다.
html { head { this@html.head { } // 가능 } }
Kotlin
복사
외부 수신자의 멤버를 호출하려면 명시적으로 지정해야 한다.

com.example.html 패키지의 전체 예제

다음은 HTML 트리를 생성하는 com.example.html 패키지의 전체 예제이다.
package com.example.html interface Element { fun render(builder: StringBuilder, indent: String) } class TextElement(val text: String) : Element { override fun render(builder: StringBuilder, indent: String) { builder.append("$indent$text\n") } } @DslMarker annotation class HtmlTagMarker @HtmlTagMarker abstract class Tag(val name: String) : Element { val children = arrayListOf<Element>() val attributes = hashMapOf<String, String>() protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T { tag.init() children.add(tag) return tag } override fun render(builder: StringBuilder, indent: String) { builder.append("$indent<$name${renderAttributes()}>\n") for (c in children) { c.render(builder, indent + " ") } builder.append("$indent</$name>\n") } private fun renderAttributes(): String { val builder = StringBuilder() for ((attr, value) in attributes) { builder.append(" $attr=\"$value\"") } return builder.toString() } override fun toString(): String { val builder = StringBuilder() render(builder, "") return builder.toString() } } abstract class TagWithText(name: String) : Tag(name) { operator fun String.unaryPlus() { children.add(TextElement(this)) } } class HTML : TagWithText("html") { fun head(init: Head.() -> Unit) = initTag(Head(), init) fun body(init: Body.() -> Unit) = initTag(Body(), init) } class Head : TagWithText("head") { fun title(init: Title.() -> Unit) = initTag(Title(), init) } class Title : TagWithText("title") abstract class BodyTag(name: String) : TagWithText(name) { fun b(init: B.() -> Unit) = initTag(B(), init) fun p(init: P.() -> Unit) = initTag(P(), init) fun h1(init: H1.() -> Unit) = initTag(H1(), init) fun a(href: String, init: A.() -> Unit) { val a = initTag(A(), init) a.href = href } } class Body : BodyTag("body") class B : BodyTag("b") class P : BodyTag("p") class H1 : BodyTag("h1") class A : BodyTag("a") { var href: String get() = attributes["href"]!! set(value) { attributes["href"] = value } } fun html(init: HTML.() -> Unit): HTML { val html = HTML() html.init() return html }
Kotlin
복사

빌더 타입 추론을 통한 빌더 사용

Kotlin에서 빌더 타입 추론은 제네릭 빌더와 작업할 때 유용하다.
람다 인수 안의 다른 호출에 대한 타입 정보를 기반으로 빌더 호출의 타입 인수를 컴파일러가 추론할 수 있도록 도와준다.
fun addEntryToMap(baseMap: Map<String, Number>, additionalEntry: Pair<String, Int>?) { val myMap = buildMap { putAll(baseMap) if (additionalEntry != null) { put(additionalEntry.first, additionalEntry.second) } } }
Kotlin
복사
일반적인 방식으로 타입 인수를 추론할 수 있는 충분한 정보가 없지만 빌더 추론은 람다 인수 내부의 putAll() 과 put() 호출에 대한 타입 정보를 분석한다.
이를 바탕으로 buildMap() 호출의 타입 인수인 String 과 Number 를 자동으로 추론한다.
이 덕분에 제네릭 빌더를 사용할 때 타입 인수를 생략할 수 있다.

사용자 정의 빌더 작성

빌더 추론을 활성화 하기 위한 요구 사항
Kotlin 1.7.0 이전에는 -Xenable-builder-inference 컴파일러 옵션을 통해 빌더 추론을 활성화
Kotlin 1.7.0 이후에는 기본적으로 빌더 추론이 활성화
빌더 추론을 사용하려면 빌더 함수의 선언에 수신자가 있는 함수 타입의 빌더 람다 매개변수가 포함되어야 한다.
빌더 추론이 추론해야 하는 타입 인수를 사용해야 한다.
fun <V> buildList(builder: MutableList<V>.() -> Unit) { ... }
Kotlin
복사
fun <T> myBuilder(builder: T.() -> Unit) 같이 타입 매개변수의 타입을 직접 전달하는 것은 아직 지원되지 않는다.
서명에 해당 타입 매개변수를 포함하는 공개 멤버 또는 확장을 제공해야 한다.
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 { // Type of itemHolder1 is ItemHolder<String> addItem(s) } val itemHolder2 = itemHolderBuilder { // Type of itemHolder2 is ItemHolder<String> addAllItems(listOf(s)) } val itemHolder3 = itemHolderBuilder { // Type of itemHolder3 is ItemHolder<String?> val lastItem: String? = getLastItem() // ... } }
Kotlin
복사
지원되는 기능
여러 타입 인수 유추
fun <K, V> myBuilder(builder: MutableMap<K, V>.() -> Unit): Map<K, V> { ... }
Kotlin
복사
상호 종속적인 것을 포함하여 하나의 호출 내에서 여러 빌더 람다의 타입 인수를 유추한다.
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 has Pair<List<Int>, Map<String, Int>> type }
Kotlin
복사
람다의 매개변수 또는 반환 타입인 타입 매개변수를 유추하는 타입 인수
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 has the Map<Long, String> type inferred val result1 = myBuilder1 { put(1L, "value") 2 } val result2 = myBuilder2 { put(1, "value 1") // You can use `it` as "postponed type variable" type // See the details in the section below put(it, "value 2") } }
Kotlin
복사

빌더 추론의 작동 방식

연기된 타입 변수
빌더 추론은 빌더 추론 분석 중에 빌더 람다 내부에 나타나는 연기된 타입 변수의 관점에서 작동한다.
연기된 타입 변수는 추론 과정에 있는 타입 인수의 타입이다.
컴파일러는 이를 사용하여 타입 인수에 대한 타입 정보를 수집한다.
val result = buildList { val x = get(0) }
Kotlin
복사
x는 연기된 타입 변수의 타입을 갖는다.
get() 호출은 E 타입의 값을 반환하지만 E 자체는 아직 고정되지 않았다.
즉, 이 시점에서 E 에 대한 구체적인 타입은 아직 알 수 없다.
val result = buildList { val x = get(0) val y: String = x } // result has the List<String> type inferred
Kotlin
복사
연기된 타입 변수가 구체적인 타입과 연결되면 빌더 추론은 이 정보를 수집하여 해당 타입 인수의 최종 타입을 추론한다.
연기된 타입 변수가 String 타입의 변수에 할당되면서 빌더 추론은 x가 String의 하위 타입이라는 정보를 얻는다.
이 할당이 빌더 람다에서 마지막 문장이므로 빌더 추론 분석은 타입 인수 E 가 String으로 추론되는 것으로 끝난다.
빌더 추론에 기여하기
빌더 추론은 다양한 타입 정보를 수집하여 분석 결과에 기여할 수 있다.
람다의 수신자 객체에서 타입 매개변수의 타입을 사용하는 메서드 호출
val result = buildList { // 전달된 "value" 인수를 기반으로 타입 인수가 String으로 추론됩니다. add("value") } // 결과는 List<String> 타입으로 추론됩니다.
Kotlin
복사
타입 매개변수의 타입을 반환하는 호출에 대해 예상 타입 지정
val result = buildList { // 예상 타입에 따라 타입 인수가 Float로 추론됩니다. val x: Float = get(0) } // 결과는 List<Float> 타입입니다.
Kotlin
복사
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 // ... } // 결과는 Foo<CharSequence> 타입으로 추론됩니다. }
Kotlin
복사
연기된 타입 변수를 구체적인 타입을 기대하는 메서드에 전달
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> 타입입니다. }
Kotlin
복사
람다 수신자 객체의 멤버에 대한 호출 가능 참조를 가져오기
fun main() { val result = buildList { val x: KFunction1<Int, Float> = ::get } // 결과는 List<Float> 타입으로 추론됩니다. } fun takeFunction(x: KFunction1<Int, Float>) { ... } fun main() { val result = buildList { takeFunction(::get) } // 결과는 List<Float> 타입입니다. }
Kotlin
복사
분석이 끝나면 빌더 추론은 수집된 모든 타입 정보를 고려하여 최종 타입을 결정하려고 시도한다.
val result = buildList { // 연기된 타입 변수 E를 추론 중 // E가 Number 또는 Number의 하위 타입으로 간주됨 val n: Number? = getOrNull(0) // E가 Int 또는 Int의 상위 타입으로 간주됨 add(1) // E는 Int로 추론됨 } // 결과는 List<Int> 타입입니다.
Kotlin
복사
최종 타입은 분석 동안 수집된 타입 정보와 일치하는 가장 구체적인 타입이다.
만약 주어진 타입 정보가 서로 충돌하여 병합될 수 없으면 컴파일러는 오류를 보고한다.
빌더 추론 분석이 필요하지 않은 경우
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 제공) } }
Kotlin
복사
Map의 예상 타입이 빌더 람다 외부에서 지정되었기 때무넹 타입 불일치가 발생한다.
컴파일러는 고정된 수신자 객체 타입인 Map<in String, String>으로 람다 내부의 모든 문장을 분석한다.