Kotlin Reference: Type-Safe Builders

  建造者(Builder) 的概念在 Groovy 社区中颇为流行。建造者允许以半声明式的形式定义数据,常用于生成 XMLUI 元素布局描述 3D 场景等。

  Kotlin 提供的类型检查建造者适用于大多数用例,比 Groovy 中的动态类型的实现更加诱人。

  Kotlin 也支持动态类型建造者,以满足其他用例的需要。

A type-safe builder example

  考虑下面的代码:

import com.example.html.* // see declarations below

fun result(args: Array) =
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 = "http://kotlinlang.org") {+"Kotlin"}

// mixed content
p {
+"This is some"
b {+"mixed"}
+"text. For more see the"
a(href = "http://kotlinlang.org") {+"Kotlin"}
+"project"
}
p {+"some text"}

// content generated by
p {
for (arg in args)
+arg
}
}
}

这是完全合法的 Kotlin 代码,你可以在这里在线修改并在浏览器中运行这段代码。

How it works

  下面会逐步介绍在 Kolint 中实现类型安全的建造者的方法。首先,我们需要定义想要建造的模型,这里我们对 HTML 标签进行建模,只需简单地定义一些列类即可。如 HTML 是一个用于描述 <html> 标签的类,它定义了如 <head><body> 的子元素。(见下面的声明)

  现在再回过头看前面的代码:

html {
// ...
}

html 其实是一个函数,接受一个 Lambda 表达式作为其参数,该函数的定义如下:

fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}

这个函数有一个名为 init 的参数,该参数本身也是一个函数,该函数的类型是 HTML.() -> Unit,是一个带接收者的函数类型(Function Type with Receiver)。这意味着我们需要向该函数传递一个 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  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() 定义的:

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

所以,这里前缀 + 的作用是把字符串包装进一个 TextElement 实例,然后把它添加到 children 集合,成为了标签树的一部分。

  上面的内容都定义在开头例子的顶部导入的 com.example.html 包中,在最后可以看到该包的完整定义。

Scope control: @DslMarker (since 1.1)

  使用 DSL 时,有时会遇到在上下文中存在过多可被调用的函数的情况。由于我们可以在 Lambda 中调用所有隐式接收者的方法,因此可能会导致不一致的结果,就像在 head 中又调用了 head(【注】headhtml 的方法,但在作为 head 参数的 Lambda 中,仍可以访问外层 htmlhead 方法):

html {
head {
head {} // should be forbidden
}
// ...
}

在这个例子中,第 3 行处我们只希望能使用最近的隐式接收者 this@head (第 2 行的 head)的成员,而第 3 行的 head 作为更外层的接收者 this@html (第 1 行的 html)的成员,应当被禁止调用。

  Kotlin 1.1 引入了一个特殊的机制来控制接收者的作用域,以解决这个问题。

  为了让编译器对作用域进行控制,必须为在 DSL 中使用的所有接收者的类型添加同样的标记注解。举例来说,我们为 HTML 建造者声明了注解 @HTMLTagMarker

@DslMarker
annotation class HtmlTagMarker

使用 @DslMarker 注解的注解类称为 DSL 标记(DSL Marker)。

  在我们的 DSL 中,所有的标签类都继承自同一个超类 Tag,只要使用 @HtmlTagMarker 注解超类,之后 Kotlin 编译器就会认为所有的子类都有该注解:

@HtmlTagMarker
abstract class Tag(val name: String) { ... }

  我们不需要使用 @HtmlTagMarker 注解 HTMLHead 类,因为它们的超类已经被注解了:

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

Full definition of the com.example.html package

  下面是 com.example.html 包的具体定义(仅包含上面用到的元素),它建造了一个 HTML 树,重度使用了扩展函数带接收者的 Lambda

  注意 @DslMarker 注解仅从 Kotlin 1.1 起可用。

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
}