CPS変換はじめてみた
インタプリタ自作によってプログラミング言語の諸概念を学んでいく「Essentials of Programming Languages」の第5章、第6章はどちらも継続渡しスタイルについて語っている。
第5章は「インタプリタを継続渡しスタイルで書くとどうなるか」という話を掘り下げていく内容で、このブログの「めざそう言語処理系の沼」シリーズの元ネタにもなっている。
第6章は「プログラムを継続渡しスタイルに変換してから実行するインタプリタ」についての話だ。ソースに対してCPS変換を行うことで実行が末尾呼び出しの形になったり、複雑な制御フローをCPSの形として脱糖することでインタプリタの評価関数には手を入れることなく実装できたりする、らしい。さらにはCPS変換したソースは最適化した上でコンパイルするのが楽らしい。EoPLにはそういった話題はないが、「最新コンパイラ構成技法」(通称「虎本」)の著者としても有名なAppelの「Compiling with Continuations」というそのままズバリな著作もある。
後者を読んでいくための下準備としても、EoPLの第6章を読んで簡単なCPS変換を行うインタプリタを実装してみたい。
eopl-ocaml/by-lang/cpsmin at master · zehnpaard/eopl-ocaml · GitHub
実装してみた。
ソース言語
言語機能としては
- S式
- if分岐
- 多引数のビルトイン・ユーザ定義関数
- 関数の仮引数としての変数
- リテラル整数
が存在している。let
とletrec
はまだない。
使用例
例1変換前:
(+ 1 (- 5 3) 2)
変換後:
(- 5 3 (proc [gensym2] (+ 1 gensym2 2 (proc [gensym1] gensym1))))
例2変換前:
((proc [x] (+ (* x x) 1)) 5)
変換後:
((proc [gensym7] (gensym7 5 (proc [gensym6] gensym6))) (proc [x gensym8] (* x x (proc [gensym9] (+ gensym9 1 gensym8)))))
例3変換前:
(if (zero? 3) (+ 1 2) (+ 3 4))
変換後:
(zero? 3 (proc [gensym1] (if gensym1 (+ 1 2 (proc [gensym0] gensym0)) (+ 3 4 (proc [gensym0] gensym0)))))
となる。
コード
CPS変換するためのコードはTocps
モジュールに入っている:
eopl-ocaml/tocps.ml at master · zehnpaard/eopl-ocaml · GitHub
CPS変換するためには、継続の仮引数に今まで使われていない新しい変数を割り当てる必要がある。そのためのgensym
関数:
let gensym = let n = ref (-1) in let f () = incr n; "gensym" ^ (string_of_int !n) in f
gensym0
などの変数名を作成する。本当は「継続の中に入る式を検査して自由変数が被っていないかを調べる」という手順があったほうがいいのだがめんどくさいので割愛。ソースプログラムにはgensym_
という形の変数は出てこないと仮定する。(パーサの仕様でユーザが変数として使えない文字列を使っても良かったかもしれない・・・)
CPS変換する場合、関数呼び出しの一部にリテラルや変数が入っていた場合はその部分は継続化しないようにしたい(継続化してもどっちみち引数である変数がその部分に入るし・・・)。というわけでリテラルや変数かどうかを判定するis_simple
関数:
let is_simple = function | Const _ | Var _ -> true | _ -> false
ただのパターンマッチである。
次にCPS変換の心臓部になる関数。しかし名前をつけるのが億劫だったのでg
。「今からおまえの名前はg
だ。いいかい、g
だよ」:
let rec g k e = match e with | Const _ | Var _ -> Call(k,[e])
g
の引数は
- 継続
k
- CPS変換する式
e
となる。e
がリテラルや変数だった場合、継続k
にその式を適用するCall(k,[e])
がCPS変換の結果となる。
関数適用をCPS変換する場合:
| Call(proc,es) -> let es = proc::es in let es' = List.filter (fun e -> not @@ is_simple e) es in let vs' = List.map (fun _ -> gensym ()) es' in let evs = List.rev @@ List.combine es' vs' in let f e = match List.assoc_opt e evs with | None -> e | Some v -> Var(v) in let es = List.map f es in let cont_body = Call(List.hd es,List.tl es @ [k]) in let g' cont_body (e, v) = g (Proc([v],cont_body)) e in List.fold_left g' cont_body evs
関数と実引数のうち、「変数あるいはリテラル整数」ではない(つまりis_simple
でない)ものをes'
というリストに入れ、それらにgensym
による変数を割り当てる。それらだけ入れ替えたcont_body
という式を作り、最終的にList.fold_left
を使ってis_simple
でない要素の分だけ入れ子状態の継続にしている。
if
のケース:
| If(cond,yes,no) -> let v = gensym () in let cont = Proc([v],If(Var(v),g k yes,g k no)) in g cont cond
条件文にあたるcond
のところだけ継続化。今見直してみると、ここもis_simple
のチェックを入れたほうが良かった。
関数定義のCPS変換:
| Proc(ss,body) -> let v = gensym () in Call(k,[Proc(ss@[v], g (Var v) body)])
処理としては:
という式を返す流れとなる。
これでg
の定義は終わり。
あとは終端継続を定義して、それをg
に渡すことでトップレベルの式をCPS変換するTocps.f
を定義:
let f e = let v = gensym () in g (Proc([v],Var v)) e
終端継続は値を受け取ってそのまま返す(Proc([v],Var v))
という関数になる。
不満
Tocps.f
はExp.t -> Exp.t
で、まるで「同じインタプリタで実行できる言語内での変換」であるように見える。が実際はそうではない。
前述の例をとってみると:
(+ 1 (- 5 3) 2) (- 5 3 (proc [gensym2] (+ 1 gensym2 2 (proc [gensym1] gensym1))))
+
や-
といったビルトイン関数の挙動が変わっている。具体的には最終引数に継続をとるようになっている。
このことによって、(+ 1 (- 5 3) 2)
をCPS変換なしにインタプリタに実行させようとすると「最後の引数が関数ではない」とエラーを吐く。
実行できないのはいいとして、それなら実行時エラーではなくコンパイルが通らないほうがいい。
というわけでCPS変換前と後では型を変えたほうがいいように思うので、次はその変更を加えてみたい。