본문으로 건너뛰기

타입 안전 빌더

수신 객체 지정 함수 리터럴과 함께 잘 명명된 함수를 빌더로 사용하면 Kotlin에서 타입 안전 정적 타입 빌더를 만들 수 있습니다.

타입 안전 빌더를 사용하면 복잡한 계층적 데이터 구조를 준 선언적으로 빌드하는 데 적합한 Kotlin 기반의 DSL(Domain-Specific Language)을 만들 수 있습니다. 빌더의 샘플 사용 사례는 다음과 같습니다.

  • HTML 또는 XML과 같은 Kotlin 코드로 마크업 생성
  • 웹 서버에 대한 경로 구성: Ktor

다음 코드를 살펴보세요.

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<html> 태그를 설명하는 클래스로, <head><body>와 같은 자식을 정의합니다. (선언은 아래에서 확인하세요.)

이제 코드에서 다음과 같이 말할 수 있는 이유를 상기해 보겠습니다.

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 { ... }
}

(headbodyHTML의 멤버 함수입니다.)

이제 평소와 같이 this를 생략할 수 있으며 이미 빌더와 매우 유사한 것을 얻을 수 있습니다.

html {
head { ... }
body { ... }
}

그렇다면 이 호출은 무엇을 할까요? 위에서 정의된 html 함수의 본문을 살펴 보겠습니다. HTML의 새 인스턴스를 만들고 인수로 전달된 함수를 호출하여 초기화한 다음 (이 예제에서는 HTML 인스턴스에서 headbody를 호출하는 것으로 귀결됨) 이 인스턴스를 반환합니다. 이것이 바로 빌더가 해야 할 일입니다.

HTML 클래스의 headbody 함수는 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() 연산을 호출하는 함수 호출입니다. 해당 연산은 실제로 Title의 부모인 TagWithText 추상 클래스의 멤버인 확장 함수 unaryPlus()에 의해 정의됩니다.

operator fun String.unaryPlus() {
children.add(TextElement(this))
}

따라서 여기서 접두사 +가 하는 일은 문자열을 TextElement 인스턴스로 래핑하고 children 컬렉션에 추가하여 태그 트리의 적절한 부분이 되도록 하는 것입니다.

이 모든 것은 위의 빌더 예제 상단에서 가져온 com.example.html 패키지에서 정의됩니다. 마지막 섹션에서는 이 패키지의 전체 정의를 읽을 수 있습니다.

범위 제어: @DslMarker

DSLs를 사용할 때 컨텍스트에서 너무 많은 함수를 호출할 수 있는 문제에 직면했을 수 있습니다. 람다 내에서 사용 가능한 모든 암시적 수신 객체의 메서드를 호출하여 일관성 없는 결과를 얻을 수 있습니다. 예를 들어 다른 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
}