型安全なビルダー
レシーバーを持つ関数リテラルと組み合わせて、適切に命名された関数をビルダーとして使用することで、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.
}
}
}ラムダ内では最も近いレシーバーのメンバーと拡張のみがアクセス可能であり、ネストされたスコープ間での意図しない相互作用を防ぎます。
暗黙的なレシーバーのメンバーと、コンテキストパラメータからの宣言が、同じ名前を持つスコープ内にある場合、コンパイラは、暗黙的なレシーバーがコンテキストパラメータによってシャドウされるため、警告を報告します。 これを解決するには、this修飾子を使用してレシーバーを明示的に呼び出すか、contextOf<T>()を使用してコンテキスト宣言を呼び出します。
interface HtmlTag {
fun setAttribute(name: String, value: String)
}
// 同じ名前のトップレベル関数を宣言します。これはコンテキストパラメータを通じて利用可能です
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) { tag.setAttribute(name, value) }
fun test(head: HtmlTag, extraInfo: HtmlTag) {
with(head) {
// 内部スコープで同じ型のコンテキスト値を導入します
context(extraInfo) {
// 警告を報告します:
// コンテキストパラメータによってシャドウされた暗黙的なレシーバーを使用します
setAttribute("user", "1234")
// レシーバーのメンバーを明示的に呼び出します
this.setAttribute("user", "1234")
// コンテキスト宣言を明示的に呼び出します
contextOf<HtmlTag>().setAttribute("user", "1234")
}
}
}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
}