타입이 안정적인 빌더
Kotlin 에서는, 잘 지어진 이름을 가진 함수와 수신자를 갖는 함수 리터럴 을 사용하면, 안전한, 정적 타입을 가지는 빌더를 만들 수 있습니다.
타입이 안정적인 빌더들은 Kotlin 기반의 도메인 특화 언어(DSL)를 만들 수 있게 하며, 이들은 복잡한 계층적 데이터 구조를 선언적인 형태로 작성하기에 적합합니다. 빌더의 예시 사용 케이스들은 아래와 같습니다:
아래의 예제를 살펴봅시다:
1import com.example.html.* // see declarations below
2
3fun result() =
4 html {
5 head {
6 title {+"XML encoding with Kotlin"}
7 }
8 body {
9 h1 {+"XML encoding with Kotlin"}
10 p {+"this format can be used as an alternative markup to XML"}
11
12 // an element with attributes and text content
13 a(href = "https://kotlinlang.org") {+"Kotlin"}
14
15 // mixed content
16 p {
17 +"This is some"
18 b {+"mixed"}
19 +"text. For more see the"
20 a(href = "https://kotlinlang.org") {+"Kotlin"}
21 +"project"
22 }
23 p {+"some text"}
24
25 // content generated by
26 p {
27 for (arg in args)
28 +arg
29 }
30 }
31 }
32
이 코드는 완전히 정당한 Kotlin 코드입니다. 여기에서 이 코드로 이것저것 가지고 놀아볼 수 있습니다(브라우저에서 수정하거나 실행할 수도 있습니다!).
어떻게 동작하나요?
Kotlin 에서 타입이 안정적인 빌더를 구현하려고 한다고 생각해봅시다. 첫 번째로, 만드려는 모델의 구조를 정의해야합니다.
이 경우에는 HTML 태그들을 만들어야겠지요. 이것은 몇 개의 클래스들로 쉽게 가능합니다.
예를 들어, HTML
클래스가 그의 자식들 <head>
와 <body>
를 가지는 <html>
태그를 정의합니다.
(아래쪽의 선언을 살펴보세요.)
이제, 우리가 어째서 아래와 같은 코드를 작성할 수 있는지 살펴볼까요:
1html {
2 // ...
3}
4
html
은 사실 람다 표현을 파라미터로 받는 함수입니다.
이 함수는 아래처럼 정의되어있습니다:
1fun html(init: HTML.() -> Unit): HTML {
2 val html = HTML()
3 html.init()
4 return html
5}
6
이 함수는 하나의 init
이라고 명명되는, 그 자체로 함수인 파라미터를 받습니다.
이 함수의 타입은 HTML.() -> Unit
으로, 수신자를 가지는 함수입니다.
이는 HTML
타입의 인스턴스(수신자)를 함수에 전달해야하며, 그 함수 안에서 HTML
타입의 멤버를 사용할 수 있음을 의미합니다.
수신자는 this
라는 키워드를 통해 접근할 수 있습니다:
1html {
2 this.head { ... }
3 this.body { ... }
4}
5
(head
와 body
는 HTML
의 멤버 함수입니다.)
이제, this
는 알고 있듯이 생략할 수 있습니다. 그러고 나면 벌써 생각했던 빌더의 모습과 거의 유사한 무언가가 나옵니다:
1html {
2 head { ... }
3 body { ... }
4}
5
그래서, 이 호출들이 뭘 할까요? 위에 있는 html
함수의 내용을 살펴봅시다.
우선 HTML
타입의 인스턴스를 만들고, 전달받은 함수를 사용하여 초기화한 뒤(이 경우에는 HTML
인스턴스의 head
와 body
함수를 호출합니다), 그 자신을 다시 리턴합니다.
이것이 정확히 빌더들이 해야하는 일입니다.
HTML
의 멤버인 head
와 body
함수들도 html
함수와 비슷하게 정의됩니다.
유일한 차이점은 그들 각각이 만든 인스턴스들을 그들을 감싸는 HTML
인스턴스의 children
컬렉션에 추가한다는 것 정도입니다:
1fun head(init: Head.() -> Unit): Head {
2 val head = Head()
3 head.init()
4 children.add(head)
5 return head
6}
7
8fun body(init: Body.() -> Unit): Body {
9 val body = Body()
10 body.init()
11 children.add(body)
12 return body
13}
14
이들은 기본적으로 동일한 동작을 하므로, 제너릭을 사용한 initTag
를 만들 수도 있습니다:
1protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
2 tag.init()
3 children.add(tag)
4 return tag
5}
6
이제 이 두 함수들은 매우 간결합니다:
1fun head(init: Head.() -> Unit) = initTag(Head(), init)
2
3fun body(init: Body.() -> Unit) = initTag(Body(), init)
4
그리고 이들을 <head>
태그와 <body>
태그를 만드는데 사용하면 됩니다.
위에서 살펴본 것 중 아직 살펴보지 않은 또 하나의 요소는 태그의 몸체에 어떻게 텍스트를 추가할 수 있을지 입니다. 위의 예제에서 이런 비슷한 코드가 있었지요:
1html {
2 head {
3 title {+"XML encoding with Kotlin"}
4 }
5 // ...
6}
7
즉, 태그의 몸체에 문자열을 곧바로 집어넣었지만, 그 앞에 +
가 있습니다.
이것은 오버로딩 된 연산자 함수 unaryPlus()
를 호출합니다.
이 동작은 TagWithText
추상 클래스(Title
의 슈퍼타입)의 멤버로 정의된, String 을 확장하는 unaryPlus()
함수에서 발생합니다.
1operator fun String.unaryPlus() {
2 children.add(TextElement(this))
3}
4
그러므로 여기에서 +
접두사가 하는 역할은 문자열들을 TextElement
로 감싸 children
컬렉션에 추가하는 일입니다.
그럼으로써 이 문자열들이 태그 트리의 일부가 됩니다.
이 모든 내용은 위의 빌더 예제에서 가져온 com.example.html
패키지에 정의되어 있습니다.
이 문서의 마지막 영역에서 이 패키지의 전체 정의를 확인해볼 수 있습니다.
head
함수는 아래처럼도 작성할 수 있습니다:
1fun head(init: Head.() -> Unit) =
2 Head().apply(init).also { children.add(it) }
3
스코프 제어: @DslMarker
DSL 을 사용할 때, 어떤 단일 컨텍스트에서 너무 많은 것들이 불릴 수 있는 문제가 발생할 수도 있습니다.
그 컨텍스트의 암시적인 수신자들에 대한 함수를 모두 호출할 수 있습니다. 그렇기 때문에, 위의 예제에서
head
태그 안에서 head
를 다시 만드는 등의 정의되지 않은 동작으로 인한 결과를 마주하게 될 수도 있습니다:
1html {
2 head {
3 head {} // 이것은 금지되어야 합니다.
4 }
5 // ...
6}
7
이 예제에서는 가장 가까운 암시적 수신자의 멤버들에만 접근 가능해야합니다.
head()
는 바깥쪽 수신자 this@html
의 멤버이므로, 호출이 불가능해야합니다.
이런 문제를 해결하기 위해, 수신자의 스코프를 제어할 수 있는 특별한 메커니즘이 존재합니다.
컴파일러가 스코프를 관리하게 하기 위해서는, DSL에서 수신자들로 사용된 모든 타입들에 같은 마커 어노테이션을 붙혀주면 됩니다.
예를 들어, HTML 빌더들에게는 @HTMLTagMarker
를 붙힐 수 있겠지요:
1@DslMarker
2annotation class HtmlTagMarker
3
어노테이션 클래스는 @DslMarker
로 표기되면 DSL 마커로 불리는 특별한 어노테이션이 됩니다.
우리의 DSL 에서 모든 태그들은 슈퍼타입인 Tag
를 확장합니다.
그러므로 슈퍼타입인 Tag
클래스에만 @HtmlTagMarker
를 붙혀주어도 충분하며 이렇게 하면 Kotlin 의 컴파일러가
모든 확장하는 서브타입들에 대해 어노테이션한 것으로 간주합니다.
1@HtmlTagMarker
2abstract class Tag(val name: String) { ... }
3
HTML
클래스나 Head
클래스 등은 이미 그들의 슈퍼타입이 어노테이션 되었으므로 @HtmlTagMarker
로 어노테이션할 필요가 없습니다.
1class HTML() : Tag("html") { ... }
2
3class Head() : Tag("head") { ... }
4
어노테이션을 추가하고 나면, Kotlin 의 컴파일러가 같은 DSL 의 암시적 수신자가 누구인지 명확하게 알게 되며 가장 가까운 수신자의 멤버만 호출할 수 있도록 허가합니다:
1html {
2 head {
3 head { } // error: a member of outer receiver
4 }
5 // ...
6}
7
그러나 여전히 바깥쪽 수신자의 멤버를 호출하는 것이 가능하기는 합니다. 그렇게 하려면 아래처럼 수신자를 명시적으로 지정해주면 됩니다:
1html {
2 head {
3 this@html.head { } // 가능합니다.
4 }
5 // ...
6}
7
com.example.html 패키지의 전체 코드
아래가 com.example.html
패키지의 전체 내용입니다(위의 예제에서 쓰인 요소들만 포함되어있습니다).
이들은 HTML
트리를 구성하며, 굉장히 많은 익스텐션과 수신자를 가지는 람다들을 사용하고 있습니다.
1package com.example.html
2
3interface Element {
4 fun render(builder: StringBuilder, indent: String)
5}
6
7class TextElement(val text: String) : Element {
8 override fun render(builder: StringBuilder, indent: String) {
9 builder.append("$indent$text\n")
10 }
11}
12
13@DslMarker
14annotation class HtmlTagMarker
15
16@HtmlTagMarker
17abstract class Tag(val name: String) : Element {
18 val children = arrayListOf<Element>()
19 val attributes = hashMapOf<String, String>()
20
21 protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
22 tag.init()
23 children.add(tag)
24 return tag
25 }
26
27 override fun render(builder: StringBuilder, indent: String) {
28 builder.append("$indent<$name${renderAttributes()}>\n")
29 for (c in children) {
30 c.render(builder, indent + " ")
31 }
32 builder.append("$indent</$name>\n")
33 }
34
35 private fun renderAttributes(): String {
36 val builder = StringBuilder()
37 for ((attr, value) in attributes) {
38 builder.append(" $attr=\"$value\"")
39 }
40 return builder.toString()
41 }
42
43 override fun toString(): String {
44 val builder = StringBuilder()
45 render(builder, "")
46 return builder.toString()
47 }
48}
49
50abstract class TagWithText(name: String) : Tag(name) {
51 operator fun String.unaryPlus() {
52 children.add(TextElement(this))
53 }
54}
55
56class HTML : TagWithText("html") {
57 fun head(init: Head.() -> Unit) = initTag(Head(), init)
58
59 fun body(init: Body.() -> Unit) = initTag(Body(), init)
60}
61
62class Head : TagWithText("head") {
63 fun title(init: Title.() -> Unit) = initTag(Title(), init)
64}
65
66class Title : TagWithText("title")
67
68abstract class BodyTag(name: String) : TagWithText(name) {
69 fun b(init: B.() -> Unit) = initTag(B(), init)
70 fun p(init: P.() -> Unit) = initTag(P(), init)
71 fun h1(init: H1.() -> Unit) = initTag(H1(), init)
72 fun a(href: String, init: A.() -> Unit) {
73 val a = initTag(A(), init)
74 a.href = href
75 }
76}
77
78class Body : BodyTag("body")
79class B : BodyTag("b")
80class P : BodyTag("p")
81class H1 : BodyTag("h1")
82
83class A : BodyTag("a") {
84 var href: String
85 get() = attributes["href"]!!
86 set(value) {
87 attributes["href"] = value
88 }
89}
90
91fun html(init: HTML.() -> Unit): HTML {
92 val html = HTML()
93 html.init()
94 return html
95}
96
이 페이지가 도움이 되셨다면, 원문 페이지에 방문해 엄지척을 해주세요!