ペンギン村 Tech Blog

技術をこよなく愛するエンジニア集団が在住するペンギン村から、世界へ役立つ(かもしれない)技術情報を発信する技術系ブログです。某アラレちゃんが済む村とは一切関係ありません。んちゃ!

Swiftと比較しながら見る、KotlinのDSLを支える技術

自己紹介

はじめまして、けものフレンズではサーバルちゃんが一番好きなペンギン村の住人@tobi462です。

自分の技術ブログ(My Favorite Things - Coding or die.)も持っているのですが、楽しそうな記事はこっちで書きたいなって気分です。

という感じで、一発目の記事なので自己紹介でした。

さて今回はKotlinのDSLを支える技術について、Swiftと比較しながら機能を見ていきたいと思います。

DSLとは

DSLは、ドメイン特化言語(Domain Specific Language)と呼ばれます。

あれこれ説明するよりも、実際のコード例を見るのが早いかもしれません。以下はkotlinx.htmlパッケージのAPIを使ってHTMLを組み立てる例です。

val html = createHTML().
        table {
            tr {
                td { +"Hello" }
            }
        }
println(html)

これは多くの方の予想どおり以下のような出力結果が得られます。

<table>
  <tr>
    <td>Hello</td>
  </tr>
</table>

これは間違いなくKotlinのコードであり、実際にコンパイルも出来ますが、一見すると他の言語のようにも見えます。

このようにプログラミング言語に備わった機能を利用し、APIなどを工夫し、特定のタスクを解きやすくするための専用の構文が用意されているかの様に見えるのがDSLの特徴です。

”ドメイン特化”と言われる理由が分かるかと思います。

ちなみにDSLとしては、SQLなど他の言語として書かれる「外部DSL」と、今回のようにその言語内で表現される「内部DSL」とがあります。

静的型付け言語における内部DSLは、コンパイル時に意図した構造かをチェックできるのに加え、IDEなどの補完を活用できるというのがメリットとして挙げられるかと思います。

KotlinのDSL

KotlinのDSLを支える言語仕様について、Kotlin in Actionでは以下が列挙されています。

Kotlinの機能 Swiftにおける機能
拡張関数 extensions
演算子オーバーロード 同様
メソッド規約 subscript
括弧の外側のラムダ 接尾クロージャ
中置呼び出し なし
レシーバ付きラムダ なし

見てのとおり、多くはSwiftでもサポートしていますが、一部はサポートされていません。

順に見ていきたいと思います。

拡張関数(extension function)

既存のクラスに対して、新しいメソッドやプロパティを定義できる機能です。

以下ではstrong()という、新たなメソッドを既存のStringクラスに追加しています。

fun String.strong(): String {
    return this + "!!"
}

"Hello".strong() // => "Hello!!"

Swiftでは以下のようになります。

extension String {
    func strong() -> String {
        return self + "!!"
    }
}

"Hello".strong() // => "Hello!!"

定義方法は異なりますが、呼び出し側からみるとKotlinもSwiftも同等です。

演算子オーバーロード

演算子が利用された時の処理を、独自にカスタマイズできる仕組みです。

Swiftは新たな演算子が自分で定義できるのに対し、Kotlinでは言語に用意された演算子以外をオーバーロードすることは出来ません

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

val p1 = Point(10, 20)
val p2 = Point(30, 40)
p1 + p2 // => Point(x=40, y=60)

// 通常の呼び方
p1.plus(p2)

Swiftでは以下のように独自の演算子を定義できます。

infix operator ++

func ++ (a: Point, b: Point) -> Point {
    return Point(x: a.x + b.x, y: a.y + b.y)
}

let p1 = Point(x: 10, y: 20)
let p2 = Point(x: 30, y: 40)
p1 ++ p2 // => Point(x: 40, y: 60)

一見すると、自由に演算子を定義できるSwiftの方が優れているように見えますが、Kotlinはあえて制限することでコードをシンプルに保つという言語思想のようです。

私見ですが、独自の演算子は関数型ライブラリを作成する時などによく使われる印象があるので、そうした際にはSwiftの方がより可読性の高いAPIを提供できるかもしれません。

メソッド規約

x = array[0]array[0] = xといったように、[]など特定の書き方をした際の挙動をカスタマイズする仕組みです。

Kotlinではoperatorというキーワードに加えて、規約で定められたシグネチャで実装することで実現できます。

以下では独自に定義したPointクラスに対して[]が利用できるようにしています。

