跳至主要内容

類型安全的建構器 (Type-safe builders)

透過將命名良好的函式作為建構器,並結合帶接收者的函式字面值 (function literals with receiver),可以在 Kotlin 中建立具型別安全 (type-safe) 的靜態型別 (statically-typed) 建構器。

具型別安全 (type-safe) 的建構器允許建立基於 Kotlin 的領域特定語言 (Domain-Specific Language, DSL),適用於以半宣告式 (semi-declarative) 的方式建構複雜的階層式資料結構。建構器的範例使用案例包括:

  • 使用 Kotlin 程式碼產生標記 (markup),例如 HTML 或 XML
  • 為網路伺服器配置路由: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 中實現一個具型別安全 (type-safe) 的建構器。 首先,定義你要建構的模型。在這種情況下,你需要對 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 型別的實例 (一個 接收者 (receiver)) 給該函式, 並且你可以在該函式內部呼叫該實例的成員。

接收者 (receiver) 可以透過 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() 運算。 該運算實際上是由一個擴充函式 (extension function) unaryPlus() 定義的,該函式是 TagWithText 抽象類別 (abstract class) (一個 Title 的父類) 的成員:

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

所以,這裡的前綴 + 所做的是將一個字串包裝到一個 TextElement 的實例中,並將其添加到 children 集合中, 使其成為標籤樹的適當部分。

所有這些都定義在一個 com.example.html 套件中,該套件在上面的建構器範例的頂部導入。 在最後一節中,你可以閱讀此套件的完整定義。

範圍控制:@DslMarker

當使用 DSL 時,人們可能會遇到在上下文中可以呼叫太多函式的問題。 你可以在 Lambda 表達式中呼叫每個可用的隱式接收者 (implicit receiver) 的方法,因此得到不一致的結果, 例如另一個 head 中的 head 標籤:

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

在本範例中,只有最近的隱式接收者 (implicit receiver) this@head 的成員必須可用;head() 是 外部接收者 (outer receiver) this@html 的成員,因此呼叫它必須是非法的。

為了解决這個問題,有一種特殊的機制來控制接收者 (receiver) 範圍。

要使編譯器開始控制範圍,你只需使用相同的標記註解來註解 DSL 中使用的所有接收者 (receiver) 的型別。 例如,對於 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 編譯器會知道哪些隱式接收者 (implicit receiver) 是同一個 DSL 的一部分,並且只允許呼叫最近的接收者 (receiver) 的成員:

html {
head {
head { } // error: a member of outer receiver
}
// ...
}

請注意,仍然可以呼叫外部接收者 (outer receiver) 的成員,但要做到這一點,你必須明確指定此接收者 (receiver):

html {
head {
this@html.head { } // possible
}
// ...
}

你也可以直接將 @DslMarker 註解應用於函式型別 (function types)。 只需使用 @Target(AnnotationTarget.TYPE) 註解 @DslMarker 註解:

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

因此,@DslMarker 註解可以應用於函式型別,最常見的是帶接收者 (receiver) 的 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 表達式的主體中的外部接收者 (outer receiver) 的存取,除非你明確指定它們:

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

只有最近的接收者 (receiver) 的成員和擴充功能才能在 Lambda 表達式中存取,從而防止巢狀範圍之間發生意外互動。

com.example.html 套件的完整定義

以下是如何定義 com.example.html 套件(僅使用上面的範例中使用的元素)。 它建構了一個 HTML 樹。它大量使用了擴充函式 (extension functions)帶接收者的 Lambda 表達式 (lambdas with receiver)

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
}