型安全なビルダー
名前が適切に付けられた関数をビルダーとして、レシーバー付き関数リテラルと組み合わせることで、Kotlinで型安全な静的型付けされたビルダーを作成できます。
型安全なビルダーは、複雑な階層型データ構造を半宣言的な方法で構築するのに適した、Kotlinベースのドメイン固有言語 (DSL) の作成を可能にします。ビルダーの使用例は次のとおりです。
次のコードを考えてみましょう。
import com.example.html.* // 以下の宣言を参照
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
は実際にはラムダ式を引数として取る関数呼び出しです。 この関数は次のように定義されています。
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
DSLを使用する際、コンテキスト内で呼び出し可能な関数が多すぎるという問題に遭遇することがあります。 ラムダ内で利用可能なすべての暗黙のレシーバーのメソッドを呼び出すことができ、その結果、別の 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
}