operator fun Point.get(index: Int): Int {
    return when(index) {
        0 -> x
        1 -> y
        else -> throw IndexOutOfBoundsException()
    }
}

val p = Point(10, 20)
p[0] // => 10
p[1] // => 20

// 通常の呼び方
p.get(0) // => 10
p.get(1) // => 20

以下のように、代入時の挙動もカスタマイズ出来ます。

data class MutablePoint(var x: Int, var y: Int)

operator fun MutablePoint.set(index: Int, value: Int) {
    when (index) {
        0 -> x = value
        1 -> y = value
    }
}

val point = MutablePoint(10, 20)
point[0] = 30
point[1] = 40
println(point) // => MutablePoint(30, 40)

// 通常の呼び方
point.set(0, 30)
point.set(1, 40)

Swiftでは同様の機能はSubscriptと呼ばれるもので実現します。

extension MutablePoint {
    subscript(index: Int) -> Int {
        get {
            switch index {
            case 0:  return x
            case 1:  return y
            default: assert(false)
            }
        }
        set {
            switch index {
            case 0:  x = newValue
            case 1:  y = newValue
            default: assert(false)
            }
        }
    }
}

var mp = MutablePoint(x: 10, y: 20)
mp[0] = 30
mp[1] = 40
mp[0] // => 30
mp[1] // => 40

両言語とも引数の数を変更できる点は同じですが、Kotlinは他にもいくつか規約をサポートしています。

以下にKotlinでサポートされている規約をいくつか挙げてみます。

シンタックス 関数呼び出し
x[a, b] x.get(a, b)
x[a, b] = c x.set(a, b, c)
a in c c.contains(a)
start..end start.rangeTo(end)
val (a, b) = p val a = p.component1(); val b = p.component2()
a("hello") a.invoke("hello")

中でも最後のinvoke規約はDSLを支える上で便利な機能なので、詳しく見ていきたいと思います。

invoke規約

invoke規約とは、オブジェクトそれ自体をメソッドのように呼び出せるようにする仕組みです。

以下では、Pointオブジェクトそれ自体をp("Hello")という形で呼び出せています。

operator fun Point.invoke(prefix: String) {
    println("$prefix $this")
}

val p = Point(10, 20)
p("Hello") // => "Hello Point(10, 20)"

それ自体を呼び出せるという表現から、関数オブジェクトを思い浮かべる方も多いかもしれません。

実際、関数オブジェクトはinvoke規約を用いた仕組みで実現されています。見比べると先程のコードとの共通性を見つけられると思います。

val succ: (Int) -> Int = { it + 1 }
succ(1) // => 2

Kotlintestの例

一見すると、これはコードの意図が分かりづらくなるようにも見えますが、DSLを構築する上では便利です。

以下は、サードパーティ製のKotlintestを用いたテストコードの例です。

class PlusSpec : ShouldSpec({
    "1 + 1" {
        should("return 2") {
            (1 + 1) shouldEqual 3
        }
    }
})

詳細は割愛しますが、ここで注目したいのは"1 + 1" { ... }という、一見するとKotlinには見えないコードです。

コードを読むと、以下のようにString型に対してinvokeメソッドを追加することで実現されています。

operator fun String.invoke(init: () -> Unit): Unit {
    ...
}

引数として関数型() -> Unitを受け取るようになっており、呼び出し時にはラムダ式を利用しているのがポイントです。

先程のKotlintestのコードを省略せずに記述すると以下のようになります。

class PlusSpec : ShouldSpec({
    "1 + 1".invoke({
        should("return 2") {
            (1 + 1) shouldEqual 2
        }
    })
})

これは仕組みが分かりやすいという点では優れていますが、DSLの可読性という面ではノイズが多く、invoke規約によって可読性の高いDSLを提供することができる良い例になっていると思います。

括弧の外側のラムダ

最後の引数がラムダ式の場合、ラムダ式を引数の()の外に出すことができる機能です。

先程のinvoke規約で挙げたKotlintestのコードでも利用されています。

以下は標準APIであるfilter関数をただラップした、selectという拡張メソッドを定義する例です。(わかりやすさのためジェネリクスは使用していません)

fun <Int> List<Int>.select(predicate: (Int) -> Boolean): List<Int> {
    return this.filter(predicate)
}

val xs = listOf(1, 2, 3, 4)
xs.select { it % 2 == 0 } // [2, 4]

