Kotlinで継続
CPS(継続渡しスタイル, Continuation passing style)とは、継続を引数に取るようなプログラムの書き方のことです。
例えば値は次のように表現します。
// 通常の書き方
val n: Int = 3
// CPS
fun <T> cpsN(cont: (Int) -> T): T = cont(3)
関数は次のように表現します。
// 通常の書き方
fun plus(a: Int, b: Int): Int = a + b
// CPS
fun <T> cpsPlus(a: Int, b: Int, cont: (Int) -> T): T = cont(a + b)
見ての通り、コールバック関数を受け取ってそれを適用するようにしただけです。
このコールバック関数を継続と呼びます。
CPSの一般的な型は次の通りです。ほぼcpsN
の型がそれです。
typealias CPS<R, A> = ((A) -> R) -> R
CPSでは、次のようにしてプログラムを繋げていきます。
コールバック関数をどんどんネストさせるため、見ての通りどんどんネストが深くなっていきます。
また注目するポイントとして、最後の関数 { r -> println(r) }
だけCPSではありません。
cpsN { n ->
cpsM { m ->
cpsPlus(n, m) { r ->
println(r)
}
}
}
一見プログラムがネストだらけになって見づらくなっただけのように見えますが、それは本来暗黙的に扱われている継続という概念を明示的に扱っていることを意味しています。
もちろん継続を明示的に扱うことには意味があります。普段から利用している関数にもCPSであるようなものが数多くあります。
例えばリソース管理でお世話になるuse
や、スコープ関数などはまさにCPSの関数です。そしてこれらの関数が抱える「ネストが深くなる」という悩みは、まさに上でCPSのプログラムに対して抱いた不満と同じものです。
fun <T : Closeable?, R> T.use(block: (T) -> R): R
fun <T, R> T.let(f: (T) -> R): R
さて、前置きはここまでにして、Kotlinのsuspend関数はコンパイラによってCPS変換されます。
このようなsuspend関数は
suspend fun suspendPlus(n: Int, m: Int): Int = n + m
このように変換されます:
fun cpsPlus(n: Int, m: Int, cont: Continuation<Int>): Any?
戻り値がAny?
なのは、Suspendするか否かで特殊な値を返す場合があるからです。具体的には以下の2パターンがあります。(要出典)
// Suspendする場合
fun cpsPlus(n: Int, m: Int, cont: Continuation<Int>): Any? {
cont.resumeWith(Result.success(n + m))
return kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED
}
// Suspendしない場合
fun cpsPlus(n: Int, m: Int, cont: Continuation<Int>): Any? {
return n + m
}
ここでContinuation
は次のような型です。
interface Continuation<in T> {
val coroutineContext: CoroutineContext
fun resumeWith(result: Result<T>)
}
すなわち、大雑把に言えばCPS変換後の関数cpsPlus
は下のような型とみなすことができます。
fun cpsPlus(a: A, (Result<B>) -> Unit): Unit
R
がUnit
に固定されていますが、きちんと上で紹介したCPSの一般的な型に当てはめることができますね。
typealias CPS<R, A> = ((A) -> R) -> R
suspend関数はKotlinのコンパイラによってCPS変換をされますが、suspend関数を使うときはそれを意識する必要はありません。すなわちネストがやたら深くなって使いづらいことはありません。
つまりsuspendを利用すれば、コンパイラの支援によってCPSの利点をネストを浅く保ちつつ享受することができるというわけです。
最初に用意するのは継続渡しスタイルの世界の世界に入るための関数です。suspend関数を通常の関数から呼ぶことはできないため、その橋渡しをします。
import kotlin.coroutines.*
fun <T> runCont(
finalContinuation: CoroutineContext.(Result<T>) -> Unit = {},
block: suspend () -> T,
) {
block.startCoroutine(object : Continuation<T> {
override val context = EmptyCoroutineContext
override fun resumeWith(result: Result<T>) =
context.finalContinuation(result)
})
}
使い方は簡単です。runCont
を呼び出せば、そこに渡すブロック内ではsuspend関数が呼べるようになります。
runCont {
// suspend関数が呼べる
}
ひとつ実用的な例としてuse
のsuspend関数版を紹介します。
import kotlin.coroutines.*
import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED
import java.io.Closeable
suspend fun <T : Closeable> withCloseable(open: () -> T): T {
val resource = open()
return suspendCoroutineUninterceptedOrReturn { cont ->
try {
cont.resume(resource)
} finally {
resource.close()
}
COROUTINE_SUSPENDED
}
}
この関数を利用すれば、use
を使った場合深くネストしていくような操作を、ネストを浅く保ったまま書くことができるようになります。
runCont {
val src1 = withCloseable { openSomeResource1() }
val src2 = withCloseable { openSomeResource2() }
val dest = withCloseable { openSomeResource3() }
dest.write(concatResources(src1.read(), src2.read()))
}
他にも、呼び出した後は別のスレッドに切り替わる関数など、面白い性質を持つ関数を定義することもできます。(尤も非同期処理はkotlinx.coroutines
を使うべきですが)
suspend fun resumeOnAnotherThread(): Int {
return suspendCoroutineUninterceptedOrReturn { cont ->
Thread { cont.resume(42) }.start()
COROUTINE_SUSPENDED
}
}
呼び出しの前後で実行スレッドが変わります。これは継続を明示的に扱うことで得られる力を端的に示している気がします。
runCont {
println("ここはMainスレッドで実行される")
val n = resumeOnAnotherThread()
println("ここは別スレッドで実行される: $n")
}
Kotlinにはsuspendを利用したライブラリがいくつかあるので、そちらを見てみるのも面白いかもしれません。
この一連の投稿を書くにあたって参考にしたわけではないですが、例えばarrow-ktなどはそのような機能を持っているようです: