类型安全的构建器

通过使用命名得当的函数作为构建器,结合带有接收者的函数字面值, 可以在 Kotlin 中创建类型安全、静态类型的构建器。

类型安全的构建器可以创建基于 Kotlin 的适用于采用半声明方式构建复杂层次数据结构领域专用语言(DSL)。以下是构建器的范例应用场景:

  • 使用 Kotlin 代码生成标记语言,例如 HTML 或 XML
  • 为 Web 服务器配置路由:Ktor

考虑下面的代码:

  1. import com.example.html.* // 参见下文声明
  2. fun result() =
  3. html {
  4. head {
  5. title {+"XML encoding with Kotlin"}
  6. }
  7. body {
  8. h1 {+"XML encoding with Kotlin"}
  9. p {+"this format can be used as an alternative markup to XML"}
  10. // 一个具有属性和文本内容的元素
  11. a(href = "https://kotlinlang.org") {+"Kotlin"}
  12. // 混合的内容
  13. p {
  14. +"This is some"
  15. b {+"mixed"}
  16. +"text. For more see the"
  17. a(href = "https://kotlinlang.org") {+"Kotlin"}
  18. +"project"
  19. }
  20. p {+"some text"}
  21. // 以下代码生成的内容
  22. p {
  23. for (arg in args)
  24. +arg
  25. }
  26. }
  27. }

这是完全合法的 Kotlin 代码。 可以在这里在线运行上文代码(修改它并在浏览器中运行)

实现原理

Assume that you need to implement a type-safe builder in Kotlin. 首先,定义想要构建的模型。在本例中我们需要建模 HTML 标签。 用一些类就可以轻易完成。 例如,HTML 是一个描述 <html> 标签的类,它定义了像 <head><body> 这样的子标签。 (参见下文它的声明。)

现在,让我们回想下为什么可以在代码中这样写:

  1. html {
  2. // ……
  3. }

html 实际上是一个函数调用,它接受一个 lambda 表达式 作为参数。 该函数定义如下:

  1. fun html(init: HTML.() -> Unit): HTML {
  2. val html = HTML()
  3. html.init()
  4. return html
  5. }

这个函数接受一个名为 init 的参数,该参数本身就是一个函数。 该函数的类型是 HTML.() -> Unit,它是一个带接收者的函数类型。 这意味着需要向函数传递一个 HTML 类型的实例(接收者), 并且可以在函数内部调用该实例的成员。

该接收者可以通过 this 关键字访问:

  1. html {
  2. this.head { …… }
  3. this.body { …… }
  4. }

headbodyHTML 的成员函数。)

现在,像往常一样,this 可以省略掉了,得到的东西看起来已经非常像一个构建器了:

  1. html {
  2. head { …… }
  3. body { …… }
  4. }

那么,这个调用做什么? 让我们看看上面定义的 html 函数的主体。 它创建了一个 HTML 的新实例,然后通过调用作为参数传入的函数来初始化它 (在本例中,归结为在HTML实例上调用 headbody),然后返回此实例。 这正是构建器所应做的。

HTML 类中的 headbody 函数的定义与 html 类似。 唯一的区别是,它们将构建的实例添加到包含 HTML 实例的 children 集合中:

  1. fun head(init: Head.() -> Unit): Head {
  2. val head = Head()
  3. head.init()
  4. children.add(head)
  5. return head
  6. }
  7. fun body(init: Body.() -> Unit): Body {
  8. val body = Body()
  9. body.init()
  10. children.add(body)
  11. return body
  12. }

实际上这两个函数做同样的事情,所以可以有一个泛型版本,initTag

  1. protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
  2. tag.init()
  3. children.add(tag)
  4. return tag
  5. }

所以,现在该函数很简单:

  1. fun head(init: Head.() -> Unit) = initTag(Head(), init)
  2. fun body(init: Body.() -> Unit) = initTag(Body(), init)

并且可以使用它们来构建 <head><body> 标签。

这里要讨论的另一件事是如何向标签体中添加文本。在上例中这样写到:

  1. html {
  2. head {
  3. title {+"XML encoding with Kotlin"}
  4. }
  5. // ……
  6. }

所以基本上,只是把一个字符串放进一个标签体内部,但在它前面有一个小的 +, 所以它是一个函数调用,调用一个前缀 unaryPlus() 操作。 该操作实际上是由一个扩展函数 unaryPlus() 定义的,该函数是 TagWithText 抽象类(Title 的父类)的成员:

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

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

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

作用域控制:@DslMarker