// 通常の呼び方
xs.select({ it % 2 == 0 })

Swiftでは以下のようになります。

extension Array {
    func select(_ predicate: (Element) -> Bool) -> [Element] {
        return self.filter(predicate)
    }
}

let xs = [1, 2, 3, 4]
xs.select { $0 % 2 == 0 } // => [2, 4]

// 通常の呼び方
xs.select({ it % 2 == 0 })

Kotlinの方が制約が多いですが、呼び出し側は両言語とも同じシンタックスになるのが分かります。

中置呼び出し

さて、ここからはSwiftには完全にない機能です。

中置呼び出しは、メソッド呼び出し時に.を利用せず、代わりにスペースを利用することが出来る機能です。infixキーワードをつけた関数が対象になります。

infix fun Int.add(x: Int): Int {
    return this + x
}

// 中置呼び出し
1 add 1  // => 2

// 通常
1.add(1) // => 2

中置呼び出しは、Mapを生成するための標準APIとしても利用されています。以下ではtoが中置呼び出しとして利用されています。

val dict = mapOf(1 to "one", 2 to "two")
dict[1] // => "one"
dict[2] // => "two"

他には先程invoke規約のところで上げた、KotlintestのAssertionコードも良い例です。

(1 + 1) shouldEqual 2

レシーバ付きラムダ

レシーバ付きラムダは、ラムダ式に暗黙のレシーバを渡し、ラムダ式の中でthisとして参照できる機能です。ポイントはthisを省略できるという点です。

標準APIのwithを見てみたいと思います。

val point = MutablePoint(10, 20)
with(point) {
    x = 50
    y = 60
}
println(point) // => MutablePoint(50, 60)

一見するとwithは言語に組み込まれた構文のように見えます。しかし、実体は単なるトップレベルの関数であり、withに渡した引数mpがラムダ式にレシーバとして渡されている、という仕組みです。

レシーバはthisとして参照できるので、省略しない場合は以下のようになります。

with(point) {
    this.x = 50
    this.y = 60
}

kotlinx.htmlの例

冒頭で紹介した、HTML生成のコードでもレシーバ付きラムダが利用されており、thisを省略しない場合は以下のようなコードになります。

val html = createHTML().
        table {
            this.tr {
                this.td { +"Hello" }
            }
        }

このようにレシーバ付きラムダは、構造化された宣言的なDSLを作るのに便利です。

Swiftでは

ちなみにレシーバ付きラムダがないSwiftでも、一見するとそのような機能が使われているように見えるコードが書かれたりすることがあります。以下は、BDDフレームワークであるQuick/Nimbleのコード例です。

class SampleTest: QuickSpec {
    override func spec() {
        describe("plus") {
            context("1 + 1") {
                it("2") {
                    expect(1 + 1) == 2
                }
            }
        }
    }
}

先程から挙げているKotlinのDSLコードに非常に似ていますし、状態を保持しながらコードが実行されるようにみえるため、暗黙的なレシーバが利用されているようにも見えます。

しかし、describecontextなどは単なるトップレベル関数であり、共通のシングルトンインスタンスに状態を追加しているだけです。

言い換えるとSwiftでは、先程のwithのような関数をスマートな形で実装できません。 qiita.com

機能のまとめ

さて、ここまでKotlinのDSLを支える機能を、Swiftと比較しながら見てきました。

それぞれの機能に細かい差はありましたが、大局で見ると以下のようになりました。

Swiftにもある機能

Kotlinの機能名 通常の書き方 DSLライクな書き方
拡張関数 String.strong(x) x.strong()
演算子オーバーロード 1.plus(2) 1 + 2
getメソッド規約 point.set(0, 10) point[0] = 10
括弧の外側のラムダ filter({ ... }) filter { ... }

Kotlinにのみの機能

Kotlinの機能名 通常の書き方 DSLライクな書き方
中置呼び出し 1.to("one") 1 to "one"
レシーバ付きラムダ sb.append("1"); sb.append("2") with(sb) { append("1"); append("2") }

最後に

シンタックスの面では結構似ている両言語ですが、このようにDSLを支える機能という観点から見てみると、それぞれの言語の思想が見えてきて面白いのではないかと思います。

といった形で、今回は機能紹介に特化した感じになってしまいましたが、本記事を締めくくりたいと思います。(機会があればもう少しDSLに立ち入った記事を書くかも?)

そんな感じで、今年も(?)よろしくお願いします。