타입-세이프 빌더
•
잘 명명된 함수들과 수신자가 있는 함수 리터럴을 결합하여 타입-세이프하고 정적으로 타입이 지정된 빌더를 생성할 수 있다.
•
도메인 특화 언어(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>으로 람다 내부의 모든 문장을 분석한다.