KotlinでイケてるDSLを作る

  • 28
    いいね
  • 0
    コメント

この記事は Kotlin Advent Calendar 2016 7日目の記事です。


今や「Androidの新規開発をするならKotlinを使え」と言われる(らしい)ほどの人気となったKotlin。
使ってみることを検討している方も、すでに使っている方もいらっしゃるかと存じます。

nullable型、拡張関数、interfaceのデフォルト実装、Property Delegation、名前付き引数、型推論、etc...
言語機能が強力なので、頭の中のものをスラスラと、しかも簡潔な形で書くことができます。

さて、その強力な言語機能を使うと、(Javaではどうにも難しかった)DSLを作ることだってできてしまいます。
(GradleもKotlinで書くことができるようになりましたし、その用途の広さはお墨付きです!!)

DSLを作る際に有用なKotlinの言語機能を紹介しつつ、実際にDSLを組み立ててみたいと思います。

材料

こういった言語機能を使用します。

  1. 拡張関数
  2. 引数の最後がラムダ式であれば、メソッド呼び出しの丸括弧をラムダ式前で閉じることができる(丸括弧の省略)
  3. ラムダ式のreturnは省略可能
  4. thisは省略可能
  5. レシーバーを指定した関数
  6. 中置関数

拡張関数

既存のクラスに、新しいメソッドを定義できる機能です。

// String型にgreetingというメソッドを作成する
fun String.greeting() {
    // thisは通常のインスタンスメソッドと同様に使用可
    print("Hello, ${this}!")
}

"John".greeting()  // -> "Hello, John!"

引数の最後がラムダ式であれば、メソッド呼び出しの丸括弧をラムダ式前で閉じることができる(丸括弧の省略)

短い言い表し方が無かったのでタイトル通りです。

:point_down: 以下のような関数があったとします。

fun hoge(i :Int, lambda: () -> String)

これを呼び出す際、以下の書き方をすることができます。

hoge(1) { "in lambda" }

// もちろん、以下のように書くこともできる。
// hoge(1, { "in lambda" })

引数がラムダ式だけであれば、 () は全て省略可能です。

fun moge(lambda: () -> String)



moge { "in moge lambda" }

ラムダ式のreturnは省略可能

ラムダ式内では、最後の行の評価値が自動的に戻り値として認識されます。

val l: (x: Int, y: Int) -> Int = { x * y }

// もちろん、以下のようにも書ける。
// val l: (x: Int, y: Int) -> Int = { return x * y }

thisは省略可能

Javaでも同様ですね。
メソッドのレシーバーを表す this は省略する事ができます。

レシーバーを指定した関数型

なんとKotlinでは関数型の宣言時に、レシーバーの型を指定することができます。
動的に拡張関数を作ることができる、とでもいいますか…
JavaScriptなどでは applycall でthisを別のオブジェクトに差し替えることができますが、それと同じようなことを、型を限定して行う感じです。

class Person(val name: String) {}

// `Person.() -> Unit` は「Person型をレシーバーとした、引数なし、戻り値なし(Unit型)の関数」の宣言
fun doSomething(lambda: Person.() -> Unit) {
    val p = Person("John")
    // Person型のインスタンスがレシーバーになれる
    p.lambda()
}

doSomething {
    // レシーバーは通常のインスタンスメソッドと同じようにthisで参照できる
    print("I'm ${this.name}.")   // -> "I'm John."

    // レシーバーのthisは省略できるので、こう書くこともできる
    // print("I'm ${name}.")
}

中置関数

関数の一種として中置関数を作ることもできます。
ただし中置関数の引数は一つだけである必要があります。

// 例:二次元ベクトルの加法を定義します
data class Vec2(val a: Int, val b: Int) {
    infix fun add(other: Vec2) = Vec2(this.a + other.a, this.b + other.b)
}

val v1 = Vec2(1, 1)
val v2 = Vec2(4, 4)
val v3 = v1 add v2   // -> Vec2(a = 5, b = 5)

// もちろん、拡張関数として作成することも可能
// infix fun Vec2.add(other: Vec2) = Vec2(this.a + other.a, this.b + other.b)

実演

拙作のバリデーションライブラリ Kenin は、主機能はJavaで開発されていますが、Kotlin向けのDSLも持っています。

Java版.java
// 「アルファベットのみか数字のみの文字列が、入力されている必要がある」の条件設定
KeninAndroid.
    create(
        // EditTextに対して
        mUserId,

        // 引数の条件全てを満たす必要がある
        CompositeCondition.and(
            // 入力必須
            Conditions.requireField(),

            // 引数の条件をいずれか満たす必要がある
            CompositeCondition.or(
                // アルファベットのみ
                Conditions.alphabet(),
                // 数字のみ
                Conditions.numeric()
            )
        )
    );

