自己紹介
はじめまして、けものフレンズではサーバルちゃんが一番好きなペンギン村の住人@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コードに非常に似ていますし、状態を保持しながらコードが実行されるようにみえるため、暗黙的なレシーバが利用されているようにも見えます。
しかし、describe
やcontext
などは単なるトップレベル関数であり、共通のシングルトンインスタンスに状態を追加しているだけです。
言い換えると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に立ち入った記事を書くかも?)
そんな感じで、今年も(?)よろしくお願いします。