再帰関数のスタックオーバーフローを倒す話 その2.5
連載目次
- 再帰関数のスタックオーバーフローを倒す話 その1
- 再帰関数のスタックオーバーフローを倒す話 その1.5
- F#での「末尾」についての話
- 再帰関数のスタックオーバーフローを倒す話 その2
- .NETにおける末尾最適化の方法についての話
- 再帰関数のスタックオーバーフローを倒す話 その2.5 ← 今回
- 継続モナドと、F#の残念さの話
- 再帰関数のスタックオーバーフローを倒す話 その3
- すべてをあきらめて再帰をwhileに書き直す方法の話
はじめに
再帰関数のスタックオーバーフローを倒す話 その1ではCPS変換について、 再帰関数のスタックオーバーフローを倒す話 その1.5では末尾呼び出しについて、 再帰関数のスタックオーバーフローを倒す話 その2では2種類の末尾呼び出しの最適化について話しました。
今回は、継続渡しスタイルがつらい人*1をモナドで救う話をします。 最初にネタバレすると、結局F#では救われないことになりますが。 まぁ、これはあくまでその1のおまけとして読んでください。
前提知識は、上記記事(特にその1)を理解していることです。
継続渡しスタイルつらい話
階乗を求める関数fact
を考えましょう。
let rec fact n = if n = 0I then 1I else n * (fact (n - 1I))
これを使って、n
の階乗とm
の階乗の和を求める関数は、このように書けます。
let f n m = let factN = fact n let factM = fact m factN + factM
このfact
を継続渡しスタイルにしたfactCps
を考えてみます。
let rec factCps n cont = if n = 0I then 1I |> cont else factCps (n - 1I) (fun pre -> n * pre |> cont)
これを使って、同様にn
の階乗とm
の階乗の和を求める関数を書くと、こうなります。
let fCps n m cont = factCps n (fun factN -> factCps m (fun factM -> factN + factM |> cont))
元の普通のF#コードに比べると、このコードをすらっと読み下せる人は格段に減ります。 このコードをすらっと書ける人となるとさらに減ります。
このあたりが継続渡しスタイルのつらいところです。
コンピュテーション式が世界を救う
階乗を求める関数を使った2つのコードをちょっと並べてみましょう。
(* 普通のやつ *) let f n m = let factN = fact n let factM = fact m factN + factM
(* CPS版 *) let fCps n m cont = factCps n (fun factN -> factCps m (fun factM -> factN + factM |> cont))
その1でもみましたけど、let
のネスト*2がfun
のネストになっています。
コンピュテーション式の変換規則を知っている人であれば、これはそのままlet!
の変換規則に見えるでしょう。
そうです、継続渡しスタイルはコンピュテーション式の後ろに隠すことができるのです!
継続もにゃど
type ContinuationBuilder() = member __.Return(x) = (fun cont -> x |> cont) member __.Bind(cpsFun, rest) = (fun cont -> cpsFun (fun x -> rest x cont)) let cont = ContinuationBuilder()
さぁ準備は整いました!
cont
コンピュテーション式を使ってfCps
を書き直してみます。
(* contコンピュテーション式版 *) let fCps n m = cont { let! factN = factCps n let! factM = factCps m return factN + factM }
これなら、継続渡しスタイルの関数でも使える気がしませんか?
注目すべき点はいくつかあります。
まずは、fCps
から引数が一つ減ったように見えるようになりました。
実際は減っていないんですが、cont
コンピュテーション式を使う限り、隠された引数を意識する必要はありません。
次に、factCps
は最後の引数として継続を受け取るはずなのに、あたかもそんな引数ないかのように呼び出しているように見えます。
これがcont
コンピュテーション式の力で、継続を引き回す処理はContinuationBuilder
のBind
の中に隠蔽されています。
let! factN = factCps n ...
は
cont.Bind(factCps n, fun factN -> ...)
のように展開されます。
member __.Bind(cpsFun, rest) = (fun cont -> cpsFun (fun x -> rest x cont))
Bind
の定義はこのようになっており、factCps n
の部分はcpsFun
が受け取ります。
このcpsFun
の部分にfactCps n
を埋め込んでみると・・・
factCps n (fun factN -> ...)
と、どこかで見たことのある形が現れますね。
このように、継続渡しスタイルの関数に継続を渡す部分は、Bind
が裏でやってくれるのです。
これで継続渡しスタイルもこわくない!
factCpsをcontコンピュテーション式で書き直す
ここまで来たらfactCps
も書き直してしまいましょう。
let rec factCps n cont = if n = 0I then 1I |> cont else factCps (n - 1I) (fun pre -> n * pre |> cont)
これがこうです!
let rec factCps n = cont { if n = 0I then return 1I else let! pre = factCps (n - 1I) return n * pre }
だいぶ普通のコードっぽく読めるようになったのではないでしょうか? しかし、これには罠があります。
contコンピュテーション式の罠
このfactCps
に50000I
とid
関数を渡して起動すると、スタックオーバーフローが起きます。
なぜこのようなことが起こるのでしょうか?
factCps
のコンピュテーション式を展開してみましょう。
let rec factCps n = let b = cont if n = 0I then b.Return(1I) else b.Bind(factCps (n - 1I), (fun pre -> b.Return(n * pre)))
あれれ、factCps
の再帰呼び出し部分が末尾じゃなくなってます!
そらスタックオーバーフロー起きるわー。
Bind
にinline
を付けてみましょう。
Bindのinline化
ContinuationBuilder
を修正します。
type ContinuationBuilder() = member __.Return(x) = (fun cont -> x |> cont) member inline __.Bind(cpsFun, rest) = (fun cont -> cpsFun (fun x -> rest x cont)) let cont = ContinuationBuilder()
これで、factCps
は以下のように展開されるようになります。
let rec factCps n = let b = cont if n = 0I then b.Return(1I) else let f = factCps (n - 1I) (fun cont -> f (fun x -> b.Return(n * x) cont))
factCps
が!ラムダ式の!外にいる!!!
これはもうどうしようもありませんね。
まとめ
ということで、継続渡しスタイルのつらみをコンピュテーション式で軽減する話でした。 そして、継続渡しスタイルの末尾再帰関数をコンピュテーション式を使って書き直すと、末尾再帰にならずに死ぬ、という話もしました。 みなさん、注意しましょう。