:point_down:

Kotlin版.kt
// 「アルファベットのみか数字のみの文字列が、入力されている必要がある」の条件設定
userId.kenin {
    requireField() and (alphabet() or numeric())
}

KotlinのDSLで書くと超スッキリですね!
読みやすくわかりやすい!

上で紹介した言語機能を活用して、作成していきましょう :muscle:

まずはKotlinでJavaのAPIを使って書き下してみます。
Javaで書いたときとほとんど変わらないですねぇ。

// val userId: EditText

KeninAndroid.
    create(
        userId,
        CompositeCondition.and(
            Conditions.requireField(),
            CompositeCondition.or(
                Conditions.alphabet(),
                Conditions.numeric()
            )
        )
    )

まずは 拡張関数 から使っていきましょう。
userId がEditTextですので、EditTextの拡張関数として kenin() を宣言します。

fun EditText.kenin(condition: Condition) 
        = KeninAndroid.create(this, condition)

userId.kenin(
    CompositeCondition.and(
        Conditions.requireField(),
        CompositeCondition.or(
            Conditions.alphabet(),
            Conditions.numeric()
        )
    )
)

すこしスッキリしました!

次は 丸括弧の省略ラムダ式のreturnは省略可能 を使って、Conditionの宣言部分をラムダ式で受け取れるようにしましょう。

fun EditText.kenin(initializer: () -> Condition) 
        = KeninAndroid.create(this, initializer())

userId.kenin {
   // CompositeCondition.and の前につけるべきreturnは省略されている
   CompositeCondition.and(
        Conditions.requireField(),
        CompositeCondition.or(
            Conditions.alphabet(),
            Conditions.numeric()
        )
    )
}

{} で囲えるとDSLっぽさが増しますね!

しかし、 Conditions, CompositeCondition などがどうにも余計です。消しましょう。

Conditions のクラスメソッドをKotlinの関数にしてもよいのですが、できるならDSLのスコープ内だけで使えてほしいものです。
変な場所で使えても、変にIDEの補完候補を増やしてしまうだけです。

ですので、 レシーバーを指定した関数型 を使って、DSL内だけで使えるように限定してしまいましょう。
さらに thisは省略可能 であることを使えば、表記がますます簡潔になります。

// `Conditions` を外すために設けたクラス
// こいつがDSLのブロック内で this になれば…!
class ConditionWrapper {
    fun requireField() = Conditions.requireField()
    fun alphabet() = Conditions.alphabet()
    fun numeric() = Conditions.numeric()
}

fun EditText.kenin(initializer: ConditionWrapper.() -> Condition) 
        = KeninAndroid.create(this, ConditionWrapper().initializer())

userId.kenin {
   CompositeCondition.and(
        // thisは省略できますね!
        this.requireField(),
        CompositeCondition.or(
            // 省略しました
            alphabet(),
            numeric()
        )
    )
}

いい感じになってきましたね!

最後に CompositeCondition を消しましょう。
メソッド名が andor など、いかにも条件演算子っぽいので、 中置関数 として宣言し、演算子らしく使えるようにします。

infix fun Condition.and(other: Condition) = CompositeCondition.and(this, other)
infix fun Condition.or(other: Condition) = CompositeCondition.or(this, other)


class ConditionWrapper {
    fun requireField() = Conditions.requireField()
    fun alphabet() = Conditions.alphabet()
    fun numeric() = Conditions.numeric()
}

fun EditText.kenin(initializer: ConditionWrapper.() -> Condition) 
        = KeninAndroid.create(this, ConditionWrapper().initializer())

userId.kenin {
   // 残念ながら結合順序は一定なので、 () で優先順位を明示する必要はあります
   requireField() and (alphabet() or numeric())
}

できました!!

まとめ

Kotlinの言語機能は強力です。
DSLを作ることだってできます。
ここでは簡単なDSLの作り方をご紹介しました。

アイデア次第で、また工夫次第で様々なDSLを作ることができます。

  • Gradle Script Kotlin
    • gradleのスクリプトをKotlinで書くためのDSL
  • Kotlin/kotlinx.html
    • HTMLを書くためのDSL
  • Anko
    • AndroidのLayoutをコードで書くためのDSL
  • TornadoFX
    • JavaFXを便利に書くためのライブラリ、LayoutやStyleSheetを記述するDSLも入っている
  • knit
    • JUnitのアサーションを短く書くためのDSL

また、Kotlin 1.1からは @DslMarker というアノテーションが導入されます
このアノテーションを付けることで、ブロックを入れ子にした際、内側のブロックから外側のブロックのメンバにアクセスできてしまうのを防ぐことができるようです。

KotlinでDSLを作る機運は、これからも高まって行きそうですね!

この投稿は Kotlin Advent Calendar 20167日目の記事です。