跳到主要内容

类型安全的构建器

通过将命名良好的函数用作构建器,并结合带接收者的函数字面量,可以在 Kotlin 中创建类型安全、静态类型的构建器。

类型安全的构建器允许创建基于 Kotlin 的领域特定语言(DSL),适合以半声明式的方式构建复杂的层级数据结构。构建器的示例用例包括:

  • 使用 Kotlin 代码生成标记,例如 HTML 或 XML
  • 为 Web 服务器配置路由: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 实际上是一个函数调用,它接受一个 lambda 表达式 作为参数。 这个函数定义如下:

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() 操作。 该操作实际上是由扩展函数 unaryPlus() 定义的,它是 TagWithText 抽象类的成员 (Title 的父类):

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

所以,前缀 + 在这里所做的是将一个字符串包装到 TextElement 的一个实例中,并将其添加到 children 集合中, 以便它成为标签树的适当部分。

所有这些都在包 com.example.html 中定义,该包在上面的构建器示例的顶部导入。 在最后一节中,你可以阅读此包的完整定义。

作用域控制:@DslMarker

使用 DSL 时,可能会遇到上下文可以调用太多函数的问题。 你可以在 lambda 内部调用每个可用隐式接收者的方法,因此得到不一致的结果, 例如,在另一个 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) { ... }

你不需要使用 @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
}
// ...
}

你还可以将 @DslMarker 注解直接应用于函数类型。 只需使用 @Target(AnnotationTarget.TYPE) 注解 @DslMarker 注解:

@Target(AnnotationTarget.TYPE)
@DslMarker
annotation class HtmlTagMarker

因此,@DslMarker 注解可以应用于函数类型,最常见的是带有接收者的 lambda 表达式。例如:

fun html(init: @HtmlTagMarker HTML.() `->` Unit): HTML { ... }

fun HTML.head(init: @HtmlTagMarker Head.() `->` Unit): Head { ... }

fun Head.title(init: @HtmlTagMarker Title.() `->` Unit): Title { ... }

当你调用这些函数时,@DslMarker 注解会限制访问使用它标记的 lambda 表达式的主体中的外部接收者,除非你显式指定它们:

html {
head {
title {
// Access to title, head or other functions of outer receivers is restricted here.
}
}
}

只有最近的接收者的成员和扩展才能在 lambda 表达式中访问,从而防止嵌套作用域之间的意外交互。

com.example.html 包的完整定义

这是 com.example.html 包的定义方式(仅使用上面示例中使用的元素)。 它构建一个 HTML 树。它大量使用了扩展函数带接收者的 lambda 表达式

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
}