Skip to content

型安全なビルダー

名前が適切に付けられた関数をビルダーとして、レシーバー付き関数リテラルと組み合わせることで、Kotlinで型安全な静的型付けされたビルダーを作成できます。

型安全なビルダーは、複雑な階層型データ構造を半宣言的な方法で構築するのに適した、Kotlinベースのドメイン固有言語 (DSL) の作成を可能にします。ビルダーの使用例は次のとおりです。

  • Kotlinコードでマークアップを生成する (HTMLやXMLなど)
  • ウェブサーバーのルーティング設定 (Ktor)

次のコードを考えてみましょう。

kotlin
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> のような子要素を定義します。 (その宣言は以下を参照してください。)

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

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

DSLを使用する際、コンテキスト内で呼び出し可能な関数が多すぎるという問題に遭遇することがあります。 ラムダ内で利用可能なすべての暗黙のレシーバーのメソッドを呼び出すことができ、その結果、別の 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
}