Skip to content

型別安全建構器

透過將命名良好的函式作為建構器,並結合帶有接收者的函式文字使用,可以在 Kotlin 中建立型別安全、靜態型別的建構器。

型別安全建構器允許建立基於 Kotlin 的領域特定語言 (DSLs),以半宣告式的方式建構複雜的階層式資料結構。建構器的範例使用案例包括:

  • 使用 Kotlin 程式碼產生標記,例如 HTML 或 XML
  • 為網頁伺服器設定路由:Ktor

考慮以下程式碼:

kotlin
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> 等子元素。 (參閱下方完整的 com.example.html 套件定義。)

現在,讓我們回顧一下為什麼您可以在程式碼中這樣寫:

kotlin
html {
 // ...
}

html 實際上是一個函式呼叫,它接受一個 Lambda 表達式作為參數。 這個函式定義如下:

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

這個函式接受一個名為 init 的參數,它本身就是一個函式。 該函式的型別是 HTML.() -> Unit,這是一個 帶有接收者的函式型別。 這意味著您需要將一個 HTML 型別的實例 (一個 接收者) 傳遞給該函式, 然後您可以在函式內部呼叫該實例的成員。

接收者可以透過 this 關鍵字存取:

kotlin
html {
    this.head { ... }
    this.body { ... }
}

(headbodyHTML 的成員函式。)

現在,this 可以像往常一樣被省略,您就會得到一個看起來非常像建構器的東西:

kotlin
html {
    head { ... }
    body { ... }
}

那麼,這個呼叫做了什麼?讓我們看看上面定義的 html 函式的本體。 它建立一個 HTML 的新實例,然後透過呼叫作為參數傳入的函式來初始化它 (在這個範例中,這歸結為在 HTML 實例上呼叫 headbody),然後它返回此實例。 這正是建構器應有的行為。

HTML 類別中的 headbody 函式以類似於 html 的方式定義。 唯一的區別是它們將建立的實例加入到外部 HTML 實例的 children 集合中:

kotlin
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

kotlin
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
    tag.init()
    children.add(tag)
    return tag
}

因此,現在您的函式非常簡單:

kotlin
fun head(init: Head.() -> Unit) = initTag(Head(), init)

fun body(init: Body.() -> Unit) = initTag(Body(), init)

您可以使用它們來建立 <head><body> 標籤。

這裡要討論的另一件事是如何將文字加入標籤本體。在上面的範例中,您會這樣寫:

kotlin
html {
    head {
        title {+"XML encoding with Kotlin"}
    }
    // ...
}

所以基本上,您只是將一個字串放在標籤本體內部,但在它前面有一個小小的 +, 因此它是一個函式呼叫,呼叫一個前綴 unaryPlus() 運算子操作。 該操作實際上是由一個擴充函式 unaryPlus() 定義的,它是 TagWithText 抽象類別 ( Title 的父類別) 的成員:

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

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

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

作用域控制:@DslMarker

在使用 DSL 時,可能會遇到在上下文中呼叫過多函式的問題。 您可以在 Lambda 內部呼叫每個可用的隱式接收者的成員方法,從而得到不一致的結果, 例如在另一個 head 內部有一個 head 標籤:

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

在這個範例中,只能存取最近的隱式接收者 this@head 的成員;head() 是外部接收者 this@html 的成員,因此呼叫它應該是非法的。

為了解決這個問題,有一個特殊的機制來控制接收者作用域。

為了讓編譯器開始控制作用域,您只需要使用相同的標記註解來註解 DSL 中使用的所有接收者的型別。 例如,對於 HTML 建構器,您可以宣告一個註解 @HTMLTagMarker

kotlin
@DslMarker
annotation class HtmlTagMarker

如果一個註解類別被 @DslMarker 註解註解,則稱其為 DSL 標記。

在我們的 DSL 中,所有的標籤類別都繼承自同一個父類別 Tag。 只需要註解父類別 Tag 加上 @HtmlTagMarker,之後 Kotlin 編譯器就會將所有繼承的類別視為已註解:

kotlin
@HtmlTagMarker
abstract class Tag(val name: String) { ... }

您不需要註解 HTMLHead 類別加上 @HtmlTagMarker,因為它們的父類別已經被註解:

kotlin
class HTML() : Tag("html") { ... }

class Head() : Tag("head") { ... }

加入此註解後,Kotlin 編譯器就知道哪些隱式接收者屬於同一個 DSL,並只允許呼叫最近接收者的成員:

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

請注意,仍然可以呼叫外部接收者的成員,但為此您必須明確指定此接收者:

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

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

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

結果,@DslMarker 註解可以應用於函式型別,最常見的是應用於帶有接收者的 Lambda。例如:

kotlin
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 本體中外部接收者的存取,除非您明確指定它們:

kotlin
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

kotlin
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
}