使用 DSL 时,可能会遇到上下文中可以调用太多函数的问题。 可以调用 lambda 表达式内部每个可用的隐式接收者的方法,因此得到一个不一致的结果, 就像在另一个 head 内部的 head 标记那样:

  1. html {
  2. head {
  3. head {} // 应该禁止
  4. }
  5. // ……
  6. }

在这个例子中,必须只有最近层的隐式接收者 this@head 的成员可用;head() 是外部接收者 this@html 的成员,所以调用它一定是非法的。

为了解决这个问题,有一种控制接收者作用域的特殊机制。

为了使编译器开始控制标记,我们只是必须用相同的标记注解来标注在 DSL 中使用的所有接收者的类型。 例如,对于 HTML 构建器,我们声明一个注解 @HTMLTagMarker

  1. @DslMarker
  2. annotation class HtmlTagMarker

如果一个注解类使用 @DslMarker 注解标注,那么该注解类称为 DSL 标记。

在我们的 DSL 中,所有标签类都扩展了相同的超类 Tag。 只需使用 @HtmlTagMarker 来标注超类就足够了,之后,Kotlin 编译器会将所有继承的类视为已标注:

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

不必用 @HtmlTagMarker 标注 HTMLHead 类,因为它们的超类已标注过:

  1. class HTML() : Tag("html") { …… }
  2. class Head() : Tag("head") { …… }

在添加了这个注解之后,Kotlin 编译器就知道哪些隐式接收者是同一个 DSL 的一部分,并且只允许调用最近层的接收者的成员:

  1. html {
  2. head {
  3. head { } // 错误:外部接收者的成员
  4. }
  5. // ……
  6. }

请注意,仍然可以调用外部接收者的成员,但是要做到这一点,你必须明确指定这个接收者:

  1. html {
  2. head {
  3. this@html.head { } // 可能
  4. }
  5. // ……
  6. }

com.example.html 包的完整定义

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

  1. package com.example.html
  2. interface Element {
  3. fun render(builder: StringBuilder, indent: String)
  4. }
  5. class TextElement(val text: String) : Element {
  6. override fun render(builder: StringBuilder, indent: String) {
  7. builder.append("$indent$text\n")
  8. }
  9. }
  10. @DslMarker
  11. annotation class HtmlTagMarker
  12. @HtmlTagMarker
  13. abstract class Tag(val name: String) : Element {
  14. val children = arrayListOf<Element>()
  15. val attributes = hashMapOf<String, String>()
  16. protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
  17. tag.init()
  18. children.add(tag)
  19. return tag
  20. }
  21. override fun render(builder: StringBuilder, indent: String) {
  22. builder.append("$indent<$name${renderAttributes()}>\n")
  23. for (c in children) {
  24. c.render(builder, indent + " ")
  25. }
  26. builder.append("$indent</$name>\n")
  27. }
  28. private fun renderAttributes(): String {
  29. val builder = StringBuilder()
  30. for ((attr, value) in attributes) {
  31. builder.append(" $attr=\"$value\"")
  32. }
  33. return builder.toString()
  34. }
  35. override fun toString(): String {
  36. val builder = StringBuilder()
  37. render(builder, "")
  38. return builder.toString()
  39. }
  40. }
  41. abstract class TagWithText(name: String) : Tag(name) {
  42. operator fun String.unaryPlus() {
  43. children.add(TextElement(this))
  44. }
  45. }
  46. class HTML : TagWithText("html") {
  47. fun head(init: Head.() -> Unit) = initTag(Head(), init)
  48. fun body(init: Body.() -> Unit) = initTag(Body(), init)
  49. }
  50. class Head : TagWithText("head") {
  51. fun title(init: Title.() -> Unit) = initTag(Title(), init)
  52. }
  53. class Title : TagWithText("title")
  54. abstract class BodyTag(name: String) : TagWithText(name) {
  55. fun b(init: B.() -> Unit) = initTag(B(), init)
  56. fun p(init: P.() -> Unit) = initTag(P(), init)
  57. fun h1(init: H1.() -> Unit) = initTag(H1(), init)
  58. fun a(href: String, init: A.() -> Unit) {
  59. val a = initTag(A(), init)
  60. a.href = href
  61. }
  62. }
  63. class Body : BodyTag("body")
  64. class B : BodyTag("b")
  65. class P : BodyTag("p")
  66. class H1 : BodyTag("h1")
  67. class A : BodyTag("a") {
  68. var href: String
  69. get() = attributes["href"]!!
  70. set(value) {
  71. attributes["href"] = value
  72. }
  73. }
  74. fun html(init: HTML.() -> Unit): HTML {
  75. val html = HTML()
  76. html.init()
  77. return html
  78. }