言語内DSLをKotlinで

Kotlinで言語内DSLを作ってビルダーとして提供してみる。

Kotlinではコンストラクタ,関数へのデフォルト引数が許可されているため古典的なビルダーパターンは使うことが少ない。

古典的なビルダーパターンはインスタンスの生成を安全に保ちつつ柔軟に構築できるようにするのを目標にしていた。 ビルダーはオプショナルなチューニングパラメタを動的に受け入れ対象オブジェクトの生成を隠蔽する。 対象オブジェクトのコンストラクタを事前条件の確認などに注力させ複雑な生成過程から分離させることができる。

これは、たいてい他の言語でのデフォルト引数や名前付き引数に対する代替手段として使われる。 コンストラクタ,関数へのデフォルト値や名前付き引数が提供されているKotlinでは、この用途でのビルダーパターンは使わない。

だけど、さらに柔軟な方法でオブジェクトを構築したくなったりする時がある。 ビルド方法を隠蔽しつつ、欲しいオブジェクトの要件を定義する。これは言語だ。 そうするとオシャレに中置記法とか使いたくなったりする。 vagrant の設定ファイルは単なる ruby なんだよみたいな感じだ。 Kotlinでは出来てしまう。ということでやってみる。

言語内DSLが定義されたビルダークラスのスコープを準備して、スコープ関数(apply,with,run)経由で利用することにする。 次の3つの事実を組み合わせる。

infix キーワードを使うとメソッドに中置記法を許可することができる。 クラススコープに対して拡張関数を定義できる。そして拡張関数のコードはレシーバの public メンバへ this 経由でアクセスできる(また this は省略可能)。

スコープ関数よりも追加処理が必要だったりするならスコープ関数も自分で定義すれば良い。 というわけで具体例を書いてみる。一部、悪い設計で書いてる。あとで直す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import kotlin.concurrent.withLock

class Apple(val members: List<rule>) {
    companion object Builder {
        var members: List<rule> = emptyList()

        // This is a super bad code.
        private val lock = java.util.concurrent.locks.ReentrantLock()
        fun build(fn: Builder.() -> Unit): Apple {
            lock.withLock {
                try {
                    this.fn()
                    return Apple(members)
                } finally {
                    cleanup()
                }
            }
        }
        private fun cleanup() {
            members = emptyList()
        }

        // for DSL
        fun apple(vararg rules: rule): Apple {
            return Apple(rules.toList())
        }
        infix fun String.isA(other: String): rule {
            return rule(this, other)
        }
    }
    data class rule(val l: String, val r: String)
}

使ってみるとこんな感じ。 定義も呼び出しも、メモを残してる最中に雰囲気で書いてるため、細かいところで引っかかってコンパイラを通らないかも知れない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
val appleV1 = Apple.Builder.build {
   members += "orange" isA "fluit"
   members += "pine" isA "fluit"
}
val appleV2 = Apple.Builder.run {
   apple(
       "orange" isA "fluit",
       "pine" isA "fluit"
   )
}

これをまともなコードって捉えるかは、やりたいことを上手く表してるかとか素朴な実装が難しいかとかによるので一概には言えなそう。

ruby のオープンクラスで追加メソッドを生やすと全体に影響が出てしまいコードと実行結果の対応づけは動的にしか解決できないらしい。 拡張関数の場合はクラススコープのみなので混乱は少ないというか静的に動作を解決できる。

ダメな設計について

ビルダーを可変のシングルトンにして排他制御している。 これは酷い。リソースをコルーチン間で共有する必要はない。 なので直す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Apple(val members: List<rule>) {
    class Builder {
        var members: List<rule> = emptyList()

        // for DSL
        fun apple(vararg rules: rule): Apple {
            return Apple(rules.toList())
        }
        infix fun String.isA(other: String): rule {
            return rule(this, other)
        }
        companion object {
            fun build(fn: Builder.() -> Unit): Apple {
                val builder = Builder()
                builder.fn()
                return Apple(builder.members)
            }
        }
    }
    data class rule(val l: String, val r: String)
}

2つめの使い方は出来ないので run 前にインスタンス化が必要になる。

1
2
3
4
5
6
val appleV2 = Apple.Builder().run {
   apple(
       "orange" isA "fluit",
       "pine" isA "fluit"
   )
}

Apple.build() とかで呼べるようにメソッドの配置を変更するか、デフォルト値などの語彙は Buildercompanion オブジェクト内か構築対象の companion オブジェクト内に配置するのか?などの点で悩みは残る。

comments powered by Disqus