类型安全的构建器
通过使用命名良好的函数作为构建器,并结合带有接收者的函数字面量,可以在 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"}
// 一个带有属性和文本内容的元素
a(href = "https://kotlinlang.org") {+"Kotlin"}
// 混合内容
p {
+"This is some"
b {+"mixed"}
+"text. For more see the"
a(href = "https://kotlinlang.org") {+"Kotlin"}
+"project"
}
p {+"some text"}
// 由此生成的内容
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 { ... }
}(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() 操作符。 该操作符实际上由扩展函数 unaryPlus() 定义,它是 TagWithText 抽象类(Title 的父类)的一个成员:
operator fun String.unaryPlus() {
children.add(TextElement(this))
}因此,这里的前缀 + 的作用是将一个字符串包装到 TextElement 的一个实例中,并将其添加到 children 集合中,使其成为标签树的合适的部分。
所有这些都定义在 com.example.html 包中,该包在上述构建器示例的顶部导入。 在最后一节中,你可以参阅这个包的完整定义。
作用域控制:@DslMarker
在使用 DSL 时,可能会遇到在上下文中可以调用过多函数的问题。 你可以在 lambda 表达式内部调用每个可用的隐式接收者的方法,从而得到不一致的结果,例如在另一个 head 标签内部的 head 标签:
html {
head {
head {} // 应该禁止
}
// ...
}在此示例中,只有最近的隐式接收者 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 注解 HTML 或 Head 类,因为它们的超类已经注解过了:
class HTML() : Tag("html") { ... }
class Head() : Tag("head") { ... }添加此注解后,Kotlin 编译器会知道哪些隐式接收者属于同一个 DSL,并且只允许调用最近接收者的成员:
html {
head {
head { } // 错误:外部接收者的成员
}
// ...
}请注意,仍然可以调用外部接收者的成员,但要这样做,你必须显式指定该接收者:
html {
head {
this@html.head { } // 可能
}
// ...
}你还可以将 @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 {
// 在此处限制访问 title、head 或其他外部接收者的函数。
}
}
}在 lambda 表达式内部,只有最近接收者的成员和扩展是可访问的,从而防止嵌套作用域之间产生意外交互。
当隐式接收者的成员和来自 上下文形参 的声明在同一作用域中具有相同名称时,编译器会报告警告,因为隐式接收者被上下文形参遮蔽。 为了解决这个问题,可以使用 this 限定符来显式调用接收者,或者使用 contextOf<T>() 来调用****上下文声明:
interface HtmlTag {
fun setAttribute(name: String, value: String)
}
// 声明一个同名的**顶层**函数,
// 该函数通过**上下文形参**可用
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) { tag.setAttribute(name, value) }
fun test(head: HtmlTag, extraInfo: HtmlTag) {
with(head) {
// 在内部**作用域**中引入一个相同类型的**上下文**值
context(extraInfo) {
// 报告警告:
// 使用被**上下文形参**遮蔽的**隐式接收者**
setAttribute("user", "1234")
// **显式调用****接收者**的成员
this.setAttribute("user", "1234")
// **显式调用****上下文声明**
contextOf<HtmlTag>().setAttribute("user", "1234")
}
}
}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
}