類型安全建構器
透過將命名良好的函式作為建構器,並結合 帶接收者的函式字面值,可以在 Kotlin 中建立類型安全、靜態型別的建構器。
類型安全建構器允許建立基於 Kotlin 的領域特定語言 (DSLs),適用於以半宣告式方式建構複雜的階層式資料結構。建構器的範例使用案例包括:
請看以下程式碼:
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>
等子元素。 (請參閱其宣告 下方。)
現在,讓我們回想一下為什麼您可以在程式碼中這樣寫:
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
使用 DSLs 時,可能會遇到在特定上下文 (context) 中可以呼叫過多函式的問題。 您可以在 lambda 內部呼叫每個可用的 隱式接收者 的成員,從而得到不一致的結果,例如在另一個 head
內部出現 head
標籤:
html {
head {
head {} // should be forbidden
}
// ...
}
在此範例中,只有最近的隱式接收者 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 { } // error: a member of outer receiver
}
// ...
}
請注意,仍然可以呼叫外部接收者的成員,但為此您必須明確指定該接收者:
html {
head {
this@html.head { } // possible
}
// ...
}
您也可以將 @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 {
// Access to title, head or other functions of outer receivers is restricted here.
}
}
}
在 lambda 中只能存取最近接收者的成員和擴展,防止了巢狀作用域之間意外的互動。
當隱式接收者的成員和 context 參數 的宣告在作用域中具有相同名稱時, 編譯器會報告警告,因為隱式接收者被 context 參數遮蔽。 為了解決這個問題,請使用 this
限定詞來明確呼叫接收者,或使用 contextOf<T>()
來呼叫 context 宣告:
interface HtmlTag {
fun setAttribute(name: String, value: String)
}
// Declares a top-level function with the same name,
// which is available through a context parameter
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) { tag.setAttribute(name, value) }
fun test(head: HtmlTag, extraInfo: HtmlTag) {
with(head) {
// Introduces a context value of the same type in an inner scope
context(extraInfo) {
// Reports a warning:
// Uses an implicit receiver shadowed by a context parameter
setAttribute("user", "1234")
// Calls the receiver's member explicitly
this.setAttribute("user", "1234")
// Calls the context declaration explicitly
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
}