type: doc layout: reference category: "Syntax"

title: "Type-Safe Groovy-Style Builders"

构建器(builders)的概念在Groovy社区非常热门。 使用构建器我们可以用半声明(semi-declarative)的方式定义数据。 构建器非常适合用来生成XML组装UI组件, 描述3D场景,以及很多其他功能... 很多情况下,Kotlin允许检查类型的构建器,这样比Groovy本身提供的构建器更有吸引力。 其他情况下,Kotlin也支持动态类型的构建器。

一个类型安全的构建器的示例

考虑下面的代码。这段代码是从这里摘出来并稍作修改的:

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

fun result(args: Array<String>) =
  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://jetbrains.com/kotlin") {+"Kotlin"}

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

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

这是一段完全合法的Kotlin代码。(在IDEA中),可以点击函数名称浏览他们的定义代码。 这里.

构建器的实现原理

让我们一步一步了解Kotlin中的类型安全构建器是如何实现的。 首先我们需要定义构建的模型,在这里我们需要构建的是HTML标签的模型。 用一些类就可以轻易实现。 比如HTML是一个类,描述<html>标签;它定义了子标签<head><body>。 (查看它的定义下方.)

现在我们先回忆一下我们在构建器代码中这么声明:

html {
 // ...
}

这实际上是一个函数,其参数是一个函数字面量(查看这个页面的详细说明) 这个函数定义如下:

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

这个函数定义一个叫做init的参数,本身是个函数。实际上,它是一个扩展函数,其接受者类型为HTML(并且返回Unit)。 所以,当我们传入一个函数字面量作为参数时, 它被认定为一个扩展函数,从而在内部就可以使用this{: .keyword }引用了。

html {
  this.head { /* ... */ }
  this.body { /* ... */ }
}

(headbody都是HTML类的成员函数)

现在,和平时一样,this{: .keyword }可以省略掉,所以我们就可以得到一段已经很有构建器风格的代码::

html {
  head { /* ... */ }
  body { /* ... */ }
}

那么,这个调用做了什么? 让我们看看上面定义的html函数的函数体。它新建了一个HTML对象,接着调用传入的函数来初始化它,(在我们上面的HTML例子中,在html对象上调用了body()函数),接着返回this实例。 这正是构建器所应做的。

HTML类里定义的headbody函数的定义类似于html函数。唯一的区别是,它们将新建的实力先添加到html的children属性上,再返回:

fun head(init: Head.() -> Unit) {
  val head = Head()
  head.init()
  children.add(head)
  return head
}

fun body(init: Body.() -> Unit) {
  val body = Body()
  body.init()
  children.add(body)
  return body
}

实际上这两个函数做的是完全相同的事情,所以我们可以定义一个泛型函数initTag

  protected fun initTag<T : Element>(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"}
  }
  // ...
}

所以基本上,我们直接在标签体中添加文字,但前面需要在前面加一个+符号。 事实上这个符号是用一个扩展函数plus()来定义的。 plus()是抽象类TagWithText(Title的父类)的成员函数。

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

所以,前缀+所做的事情是把字符串用TextElement对象包裹起来,并添加到children集合上,这样就正确加入到标签树中了。 所有这些都定义在包com.example.html里,上面的构建器例子在代码顶端导入了。 下一节里你可以详细的浏览这个名字空间中的所有定义。

com.example.html的完整定义

下面是包com.example.html的定义(只列出了上面的例子中用到的元素)。它可以生成一个HTML树。 代码中大量使用了扩展函数扩展函数字面量技术

package com.example.html

interface Element {
    fun render(builder: StringBuilder, indent: String)

    override fun toString(): String {
        val builder = StringBuilder()
        render(builder, "")
        return builder.toString()
    }
}

class TextElement(val text: String): Element {
    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent$text\n")
    }
}

abstract class Tag(val name: String): Element {
    val children = arrayListOf<Element>()
    val attributes = hashMapOf<String, String>()

    protected fun initTag<T: Element>(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 (a in attributes.keySet()) {
            builder.append(" $a=\"${attributes[a]}\"")
        }
        return builder.toString()
    }
}

abstract class TagWithText(name: String): Tag(name) {
    fun String.plus() {
        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") {
    public var href: String
        get() = attributes["href"]!!
        set(value) {
            attributes["href"] = value
        }
}

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

附录.让Java类更好

上面的代码中有一段很好的:

  class A() : BodyTag("a") {
    var href: String
      get() = attributes["href"]!!
      set(value) { attributes["href"] = value }
  }

我们访问映射(Map) attributes的方式,是把它当作 "关联数组" (associate array)来访问的:用[]操作符。 依照编译器的惯例)它被翻译成get(K)set(K, V),正好。 但是我们说过,attributes是一个JavaMap,也就是说,它没有set(K, V)函数。(译注:Java的映射中的函数是put(K, V))。 在Kotlin中,这个问题很容易解决:

  fun <K, V> Map<K, V>.set(key: K, value: V) = this.put(key, value)

所以我们只要给Map类添加一个扩展函数set(K, V), 并委托Map类原有的put(K, V)函数,就可以让Java类使用Kotlin的操作符号了。