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<head><body>のような子要素を定義する<html>タグを記述するクラスです。 (その宣言は下記を参照してください。)

さて、なぜコードで次のように記述できるのかを思い出しましょう。

kotlin
html {
 // ...
}

htmlは実際には、ラムダ式を引数として取る関数呼び出しです。 この関数は次のように定義されます。

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

この関数は、それ自体が関数であるinitという名前のパラメータを1つ取ります。 関数の型はHTML.() -> Unitであり、これはレシーバーを持つ関数型です。 これは、HTML型のインスタンス(レシーバー)を関数に渡し、そのインスタンスのメンバーを関数内で呼び出すことができることを意味します。

レシーバーはthisキーワードを通じてアクセスできます。

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

(headbodyHTMLのメンバー関数です。)

さて、thisは通常通り省略でき、すでにビルダーのように見えるものが得られます。

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

では、この呼び出しは何をするのでしょうか?上記のhtml関数の本体を見てみましょう。 それはHTMLの新しいインスタンスを作成し、次に引数として渡された関数を呼び出すことによってそれを初期化し(この例ではこれはHTMLインスタンス上でheadbodyを呼び出すことに相当します)、その後、このインスタンスを返します。これこそがビルダーがすべきことです。

HTMLクラスのheadおよびbody関数は、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
}

実際には、これら2つの関数は全く同じことを行うため、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()演算を呼び出す関数呼び出しです。 その演算は実際には、TagWithText抽象クラス(Titleの親)のメンバーである拡張関数unaryPlus()によって定義されています。

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

したがって、ここでのプレフィックス+は、文字列をTextElementのインスタンスにラップし、childrenコレクションに追加することで、それがタグツリーの適切な一部となるようにします。

これらすべては、上記のビルダー例の先頭でインポートされているcom.example.htmlパッケージで定義されています。 最後のセクションでは、このパッケージの完全な定義を読み進めることができます。

スコープ制御: @DslMarker

DSLsを使用していると、コンテキスト内で呼び出し可能な関数が多すぎるという問題に遭遇することがあります。 ラムダ内で利用可能なすべての暗黙的なレシーバーのメソッドを呼び出すことができ、そのため、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を継承しています。 スーパークラスのみを@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アノテーションを関数型に直接適用することもできます。 @DslMarkerアノテーションを@Target(AnnotationTarget.TYPE)でシンプルに注釈付けしてください。

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

その結果、@DslMarkerアノテーションは関数型、最も一般的にはレシーバーを持つラムダに適用できるようになります。例えば、

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アノテーションは、明示的に指定しない限り、マークされたラムダの本体内での外部レシーバーへのアクセスを制限します。

kotlin
html {
    head {
        title {
            // Access to title, head or other functions of outer receivers is restricted here.
        }
    }
}

ラムダ内では最も近いレシーバーのメンバーと拡張のみがアクセス可能であり、ネストされたスコープ間での意図しない相互作用を防ぎます。

com.example.htmlパッケージの完全な定義

これがcom.example.htmlパッケージが定義されている方法です(上記の例で使用されている要素のみ)。 これはHTMLツリーを構築します。拡張関数レシーバーを持つラムダを多用しています。

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
}