타입 안전 빌더
잘 명명된 함수를 빌더로 사용하고 리시버가 있는 함수 리터럴과 조합하면 Kotlin에서 타입 안전하고 정적으로 타입이 지정된 빌더를 만들 수 있습니다.
타입 안전 빌더는 Kotlin 기반의 도메인 특화 언어(DSL)를 생성하여 복잡한 계층적 데이터 구조를 반선언적인 방식으로 빌드하는 데 적합합니다. 빌더의 사용 사례는 다음과 같습니다:
다음 코드를 살펴보세요:
import com.example.html.* // see declarations below
fun result() =
html {
head {
title {+"XML encoding with Kotlin"}
}
body {
h1 {+"XML encoding with Kotlin"}
p {+"this format can be used as an alternative markup to XML"}
// an element with attributes and text content
a(href = "https://kotlinlang.org") {+"Kotlin"}
// mixed content
p {
+"This is some"
b {+"mixed"}
+"text. For more see the"
a(href = "https://kotlinlang.org") {+"Kotlin"}
+"project"
}
p {+"some text"}
// content generated by
p {
for (arg in args)
+arg
}
}
}
이것은 완전히 유효한 Kotlin 코드입니다. 이 코드를 온라인에서 실행해 볼 수 있습니다 (브라우저에서 수정하고 실행).
작동 방식
Kotlin에서 타입 안전 빌더를 구현해야 한다고 가정해 봅시다. 우선, 만들고자 하는 모델을 정의하세요. 이 경우 HTML 태그를 모델링해야 합니다. 이것은 몇 개의 클래스로 쉽게 처리할 수 있습니다. 예를 들어, HTML
은 <head>
와 <body>
같은 자식을 정의하는 <html>
태그를 설명하는 클래스입니다. (선언은 아래에서 확인할 수 있습니다.)
이제 코드에서 다음과 같이 말할 수 있는 이유를 다시 살펴보겠습니다.
html {
// ...
}
html
은 실제로 람다 표현식을 인수로 받는 함수 호출입니다. 이 함수는 다음과 같이 정의됩니다:
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
이 함수는 init
이라는 매개변수를 하나 받는데, 이 매개변수 자체도 함수입니다. 이 함수의 타입은 HTML.() -> Unit
이며, 이는 리시버가 있는 함수 타입입니다. 이는 HTML
타입의 인스턴스(리시버)를 함수에 전달해야 하며, 함수 내에서 해당 인스턴스의 멤버를 호출할 수 있음을 의미합니다.
리시버는 this
키워드를 통해 접근할 수 있습니다:
html {
this.head { ... }
this.body { ... }
}
(head
와 body
는 HTML
의 멤버 함수입니다.)
이제 평소처럼 this
를 생략할 수 있으며, 이미 빌더와 매우 유사한 형태를 얻게 됩니다:
html {
head { ... }
body { ... }
}
그렇다면 이 호출은 무엇을 할까요? 위에 정의된 html
함수의 본문을 살펴보겠습니다. 이 호출은 HTML
의 새 인스턴스를 생성한 다음, 인수로 전달된 함수를 호출하여 초기화하고 (이 예제에서는 HTML
인스턴스에서 head
와 body
를 호출하는 것으로 귀결됨), 이 인스턴스를 반환합니다. 이것이 바로 빌더가 해야 할 일입니다.
HTML
클래스의 head
및 body
함수는 html
과 유사하게 정의됩니다. 유일한 차이점은 빌드된 인스턴스를 둘러싼 HTML
인스턴스의 children
컬렉션에 추가한다는 것입니다:
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
}
실제로 이 두 함수는 동일한 작업을 수행하므로 제네릭 버전인 initTag
를 사용할 수 있습니다:
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
따라서 이제 함수는 매우 간단합니다:
fun head(init: Head.() -> Unit) = initTag(Head(), init)
fun body(init: Body.() -> Unit) = initTag(Body(), init)
그리고 이 함수들을 사용하여 <head>
및 <body>
태그를 빌드할 수 있습니다.
여기서 논의할 또 다른 사항은 태그 본문에 텍스트를 추가하는 방법입니다. 위 예제에서는 다음과 같이 표현합니다:
html {
head {
title {+"XML encoding with Kotlin"}
}
// ...
}
기본적으로 태그 본문 안에 문자열을 넣지만, 앞에 작은 +
기호가 있어 접두사 unaryPlus()
연산을 호출하는 함수 호출이 됩니다. 이 연산은 실제로는 TagWithText
추상 클래스(Title
의 부모 클래스)의 멤버인 확장 함수 unaryPlus()
에 의해 정의됩니다:
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
따라서 여기서 접두사 +
는 문자열을 TextElement
인스턴스로 래핑하고 children
컬렉션에 추가하여 태그 트리의 적절한 부분이 되도록 합니다.
이 모든 것은 위 빌더 예제 상단에서 임포트된 com.example.html
패키지에 정의되어 있습니다. 마지막 섹션에서 이 패키지의 전체 정의를 살펴볼 수 있습니다.
스코프 제어: @DslMarker
DSL을 사용할 때, 컨텍스트 내에서 너무 많은 함수를 호출할 수 있는 문제에 직면할 수 있습니다. 람다 내에서 사용 가능한 모든 암시적 리시버의 메서드를 호출할 수 있으므로, head
태그 안에 또 다른 head
태그가 있는 것처럼 일관되지 않은 결과를 얻을 수 있습니다:
html {
head {
head {} // should be forbidden
}
// ...
}
이 예제에서는 가장 가까운 암시적 리시버 this@head
의 멤버만 사용 가능해야 합니다. head()
는 외부 리시버 this@html
의 멤버이므로 호출하는 것이 허용되지 않아야 합니다.
이 문제를 해결하기 위해 리시버 스코프를 제어하는 특별한 메커니즘이 있습니다.
컴파일러가 스코프 제어를 시작하도록 하려면, DSL에서 사용되는 모든 리시버의 타입을 동일한 마커 애너테이션으로 애너테이션하기만 하면 됩니다. 예를 들어, HTML 빌더의 경우 @HTMLTagMarker
애너테이션을 선언합니다:
@DslMarker
annotation class HtmlTagMarker
@DslMarker
애너테이션으로 애너테이션된 애너테이션 클래스를 DSL 마커라고 합니다.
저희 DSL에서는 모든 태그 클래스가 동일한 슈퍼클래스 Tag
를 확장합니다. 슈퍼클래스에만 @HtmlTagMarker
를 애너테이션하는 것으로 충분하며, 그 후 Kotlin 컴파일러는 상속된 모든 클래스를 애너테이션된 것으로 간주합니다:
@HtmlTagMarker
abstract class Tag(val name: String) { ... }
HTML
또는 Head
클래스에 @HtmlTagMarker
를 애너테이션할 필요가 없습니다. 그들의 슈퍼클래스가 이미 애너테이션되어 있기 때문입니다:
class HTML() : Tag("html") { ... }
class Head() : Tag("head") { ... }
이 애너테이션을 추가하면 Kotlin 컴파일러는 어떤 암시적 리시버가 동일한 DSL의 일부인지 알 수 있으며, 가장 가까운 리시버의 멤버만 호출할 수 있도록 허용합니다:
html {
head {
head { } // error: a member of outer receiver
}
// ...
}
외부 리시버의 멤버를 호출하는 것이 여전히 가능하지만, 그렇게 하려면 이 리시버를 명시적으로 지정해야 합니다:
html {
head {
this@html.head { } // possible
}
// ...
}
@DslMarker
애너테이션을 함수 타입에 직접 적용할 수도 있습니다. @DslMarker
애너테이션을 @Target(AnnotationTarget.TYPE)
으로 애너테이션하기만 하면 됩니다:
@Target(AnnotationTarget.TYPE)
@DslMarker
annotation class HtmlTagMarker
그 결과, @DslMarker
애너테이션은 함수 타입, 가장 일반적으로 리시버가 있는 람다에 적용될 수 있습니다. 예를 들어:
fun html(init: @HtmlTagMarker HTML.() -> Unit): HTML { ... }
fun HTML.head(init: @HtmlTagMarker Head.() -> Unit): Head { ... }
fun Head.title(init: @HtmlTagMarker Title.() -> Unit): Title { ... }
이 함수들을 호출할 때, @DslMarker
애너테이션은 해당 애너테이션으로 표시된 람다의 본문에서 외부 리시버에 대한 접근을 제한합니다. 단, 명시적으로 지정하는 경우는 제외합니다:
html {
head {
title {
// Access to title, head or other functions of outer receivers is restricted here.
}
}
}
람다 내에서는 가장 가까운 리시버의 멤버와 확장만 접근 가능하며, 중첩된 스코프 간의 의도치 않은 상호작용을 방지합니다.
com.example.html 패키지의 전체 정의
이는 com.example.html
패키지가 어떻게 정의되었는지 보여줍니다 (위 예제에서 사용된 요소만 포함). 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
")
}
}
@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()}>
")
for (c in children) {
c.render(builder, indent + " ")
}
builder.append("$indent</$name>
")
}
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
}