KotlinのType-Safe builderとかがなかなか良い、という話

kotlinの最初に読むであろう公式ドキュメントにも、普通にtype-safe builderという名前で紹介されている、 ごく入門的な機能なのだが、これがなかなか良い。 kotlinはみんな楽しく使っていると思うのだが、「kotlinのここが良い」という具体的な話をみんなあんましてない気がするので、たまには自分がしてみる。 といっても上記のドキュメント以上の話は別にしません。

ここで良いというのは、正確にはfunction literal with receiverという仕組みでDSLを作るのが良い、という話だが、分かりにくいのでタイトルはtype-safe builderで。

初めてtype-safe builderを見た時は「最近の言語はこの手の言語内DSL書ける仕組み、だいたいついてるよなぁ」くらいの認識だった。 実際同じように書ける言語はたくさんあると思う。

だが、使ってみるとkotlinのこの仕組みは凄く良い。 その良さを語る前に、とりあえずこんな風に使っている、という事を先に書こう。

ツリーを作る例

最初、こんなコードがあった。

    val body = Variable("body")
    val child = Variable("child")

    val sub = Subscript(body, child)

こういうツリーを作るコードがめちゃくちゃだるい。 例えば、以下みたいなコードになったりする。

val expr = SumExpression(Variable("a"), Variable("b"),
                Subscript(Variable("n"), Variable("x")))

さすがにもう泣いちゃう。

この時に、こんなコードを足す。

object ExprBuilder {
    fun v(name: String) = Variable(name)

    fun sub(a: Expr, b: Expr) =
        Subscript(a, b)

    fun sup(a: Expr, b: Expr) =
        Superscript(a, b)

    fun sum(body: Expr, sup: Expr? = null, sub: Expr? = null) =
        SumExpr(body, sup, sub)
}

fun build(body: ExprBuilder.()-> Expr) = ExprBuilder.body()

短い。これで、最後の例は以下のように書ける。

val expr = build {
    sum(v("a"), v("b"), sub(v("n"), v("x")))
}

buildの中だけでしか使わないので、vとかのメソッド名でもへっちゃら。 インテリセンスも利くし、どんどん中で複雑な事を書いていける。

assertの例

この前こんなコードがあった。

val parser = Parser("x")

val actual = parser.parse()

assertEquals(1, actual.size)
val node = actual[0]
assertTrue(node is NordMathOrd)

// for smart cast
if(node is NordMathOrd) {
    assertEquals("x", node.text)
    assertEquals(0, node.loc?.start)
    assertEquals(1, node.loc?.end)
    assertEquals(Mode.MATH, node.mode)
}

スマートキャストのために要らないif文書いているとか我ながら酷いが変数作るのかったるかった。 そこは本題じゃないが、とにかくこういうassertは読みにくい。

しかもチェックしたい物がnodeの全要素じゃない、という事が結構あるので、あんまり全部assertするようなカスタムassertを作りたいという訳でも無い。

こんな時に以下のようなコードを書く。

class NodeAsserter(val actual : NodeMathOrd) {
    fun text(expect: String) = assertEquals(expect, actual.text)
    fun start(expect: Int) = assertEquals(expect, actual.loc?.start)
    fun end(expect: Int) = assertEquals(expect, actual.loc?.end)
    fun mode(expect: Mode) = assertEquals(expect, actual.mode)
}

fun assertMathOrd(node: ParseNode, body: NodeAsserter.() -> Unit) {
    assertTrue(node is NodeMathOrd)
    // always succeed
    if(node is NodeMathOrd) {
        NodeAsserter(node).body()
    }
}

またゴミみたいなスマートキャストがあるがそこはいい。 これまた割と短い。これを使って、以下のように書ける。

assertMathOrd(actual[0]) {
    text("x")
    start(0)
    end(1)
    mode(Mode.MATH)
}

さて、テキストの所だけ変えたテストを書きたいとする。こうなる。

assertMathOrd(actual[0]) {
    text("y")
}

テストでassertしたい所だけしかコードに無いので何をテストしているのかが読みやすい。 この例は別にbuilderじゃないけど、この手のreceiver付きの関数リテラルは凄く良く使う。

この仕組みはめちゃくちゃシンプルなのが良い

さて、type-safe builderのドキュメントを読めば、そういうのが出来る、という事は分かる訳だが、使ってみるまで分からなかったのが、このシンプルさだ。

  1. receiver指定ありの関数リテラルが定義出来る
  2. 最後の引数が関数リテラルの時は、括弧の外に出せる
  3. 使う時に渡す関数リテラルは型推論される

この位の仕組みの組み合わせで出来ている。 一つ一つはシンプルで変なマジックも無い挙動。 で、組み合わせた結果もそんな変なコード生成が走る、とかでは無い。 インテリセンスが遅くなったりもしないし、コンパイルエラーが分かりにくいとか予想外の事が起きるとかいう事も無い。

どう動くかはほぼ自明で、スタックトレースもエラーも見やすいし、ワーニングなどのメッセージも分かりにくい事は無い。 とにかくマジックが何も無い、という所が素晴らしい。

使う方も簡単で、

  1. objectかclassで、DSLで使いたい短い名前のメソッドを定義する
  2. それをレシーバーにする関数リテラルを最後の引数にとってそれを呼ぶ関数を定義する

というだけだ。1は本質的に必要なほとんど最低限の作業で、2は一行。 これ以上手軽にこの仕組みを作るのも難しい、というレベル。

仕組みが簡単なので慣れれば読むのも簡単で、分からない事がほとんど無い。

その分「イマドキのDSLが強力な言語」と比較すれば、DSL自体で出来る事はそれほどでも無いのだが、 このそれ程でも無い事が「めちゃくちゃ簡単でシンプルに出来る」という所がこの機能のセールスポイントだ。

この事にドキュメントを読んだ時は気づいてなかった。 ただ使っていくと凄く良いね!これ。