めざそう言語処理系の沼 〜shift/resetへの旅 〜 その8 再帰
普通の関数が実装できたので、今度は再帰関数を定義できるようにする。
具体的にはこのような構文:
(letrec [factorial [n] (if (= 1 n) 1 (* n (factorial (- n 1))))] (factorial 6))
あるいは:
(letrec [(odd? [x] (if (= 0 x) false (even? (- x 1)))) (even? [x] (if (= 0 x) true (odd? (- x 1))))] (odd? 101))
といった形で相互再帰もできるようになる。
前回との差分:
Comparing v0.7...v0.8 · zehnpaard/kontlang · GitHub
まずExp.t
にLetRec
を追加:
type t = ... | LetRec of (string * string list * t) list * t
- 「再帰関数名」「仮引数」「関数の本体」のリスト
let
の本体である式
の二つの部分で構成されている。ちなみにLetFn
の定義は
| LetFn of (string * string list * t) list * t
なのでLetRec
とLetFn
は式の保持する情報はまったく同じものとなる。
次にVal
モジュールに再帰関数の値を追加:
type t = ... | RecFn of string * string list * (string * t) list ref * Exp.t
各要素の意味は
- 関数名
- 仮引数
- クロージャ
- 関数本体の式
となる。これもFn
と比較してみると:
| Fn of string * string list * (string * t) list * Exp.t
ほぼ同じだが、クロージャの部分がRecFn
ではref
になっているのが違い。再帰関数のクロージャには「自由変数と対応する値」だけではなく「letrec
で定義された関数名とそれに対応する値」も追加されるのだがその「対応する値」にこのRecFn
値も含まれる。そういうループ構造を実装するために、関数を作成後に変更できるミュータブルなref
を使っている。(他のやり方もある。Essentials of Programming Languagesの第3章を参照)
新しい式を追加したのでExecute.get_free
も変更:
let rec get_free env = function ... | Exp.LetRec(fns, e) -> let fnames, paramss, bodys = Utils.split3 fns in let f params body = get_free (Env.add_vars env (fnames @ params)) body in let free_in_fns = List.concat @@ List.map2 f paramss bodys in (get_free (Env.add_vars env fnames) e) @ free_in_fns
- 再帰関数の要素のリストから「関数名」のリスト、「仮引数」のリスト、「関数本体」のリストを作成
- 「関数一つの自由変数を出す」関数を定義
- 定義されている再帰関数すべての自由変数を上記2行の結果を使って検出
LetRec
本体の式の自由変数を検出し、上記の「再帰関数の自由変数」のリストとconcat
となっている。
「『関数一つの自由変数を出す』関数を定義」の
let f params body = get_free (Env.add_vars env (fnames @ params)) body in
で定義されたすべての再帰関数名fnames
も変数環境に追加し、再帰関数の自由変数から除外しているのもポイント。
Execute.eval
:
let eval env cont = function ... | Exp.LetRec(fns, e) -> let fnames, _, _ = Utils.split3 fns in let f fnames (fname, params, body) = let free = get_free (Env.add_vars Env.empty (fnames @ params)) body in let fvals = List.map (fun v -> v, Env.find v env) free in let fvalsr = ref fvals in let fn = Val.RecFn(fname, params, fvalsr, body) in ((fname, fn), fvalsr) in let fname_fns, fvalsrs = List.split @@ List.map (f fnames) fns in List.iter (fun fvalsr -> (fvalsr := (fname_fns @ !fvalsr))) fvalsrs; Eval(Env.extend_list fname_fns env, Cont.Env::cont, e)
おおさっぱな流れを解説すると:
- 再帰関数の要素のリストから再帰関数を作成
- 作成されたすべて再帰関数のクロージャに「作成されたすべての再帰関数の名前と値」を追加
- 変数環境に作成されたすべて再帰関数を追加して、
letrec
本体の式を評価
となっている。
「再帰関数の要素のリストから再帰関数を作成」するために定義されているf
関数が過半数の行を占めている。(おっと、このf
関数がfnames
を引数にとる意味がないな・・・)
ポイントとしては、再帰関数の名前と値のタプルだけでなく、その関数のクロージャも同時に返しているところ。すべての再帰関数の値をクロージャに追加しなければいけないので、「一つの再帰関数を作る」f
関数内ではクロージャの更新はできない。
最後にExecute.apply_cont
:
let apply_cont env cont v = match cont with ... | Cont.Call([], vs) :: cont' -> (match List.rev (v::vs) with ... | Val.RecFn(s, ss, fvalsr, e) :: vs' -> let paramcount = List.length ss in let argcount = List.length vs' in if paramcount = argcount then let env' = Env.extend_list (!fvalsr @ (List.combine ss vs')) env in Eval(env', Cont.Env::cont', e) else failwith @@ Printf.sprintf "Function %s called with incorrect number of args: expected %d received %d" s paramcount argcount
仮引数と実引数の要素数が同じなら、変数環境にクロージャを追加、継続にCont.Env
を追加してからletrec
本体の式を評価する。(引数の要素数が一致しない場合はエラー)。
くどいようだがVal.Fn
のケースと比較してみる:
let apply_cont env cont v = match cont with ... | Cont.Call([], vs) :: cont' -> (match List.rev (v::vs) with ... | Val.Fn(s, ss, fvals, e) :: vs' -> let paramcount = List.length ss in let argcount = List.length vs' in if paramcount = argcount then let env' = Env.extend_list (fvals @ (List.combine ss vs')) env in Eval(env', Cont.Env::cont', e) else failwith @@ Printf.sprintf "Function %s called with incorrect number of args: expected %d received %d" s paramcount argcount
目を凝らさないとなかなかわからないと思うが、
let env' = Env.extend_list (!fvalsr @ (List.combine ss vs')) env in (* RecFn *) let env' = Env.extend_list (fvals @ (List.combine ss vs')) env in (* Fn *)
この違いである。Fn
だとそのままリストである「自由変数と対応する値」のfval
を直接concat
しているのが、RecFn
だとref
型なので!fvalsr
としている。
今気づいたのだが、Fn
もRecFn
側に寄せてクロージャをref
で持つようにすれば値の種類をわける必要もなく、この場合分けもいらなくなるな・・・(Exp.LetRec
はいるがVal.RecFn
はいらない) どこかのタイミングで修正してみよう。
というわけで再帰も実装完了。「クロージャをミュータブルにする」という方針が決まっていれば比較的簡単に既存の非再帰関数のコードを修正できる。
次回はいくつかマイナー修正を加える。