導入
あまりKotlinそれ自体で話題になっているわけではないのですが、Kotlinは「コンテクスト指向なプログラミング言語だ」とたまに言及されることがあります。たとえばKotlinのコンパイラチームの方が書いている記事にそのような用語で言及されているのを見かけます。
私はKotlinに入門した際に、この言語はスコープの単位でいろいろ物事が整理されるように作られていそうだと感じていました。たとえばスマートキャストのように、特定の操作をして以降のスコープにおいては、変数の型付けが絞られる機能を見かけるほか、標準ライブラリにスコープ関数が用意されているなどです。このことを会社内で「スコープ指向」と個人的に呼んでいたりしました。コンパイラチームの方がコンテクスト指向として似たような話を言及していたのを見かけたとき、やはりそうだったのかと思いました。
Kotlinには高階関数があります。他のプログラミング言語と比べて大きく機能は変わりませんが、Kotlinではこの高階関数には「コンテクスト」と呼ばれる特別な意味があります。たとえば、関数fooが高階関数action: Actinable -> Unitを受け取るものであった場合、この関数はActionableをコンテクストに持つと言えます。
fun foo(action: Actinable -> Unit): Unit
Kotlinにはもう一つの特徴的な機能として、拡張関数と呼ばれるものがあります。拡張関数は特定の型に対して、後から追加で関数を生やすことができる機能です。これにより既存のclassやdata classの実装をあとから拡張することができます。拡張関数はレシーバ(つまり拡張対象の型)のプライベートメンバにはアクセスできないため、カプセル化に違反することはありません。たとえば、関数fooがExtendable(これをレシーバと呼びます)に対して実装される場合、次のように書くことができます。
// 拡張関数 fun Extendable.foo(): Unit // メンバー内拡張関数 interface SomeScope { fun Extendable.foo(): Unit }
コンテクスト指向プログラミングとは、コンテクストという概念と拡張関数の合わせ技です。つまり、特定のコンテクストに対する拡張関数を用意しておき、高階関数でコンテクストを生成し、用意した拡張関数を呼び出します。拡張関数はコンテクスト単位で個別実装が呼び出されて、目的に応じて呼び出される関数を切り替えながら処理を実装します。
fun foo(action: Actinable -> Unit): Unit fun Actionable.onClick(handle: ClickHandle.() -> Unit): Unit foo { // Actionableのコンテクスト it.onClick { // ClickHandleのコンテクスト } }
プログラミング言語一般の理論的な話でいえば、これはある種のアドホック多相であると言えます。Kotlinの言語デザインチームが議論しているところによると、完全にすべての機能を実現できているとは言えないものの、これは型クラスの一種であると捉えてよいとしている文章が見つかります。つまるところ、私はこの機能が関数型プログラミングの特定の概念の影響を大きく受けており、またそれらの機能の利点を同様に享受できると考えています。Kotlinに関数型プログラミングからの少なからぬ影響を感じています。
Kotlinにおける高階関数
2種類の高階関数
具体例に入る前に、少しKotlinの高階関数について整理しておきたいと思います。Kotlinでは高階関数はネストさせながら利用することが多いですが、ネストさせると問題になるのが、高階関数の持つ一時変数のスコープです。Kotlinではこの点をきれいに解決するために手段が用意されています。
Kotlinにおける高階関数は2種類あります。通常の高階関数と、レシーバ付き関数です。
両者により実現できる結果はまったく同じですが、両者のもつ実装時における意味は異なっています。例題を通じてそれぞれの高階関数の違いを探ることにします。
スコープ関数
Kotlinの標準ライブラリにはさらに、あるオブジェクトのコンテクスト内で任意のコードブロックを実行することを目的とした高階関数が用意されています。あるオブジェクトに対してこの関数を呼び出すことで一時的にスコープを生成し、そのオブジェクトに対して名前なしでアクセスしつつ、何かしらの処理を実行します。
具体的にはlet、run、apply、also、withがそれです。これらの関数の定義は次のようになっています(詳しい使い分けなどは公式によるガイドを参照してください)。
- let:
fun <T, R> T.let(block: (T) -> R): R - run:
fun <R> run(block: () -> R): Rorfun <T, R> T.run(block: T.() -> R): R - apply:
fun <T> T.apply(block: T.() -> Unit): T - also:
fun <T> T.also(block: (T) -> Unit): T - with:
fun <T, R> with(receiver: T, block: T.() -> R): R
ところで「コンテクストとなるオブジェクトに対してどのように参照するか」が2パターンであったことに気づかれたでしょうか。つまりitで参照するか、thisで参照するかのどちらかでどのスコープ関数を利用したいかの分岐が走ります。これは実質的に先ほど説明した、Kotlinにおける高階関数の二分類と対応しています。
使い分けについてはいろいろと議論があるようですが、概ね高階関数の二分類における使い分けとどの型を返したいかで考えれば良いでしょう。オブジェクトに対してthisアクセスするのが適切なケース、つまりオブジェクトに対してビルダーパターンのように設定を加える場合には、applyなどを用いるのが正しい、などです。またwithは特殊ケースではありますが、特定のコンテクストを切って以降はそのコンテクスト下で特定の処理を行うという情報を明示する際に使えます。
コンテクスト指向プログラミングで何ができるか?
では、Kotlinのコンテクスト指向プログラミングは何を実現できるのでしょうか?私が思う代表的な例は、たとえば下記のようなものです。
- 型安全なビルダー
- DSLを実装できる。たとえばGradleのようなビルド定義を書けるようになる。
- Result型との組み合わせ。エラーを伝播できるコンテクストを用意し、実質的にScalaのfor-yieldに近い書き心地を実現できる。
- スコープが適切に整理されたコルーチンを提供する。
とくにコルーチンはコンテクスト指向プログラミングを使用した代表例であると考えているので、これについて簡単に説明します。
Kotlinのコルーチンは、kotlinx.coroutinesというライブラリと組み合わせて使用します。このライブラリではコルーチンを開始する際にたとえばcoroutineScopeという関数を呼び出し、この関数の持つコンテクスト内でコルーチンに関連した操作を行う関数を呼び出して使用します。
coroutineScope関数は次のような定義になっています。引数はレシーバー付き関数リテラルになっており、このブロックではCoroutineScopeというコンテクストが生成されます。
suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R
CoroutineScopeには、そのコンテクストでのみ呼び出せるよう実装された関数があります。たとえばasyncやlaunchといった関数があります。async関数を例にとります。この関数が呼び出されると、一旦処理の実行は遅延しておき、await関数が呼び出されたタイミングで評価を行います。担う役割としてはRustのFutureが近いです。
async関数はCoroutineScopeに対する拡張関数として実装されています。したがってCoroutineScopeのインスタンスに対して呼び出すか、あるいはCoroutineScopeをコンテクストに持つブロックの内部で呼び出すかでこの関数を呼び出しできます。
fun <T> CoroutineScope.async( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T ): Deferred<T>
Context Parametersによるコンテクスト指向プログラミングの拡張
定義と解決したい課題
Kotlin 2.2.0からContext Parametersという機能が導入されました。これによりコンテクスト指向プログラミングがさらに強化されました。この機能は、他のプログラミング言語の文脈では型クラスと呼ばれる機能を実質的に提供していると言えます。Scalaのimplicitに非常によく似た機能だとドキュメントでも言及されています。
Context Parametersは、関数に渡すコンテクストを明示できる機能です。拡張関数も半分くらいはこの役割を果たしていますが、Context Parametersは、
- 複数個のコンテクストを渡すことができます。
- 関数(親)の呼び出している関数(子)に、さらにコンテクストを伝播させることができます。
などの追加機能が含まれています。
ではなぜContext Parametersが必要になったのでしょうか?Context Parametersは実は、前身となるContext Receiversの後継機能にあたりますが、そのContext ReceiversのDesign Docに課題意識が書かれています。これを軽く見ていきます。
従来のContext Receiversにはいくつか制約がありました。その制約とは次のようなものです(詳細はドキュメントにおけるこの部分を参照のこと)。
- メンバー拡張関数をサードパーティのクラス内で宣言することはできませんでした。
- メンバー拡張関数は常に拡張関数なため、常にレシーバーを伴う呼び出ししかできませんでした。これによりアクションの対象となるオブジェクトが存在しないトップレベルの関数を呼び出すことはできませんでした。
- コンテクストとして指定できるレシーバーはひとつに限定されます。複数の異なるコンテクストを宣言することはできませんでした。
まずメンバー拡張関数をサードパーティ製のクラス内で宣言することはできません。これは自身のアプリケーションのスコープとライブラリのスコープとの責任分解点を考えれば当たり前の仕様ではあります。たとえば次のようなことはできませんでした。
// io.ktor.client.statement.HttpResponse<T>に対して自分のプロジェクトからJSON Bodyを受け取る関数を生やすことはできない。 class HttpResponse<T> { fun JsonSerializeScope.toJsonBody(): String = TODO() }
また、メンバー拡張関数は常に拡張関数であったため、entity.doAction()のようにレシーバを伴う関数呼び出ししかできませんでした。つまりdoAction()のようなトップレベル関数の呼び出しを特定のコンテクストでのみ呼び出すといったことは難しかったのです。
最後に、メンバー拡張関数ないしは拡張関数を用いている場合、コンテクストとして指定できるレシーバーはひとつに限定されます。複数個のコンテクストを渡すことは原理的には難しかったのです。2個や3個くらいまでであればPairやTripleを使えば実現できると言えばできたでしょうが、それ以上のコンテクスト数になると面倒さや煩雑さが勝ります。
Context Parametersを利用するとこれらの制約を解決することができる、というわけです。
できること
Context Parametersを利用することで、たとえば次のような機能を実装できます。
今回はモノイド、RaiseDSLを解説します。
モノイド
Context Parametersを利用するとモノイドをスマートに実装することができます。インタフェースMonoid<T>を用意し、opとunitの2つの関数を用意しておきます。opはいわゆる結合演算を司ります。unitは単位元を意味します。
interface Monoid<A> { fun op(lhs: A, rhs: A): A fun unit(): A }
たとえばIntとStringに対するそれぞれのモノイドの個別実装を用意したとします。Monoidインタフェースをそれぞれのobjectに対して実装します。
object IntMonoid : Monoid<Int> { override fun op(lhs: Int, rhs: Int): Int = lhs + rhs override fun unit(): Int = 0 } object StringMonoid : Monoid<String> { override fun op(lhs: String, rhs: String): String = lhs + rhs override fun unit(): String = "" }
Context Parametersでコンテクストを受け取る関数を試しに用意します。この関数はジェネリックな型Aのリストを受け取り、内部的にはfoldを呼び出します。その際Monoidインタフェースが持つunitとopの2つの関数を呼び出します。foldの操作はモノイドを活用して表現できます。
context(monoid: Monoid<A>) fun <A> sum(list: List<A>): A = list.fold(monoid.unit(), monoid::op)
あとは、IntMonoidとStringMonoidそれぞれのコンテクストを用意します。たとえば次のようにスコープ関数のひとつであるwithを使用すると、それぞれコンテクストを用意することができます。注目したいのは、用意したい関数はすでにContext Parametersによって抽象化されており、コンテクストを切り替えるだけで適切なモノイド実装が呼び出されているという点です。下記は最終的な全体のコードです。
package com.github.yuk1ty.contextParametersExample interface Monoid<A> { fun op(lhs: A, rhs: A): A fun unit(): A } object IntMonoid : Monoid<Int> { override fun op(lhs: Int, rhs: Int): Int = lhs + rhs override fun unit(): Int = 0 } object StringMonoid : Monoid<String> { override fun op(lhs: String, rhs: String): String = lhs + rhs override fun unit(): String = "" } context(monoid: Monoid<A>) fun <A> sum(list: List<A>): A = list.fold(monoid.unit(), monoid::op) fun callMonoid() { // IntMonoidのコンテクストを生成する with(IntMonoid) { val intList = listOf(1, 2, 3) // IntMonoidがcontextに渡される val intSum = sum(intList) println("intSum = $intSum") // 6 } // StringMonoidのコンテクストを生成する with(StringMonoid) { val stringList = listOf("a", "b", "c") // StringMonoidがcontextに渡される val stringSum = sum(stringList) println("stringSum = $stringSum") // "abc" } }
これを意味するところは、Scalaのimplicitでできたようなアドホック多相を関数に対して付与できるということだろうと考えています。ただしKotlinの場合は少々制約があるように思われます。具体的には、モナドのような高階カインド型(List[T]とOption[T]の、ListやOption部分も抽象化するジェネリクスを付与できること。ScalaではF[_]やM[_]として表現されることが多く、このFやMにListやOptionが入ってきます)はKotlinで素直に実装するのは難しいのではないかと思われます。[*1]
Raise DSL
Kotlinのエラーハンドリングは現時点では例外を送出する手法がメインであると言えます。Javaと同様に例外をthrowすることで、例外が発生した場合はそこで処理を中断させ、呼び出し元の関数に例外をフィードバックする機構です。
しかし例外を用いたエラーハンドリングは、たとえば型付けの点で困難を抱えています。Kotlinの場合はとくに検査例外を呼び出し元で必ずしもハンドリングしなくてもよい仕様になっていますが、関数のコールスタックの深いところから投げられる例外のすべてを把握するのは困難であり、実務ではこの例外の扱いに非常に苦労することになります。
これを解消する手段のひとつがRaise DSLと呼ばれる手法です。Raise DSLはRaise<E>という型に例外の型情報を落としておきながらも、例外発生時の処理の中断も比較的手軽に実装できる手法です。この手法はResult<T, E>のようなwrapper型の扱いがどちらかというと得意でないKotlinにとって、wrapper型を使用せずに例外型をコード上に落としておける便利な手法のひとつです。
Raise DSLはまず、Raise<E>というインタフェースを用意するところからはじまります。このインタフェースは、主には例外をどう扱うかのコンテクスト提供するスコープになります。今回は便宜のために受け取った例外は、最終的にはすべて送出する手法を採用しておきます。もちろん、エラー時には特別な型を返すようにするなど他の実装手法を導入することもできるでしょう。
interface Raise<E : Exception> { // 今回はエラーを単に送出するだけにする fun raise(error: E): Nothing = throw error }
次にRaiseというコンテクスト内で渡されたブロックを実行するハンドラー関数を用意します。この関数はRaise<AppError>のコンテクスト(今回は匿名オブジェクトの生成を用いた)を用意し、コンテクストのスコープをwith関数によって切り出し、そのスコープ内でhandle関数に渡されるblockを実行します。block自体はRaise<AppError>をコンテクストとして持っています。
// AppError用のコンテクストをハンドリングする関数を定義する。 inline fun <T> handle(block: context(Raise<AppError>) () -> T): T { return try { val raiseImpl = object : Raise<AppError> {} with(raiseImpl) { block() } } catch (e: Exception) { // 何かしらのハンドリングをする。今回は便宜のために単に呼び出し元に伝播する。 throw e } }
最後に、このコンテクストの内部で処理を実行する関数を記述してみます。負の値を受け取った場合にエラーを返す関数を用意します。この関数はやはりRaise<AppError>をコンテクストパラメータで受け取っており、エラーが発生する条件を満たすと内部でRaise#raiseを呼び出し処理を中断します。エラーが発生しない場合は通常の処理フローでそのまま値を返します。
sealed class AppError : Exception() { object NegativeNumber : AppError() } context(raiseCtxt: Raise<AppError>) fun validateNumber(x: Int): Int { if (x < 0) { raiseCtxt.raise(AppError.NegativeNumber) } else { return x } }
このvalidateNumberは最後、handle関数のblockに渡されて実行されます。最後handle関数はやはり例外を返してくるものの、その例外の型はhandle関数のシグネチャやvalidateNumber関数のシグネチャを見ればわかる、というわけです。下記に最終的なコードの全体を示します。
package com.github.yuk1ty.contextParametersExample interface Raise<E : Exception> { // 今回はエラーを単に送出するだけにする fun raise(error: E): Nothing = throw error } // AppError用のコンテクストをハンドリングする関数を定義する。 inline fun <T> handle(block: context(Raise<AppError>) () -> T): T { return try { val raiseImpl = object : Raise<AppError> {} with(raiseImpl) { block() } } catch (e: Exception) { // 何かしらのハンドリングをする。今回は便宜のために単に呼び出し元に伝播する。 throw e } } sealed class AppError : Exception() { object NegativeNumber : AppError() } context(raiseCtxt: Raise<AppError>) fun validateNumber(x: Int): Int { if (x < 0) { raiseCtxt.raise(AppError.NegativeNumber) } else { return x } } fun callRaiseDSL() { // こちらは通常のバリデーションが通り、値が返ってくる。 val result = handle { validateNumber(1) } // バリデーションの結果AppError.NegativeNumberが送出される。 val error = handle { validateNumber(-1) } }
関数型プログラミング言語からの影響
まずこの機能を見た瞬間に思い浮かべるのはScala2のimplicitやScala3のusingやgivenといったキーワードによって実現される機能でしょう。実際デザインドキュメント内にも、Scalaでの次のコードは、
// Can be called only in the scope with the given of Ordering[Person] type def printPersons(s: Seq[Person])(using ord: Ordering[Person]) = ...
Kotlinにスムーズに変換することができる、と書かれています。
// Can be called only in the scope with the context receiver of Comparator<Person> type context(Comparator<Person>) fun printPersons(s: Sequence<Person>) = TODO()
ScalaのimplicitとContext Parametersとは非常に近い機能であるという言及もDesign Doc上にあります。実際のところ今回試したようにモノイドを実装できたり、他のプログラミング言語でいう型クラスのような実装を実現できるようですね。Kotlinのコンテクスト指向プログラミングが抱える課題を解決する目的で開発が進む機能ではありますが、結果としてこうした関数型プログラミング言語で見る機構を実現できるようになっているのは、非常におもしろい点だと思います。
次に思い浮かべるのは、この機能はいわゆるAlgebraic EffectやCoeffectsっぽいのではないだろうか?という点です。実際デザインドキュメント内にはこれら2つについて「似ている機能」として言及があります。
先に例としてあげた「Raise DSL」はAlgebraic Effectとよく似ているかもしれません。実際先ほどのコードを例に考えてみると、
- 操作それ自体は
raiseという関数で定義されています。これが呼び出されると、呼び出し元の関数の計算を中断し、指定されたエラーを投げた状態を表現します。 handler関数内では、Raise<AppError>インタフェース用のスコープ関数がハンドラーとして機能し、raise関数による操作が行われたあとどう振る舞うかを実装します。
Context ParametersはTyped Coeffect Systemsの限定的な形式であるとドキュメントにも言及があります。私も詳しくCoeffectsについて理解しているわけではありませんが、関数が実行される際に特定のコンテクストが必要であることをコンパイル時にチェックする点については、Coeffectsに近い概念ではないかと考えています。
Kotlinは関数型プログラミングの概念をいくつか念頭に置きながら、Kotlinの機能にフィットする形でそれらの概念をカスタマイズし、文法機能に落として実装しているプログラミング言語であると考えています。
最後に
この記事の内容は、先日開催された勉強会で登壇した内容向けに書き下したものです。この記事の内容を元にしたスライドは下記です。
Kotlinはマルチパラダイムなプログラミング言語で、Javaに寄せた書き方や関数型プログラミングのエッセンスを取り入れた書き方ができます。しかし最もKotlinらしく、かつ言語の型システムや文法機能を十分に利用できるのは、このコンテクスト指向なのだと最近思うことが多いです。高階関数と拡張関数の二つの機能を中心にAPIや日々の業務コードを設計すると、Kotlinの力[*2]を十分に発揮させられるのだと思います。KotlinはScalaなどに比べるとできる型付けのバラエティや文法機能は少なくなるよう設計されていると思っていますが、そうした機能や型付けが「ない」分は、コンテクスト指向プログラミングで十分解決できると思っています[*3]。
*1:Arrow-Ktに導入されていそうなKindという型を導入すればやれなくもない?試していないのでわかりませんが。この記事が参考になりそうです: Advanced FP for the Enterprise Bee: Higher Kinded Types | by Garth Gilmour | Google Developer Experts | Medium
*2:型付けはシンプルでありつつも、型安全性をしっかり享受できるであるとか、スコープの細かく区切られた関心の分離が行き届いたコードを書けることなど。現時点ではうまく表現できませんがまだあると思います。
*3:たとえばモナドの大半はAlgebraic Effectsで表現できるとか。この資料を参照: Algebraic Effectsとモナドで遊ぶ - Google スライド