型安全なビルダー
適切に命名された関数をビルダーとして、レシーバーを持つ関数リテラルと組み合わせることで、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
は<head>
や<body>
のような子要素を定義する<html>
タグを記述するクラスです。 (その宣言は下記を参照してください。)
さて、なぜコードで次のように記述できるのかを思い出しましょう。
html {
// ...
}
html
は実際には、ラムダ式を引数として取る関数呼び出しです。 この関数は次のように定義されます。
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
この関数は、それ自体が関数であるinit
という名前のパラメータを1つ取ります。 関数の型は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
}
実際には、これら2つの関数は全く同じことを行うため、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()
演算を呼び出す関数呼び出しです。 その演算は実際には、TagWithText
抽象クラス(Title
の親)のメンバーである拡張関数unaryPlus()
によって定義されています。
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
したがって、ここでのプレフィックス+
は、文字列をTextElement
のインスタンスにラップし、children
コレクションに追加することで、それがタグツリーの適切な一部となるようにします。
これらすべては、上記のビルダー例の先頭でインポートされているcom.example.html
パッケージで定義されています。 最後のセクションでは、このパッケージの完全な定義を読み進めることができます。
スコープ制御: @DslMarker
DSLsを使用していると、コンテキスト内で呼び出し可能な関数が多すぎるという問題に遭遇することがあります。 ラムダ内で利用可能なすべての暗黙的なレシーバーのメソッドを呼び出すことができ、そのため、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) { ... }
HTML
やHead
クラスを@HtmlTagMarker
で注釈付けする必要はありません。なぜなら、それらのスーパークラスはすでに注釈付けされているからです。
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
アノテーションを関数型に直接適用することもできます。 @DslMarker
アノテーションを@Target(AnnotationTarget.TYPE)
でシンプルに注釈付けしてください。
@Target(AnnotationTarget.TYPE)
@DslMarker
annotation class HtmlTagMarker
その結果、@DslMarker
アノテーションは関数型、最も一般的にはレシーバーを持つラムダに適用できるようになります。例えば、
fun html(init: @HtmlTagMarker HTML.() -> Unit): HTML { ... }
fun HTML.head(init: @HtmlTagMarker Head.() -> Unit): Head { ... }
fun Head.title(init: @HtmlTagMarker Title.() -> Unit): Title { ... }
これらの関数を呼び出すとき、@DslMarker
アノテーションは、明示的に指定しない限り、マークされたラムダの本体内での外部レシーバーへのアクセスを制限します。
html {
head {
title {
// Access to title, head or other functions of outer receivers is restricted here.
}
}
}
ラムダ内では最も近いレシーバーのメンバーと拡張のみがアクセス可能であり、ネストされたスコープ間での意図しない相互作用を防ぎます。
com.example.htmlパッケージの完全な定義
これがcom.example.html
パッケージが定義されている方法です(上記の例で使用されている要素のみ)。 これはHTMLツリーを構築します。拡張関数とレシーバーを持つラムダを多用しています。
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
}