はじめに
非同期プログラミングを行う時など、ある関数にコールバック関数(クロージャー)を渡していく手法がSwiftの界隈では広く行われている。ただ、コールバック関数の中でさらにコールバック関数を受け取るような処理を行うと、すぐにコールバック地獄と呼ばれるコールバック関数が大量にネストする状態になって、プログラムの見通しが悪くなってしまう恐れがある。そこで、この記事ではContと呼ばれるデータ構造を紹介し、それによってコールバック関数をデータ構造に隠蔽する方法について考える。この記事で紹介するソースコードは次のリポジトリに置かれている。
この記事を読んで何か疑問に思った点や改善点などがあれば、気軽にコメントなどで教えて欲しい。
コールバック関数を使った足し算
コールバック関数を受け取るような関数は、主にネットワーク通信が発生する非同期処理で発生しがちだが、ここでは話を単純にするため、非常に簡単な「足し算」を用いてコールバック処理を考える。
まず、整数の足し算をする関数plusを考えると、次のようになる。
func plus(_ x: Int, _ y: Int) -> Int {
return x + y
}
関数plusを用いて1 + 2 + 3を実行するには次のようにする。
plus(1, plus(2, 3))
この実行結果は次のようになる。
6
さて、この関数plusは見て分かるように2回呼び出されている。そこで関数plusを改造してコールバックを受け取るようにしたらどうなるだろうか。
func plus_k(_ x: Int, _ y: Int, _ k: (Int) -> Int) -> Int {
return k(x + y)
}
新たに追加した引数kがコールバックである。xとyの足し算結果を、コールバック処理に渡して、最終的にコールバック関数の返り値を関数の返り値としている。この関数plus_kを用いて$1 + 2 + 3$を実行すると次のようになる。
plus_k(1, 2, { r in r + 3 })
これを実行すると正しく$6$が計算されることが分かる。ところで、足し算を実装しようとしているので、{ r in r + 3 }に含まれるr + 3の部分も関数plus_kを用いて計算したい。ところがこの後にはコールバック処理が何もない。そこで、受け取った値をそのまま返す関数{ x in x }を使って次のように書き換える。
plus_k(1, 2, { r in plus_k(r, 3, { x in x }) })
このように、何もしないコールバック関数{ x in x }を使って、plus_kのみで足し算を表現することができると分った。さて、では$1 + 2 + 3 + 4 + 5$を見ると、次のようになる。
plus_k(1, 2, { x in
plus_k(x, 3, { y in
plus_k(y, 4, { z in
plus_k(z, 5, { a in
a
})
})
})
})
このようにすれば正しく計算はできるものの、コールバック地獄になることが分かる。このように、簡単な例であってもコールバック関数を取る関数を繋げていくと、簡単にコールバックだらけになる。
コールバックを表すデータ構造Cont
さて、このようにコールバックを取る関数を使うと容易にネストが深くなるので、このコールバックを表すようなデータ構造を用いることにする。コールバックを閉じ込めるデータ構造Contは次のような定義になる。定義している関数mapやflatMapについては例で解説するので、この定義で詳細に追う必要はない。
struct Cont<R, A> {
let run: ((A) -> R) -> R
init(_ f: @escaping ((A) -> R) -> R) {
self.run = f
}
func flatMap<B>(_ f: @escaping (A) -> Cont<R, B>) -> Cont<R, B> {
return Cont<R, B> {
k in self.run { a in f(a).run(k) }
}
}
func map<B>(_ f: @escaping (A) -> B) -> Cont<R, B> {
return Cont<R, B> {
k in self.run { a in k(f(a)) }
}
}
}
まず、Cont<R, A>の型パラメータRとAは何を表しているのかを説明する。型パラメータAはコールバックの引数の型であり、型パラメータRはコールバックの結果の型である。すると、関数runは型(A -> R) -> Rであり、そのままコールバックであると言える。
Contを用いた足し算の関数
さきほど定義したコールバック関数を使う足し算の関数plus_kは次のようになっていた。
func plus_k(_ x: Int, _ y: Int, _ k: (Int) -> Int) -> Int {
return k(x + y)
}
Contを用いたバージョンを作るまえに、少し関数plus_kについて考える。まずコールバック関数の引数は現在の足し算の結果であるから、Intであるし、$1 + 2 + 3 + \dots $と後続の足し算がコールバック関数であるので結果の型はIntであった。従って次のようになる。
| コールバックの引数の型 | コールバックの結果の型 |
|---|---|
Int |
Int |
つまり、この場合Contの型パラメータRとAはいずれもIntとなることが分かる。Contを用いた足し算の関数は次のようになる。
func plus_cont(_ x: Int, _ y: Int) -> Cont<Int, Int> {
return Cont { k in k(x + y) }
}
次のように実行できる。Contを実行して結果を得るときには、関数plus_kと同様に{ x in x }を用いる。
plus_cont(1, 2).run { x in x }
実行結果は次のようになる。
3
$1 + 2 + 3$のように後続の処理を繋げる場合は、flatMap関数を使って次のように書けばよい。
plus_cont(1, 2).flatMap { x in plus_cont(x, 3) }.run { x in x }
この結果は次のようになる。
6
正しく計算ができることが分かる。ただ、これではflatMapになにかの関数が入っているだけのように見え、結局複雑な計算を行うとネストが深くなりそうである。そこで、まずはplus_contをカリー化して次のような関数plus_cont_curryを作る。
func plus_cont_curry(_ x: Int) -> (Int) -> Cont<Int, Int> {
return { y in plus_cont(x, y) }
}
すると、さきほどの$1 + 2 + 3$は関数plus_cont_curryを用いて次のようになる。
plus_cont(1, 2).flatMap(plus_cont_curry(3)).run { x in x }
さらに、$1 + 2 + 3 + 4 + 5$も次のようになる。
plus_cont(1, 2).flatMap(plus_cont_curry(3))
.flatMap(plus_cont_curry(4))
.flatMap(plus_cont_curry(5))
.run { x in x }
flatMapを表す二項演算子>>>=を作れば、さらに短くなる。
precedencegroup Left {
associativity: left
}
infix operator >>>=: Left
func >>>=<R, A, B>(_ ma: Cont<R, A>, _ f: @escaping (A) -> Cont<R, B>) -> Cont<R, B> {
return ma.flatMap(f)
}
(plus_cont(1, 2) >>>= plus_cont_curry(3)
>>>= plus_cont_curry(4)
>>>= plus_cont_curry(5))
.run { x in x }
mapとリフト
たとえば、足し算の結果を数値(Int)から文字列(String)へ変換したくなった時、mapを使うと便利である。次のようなIntからStringへ変換する関数があるとする。
func int_to_string(_ n: Int) -> String {
return String(n)
}
この関数を用いて、Cont<Int, String>を次のようにmapを用いて作成できる。
let _: Cont<Int, String> = (plus_cont(1, 2) >>>= plus_cont_curry(3)).map(int_to_string)
このように、任意の関数A -> Bを用いてCont<R, A>からCont<R, B>を作ることができる。
異なる型を持つContの合成
足し算を行うCont<Int, Int>を作成したが、たとえば次のように与えられた文字列にhelloを連結して返すような関数add_helloと、文字列の文字数を数える関数countをそれぞれ次のように作ったとする。
func add_hello(_ str: String) -> Cont<Int, String> {
return Cont { k in k("hello" + str) }
}
func count(_ str: String) -> Cont<Int, Int> {
let n = str.characters.count
return Cont { k in k(n) }
}
関数add_helloはCont<Int, String>を返す。一方で関数countはCont<Int, Int>を返す関数である。この二つの関数を合成することができる。これらを用いて、たとえば文字列world!にhelloを加えた文字列の長さに、10を足すという処理を次のように書ける。
add_hello("world!").flatMap(count).flatMap(plus_cont_curry(10)).run { x in x }
結果は次のようになる。
21
さきほどの二項演算子>>>=を使えば短くなる。
(add_hello("world!") >>>= count >>>= plus_cont_curry(10)).run { x in x }
このように、Contの関数flatMapを用いることで、Cont<R, A>の型パラメータRが等しければ、Cont<R, B>のような型を持つ値と合成できる。
まとめ
このようにContというデータ構造を用いることでコールバックを隠蔽できることを紹介した。このContは継続モナドと呼ばれ、HaskellやScalaなどで実装や研究が盛んに行われている。Swiftでもこのようなデータ構造を用いてよりよいプログラムを書けたらよいと思う。