はじめに
非同期プログラミングを行う時など、ある関数にコールバック関数(クロージャー)を渡していく手法が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でもこのようなデータ構造を用いてよりよいプログラムを書けたらよいと思う。