めざそう言語処理系の沼 〜shift/resetへの旅 〜 その14 ステップ実行インタプリタ
ここまで実装してきたインタプリタは
- 式を表す文字列をパースし
- 評価して
- 結果の値を表す文字列を返していた。
もちろんそれで正しいのだが、インタプリタの中でどのような挙動になって式が値に変換されているのかがわかりにくい。
限定継続のようにかなり微妙な動きをする(そして微妙なバグが潜り込みそうな)言語機能を実装する場合、もう少し細かく内部状態がわかる「デバッグモード」がほしい。
というわけで、新しくstepwise
というexecutableが作れるようにする。
このexecutableは文字どおりステップ実行のためのインタプリタで、Execute.eval
やExecute.apply_cont
が走ってExecute.res
型の途中結果をトランポリンに返すたびにその情報をプリント出力する。
前回からの差分:
Comparing v0.13...v0.14 · zehnpaard/kontlang · GitHub
今回の変更のために以下のことをしている
- 値と継続の定義を共通のモジュールに移す
- 継続をlist listに
- 継続と変数環境を文字列化できるようにする
- ステップ実行インタプリタ
前者二つは限定継続を実装するための下準備だが、三番目の「継続と変数環境を文字列化できるようにする」のために前倒しした(継続の文字列化のロジックを後で大きく修正するのを避けたかったので)。
値と継続の定義を共通のモジュールに移す
限定継続を実装すると「継続の一部」を値として取り扱う必要が出てくる。
一番素直な実装だと、Val.t
型のバリアントの一つの内部データとしてCont.t
型(あるいはその一部)を持たせることになる。しかし継続にはすでに「ここまで評価した結果」である値が保持されているので、相互再帰的になってしまう。
OCamlだと相互再帰的なデータ型が定義できるがその場合一つのモジュールで両方の型を定義するのが一番簡単。この件に関しては以前記事に書いた:
というわけでCont.t
とVal.t
を相互再帰的にするために、新しくValcont
モジュールを作成して両者をここで定義する。さらにValcont
内で別々のVal
とCont
モジュールを定義して、Val.ml
内ではinclude Valcont.Val
、Kont.ml
ではinclude Valcont.Cont
としている。これで他のモジュールからは今までどおりVal
とCont
が使える。
本当はValcont
モジュールがVal
とCont
モジュール以外からは隠蔽したくなるのだが、調べてみたところ難しいようだ。
今になって思えばこの変更はCont.t
の文字列化にあまり影響しないので、この段階でやる必要はなかった・・・
継続をlist list
に
限定継続を実装する下準備その2。
限定継続を扱えるようにする言語機能のshift/reset
では、reset
式の中に入った部分式は継続がreset
外とは区切られていて、その中でshift
が呼ばれるとその区切りのところまでの継続が捕捉されて値に変換される。
これまで継続はフラットな一つのリストとして実装してきた。ここに例えばCont.Reset
を導入して「現在の限定継続は直近のCont.Reset
まで」と定義してもいいのだが、別解として
- 継続は
Cont.cont list list
型 reset
に入ったら継続に空リストを追加- 他の式の継続はこの先頭のリストに追加していく
shift
はこの先頭をリストをとってくるだけ
という実装を思いついたので試してみたい。
これはCont.t
の内部実装を大きく変えるところなので、Cont.to_string
を実装する前にやっておきたかった。
Cont.t
型の変更:
type t = cont list let final = []
だったものが
type t = cont list list let final = [[]]
こうなる。単なるリストに比べて追加がめんどくさくなるので専用のCont.add
関数を用意:
let add c = function | cont'::cont'' -> (c::cont')::cont'' | [] -> [[c]]
こういった変更に合わせてExecute
モジュールも変えていく。
let eval env cont = function ... | Exp.Call(e, es) -> Eval(env, Cont.Call(es, []) :: cont, e)
が
let eval env cont = function ... | Exp.Call(e, es) -> let cont' = Cont.add (Cont.Call(es, [])) cont in
になったり
let apply_cont env cont v = match cont with ... | Cont.Call(e::es, vs) :: cont' -> Eval(env, Cont.Call(es, v::vs) :: cont', e)
が
let apply_cont env cont v = match cont with ... | (Cont.Call(e::es, vs) :: cont')::cont'' -> let cont''' = Cont.add (Cont.Call(es, v::vs)) (cont'::cont'') in Eval(env, cont''', e)
になったりする。リストがネストしたぶん少し冗長になっている。
さらに
let apply_cont env cont v = match cont with ... | []::cont'' -> ApplyCont(env, cont'', v)
というケースが追加されている。これは「reset
の中の式を評価し終わったので、その限定継続から抜ける」ことを表している。
ただしまだshift/reset
が実装されていないので、継続に二つ以上のリストが含まれることはないのだが・・・
継続と変数環境を文字列化できるようにする
ステップ実行で評価途中のインタプリタの状態を表示するには、式や値だけではなく継続と変数環境も出力する必要がある。なのでCont.to_string
やEnv.to_string
を追加する。
地味なのでコードは割愛。
ステップ実行インタプリタ
これでステップ実行時に必要な情報を文字列化できるようになった。
あとはExecute
モジュールにtrampoline
にちょっと手を加えたtrampoline'
を作る。具体的にはeval
やapply_cont
が返すExecute.res
型を出力して一時停止するdisplay
関数を定義して:
let display = function | Done v -> print_endline "Done"; print_endline (Val.to_string v); ignore @@ read_line () | Eval(env, cont, e) -> print_endline @@ "Evaluating expression " ^ Exp.to_string e; print_endline @@ "Cont Head:\t" ^ Cont.to_string_hd cont; print_endline @@ "Cont Full:\t" ^ Cont.to_string cont; print_endline @@ "Environment:\t" ^ Env.to_string env; ignore @@ read_line () | ApplyCont(env, cont, v) -> print_endline @@ "Applying continuation on value " ^ Val.to_string v; print_endline @@ "Cont Head:\t" ^ Cont.to_string_hd cont; print_endline @@ "Cont Full:\t" ^ Cont.to_string cont; print_endline @@ "Environment:\t" ^ Env.to_string env; ignore @@ read_line ()
trampoline'
で次のステップに移る前にdisplay
を呼び出す:
let rec trampoline' res = match res with | Done v -> display res; v | Eval(env, cont, e) -> display res; trampoline' @@ eval env cont e | ApplyCont(env, cont, v) -> display res; trampoline' @@ apply_cont env cont v
trampoline'
を使ったrun'
やeval_string'
も定義して、最後にstepwise.ml
を作成:
open Kontlang let rec read_eval_print exp_string = let s = read_line () in if s = "" then Execute.eval_string' exp_string |> print_endline else read_eval_print (Printf.sprintf "%s\n%s" exp_string s) let () = (print_string ">>> "; read_eval_print "")
これで実装完了。
使い方
dune exec ./stepwise.exe
で実行できる。(dune
はOCamlのビルドツールで、上記のコマンドはdune
にstepwise.exe
をコンパイルして実行するよう指示している)
実行して(let [x 3] (+ (* 1 2) x))
を評価させてみる:
kontlang$ dune exec ./stepwise.exe >>> (let [x 3] (+ (* 1 2) x)) Evaluating expression (let [(x 3)] (+ (* 1 2) x)) Cont Head: Cont Full: Environment: Evaluating expression 3 Cont Head: LET x [] [] (+ (* 1 2) x) Cont Full: LET Environment: Applying continuation on value 3 Cont Head: LET x [] [] (+ (* 1 2) x) Cont Full: LET Environment: Evaluating expression (+ (* 1 2) x) Cont Head: ENV Cont Full: ENV Environment: [{x 3}] Evaluating expression + Cont Head: CALL [(* 1 2) x] [] Cont Full: CALL ENV Environment: [{x 3}] Applying continuation on value Op(+) Cont Head: CALL [(* 1 2) x] [] Cont Full: CALL ENV Environment: [{x 3}] Evaluating expression (* 1 2) Cont Head: CALL [x] [Op(+)] Cont Full: CALL ENV Environment: [{x 3}] Evaluating expression * Cont Head: CALL [1 2] [] Cont Full: CALL CALL ENV Environment: [{x 3}] Applying continuation on value Op(*) Cont Head: CALL [1 2] [] Cont Full: CALL CALL ENV Environment: [{x 3}] Evaluating expression 1 Cont Head: CALL [2] [Op(*)] Cont Full: CALL CALL ENV Environment: [{x 3}] Applying continuation on value 1 Cont Head: CALL [2] [Op(*)] Cont Full: CALL CALL ENV Environment: [{x 3}] Evaluating expression 2 Cont Head: CALL [] [1 Op(*)] Cont Full: CALL CALL ENV Environment: [{x 3}] Applying continuation on value 2 Cont Head: CALL [] [1 Op(*)] Cont Full: CALL CALL ENV Environment: [{x 3}] Applying continuation on value 2 Cont Head: CALL [x] [Op(+)] Cont Full: CALL ENV Environment: [{x 3}] Evaluating expression x Cont Head: CALL [] [2 Op(+)] Cont Full: CALL ENV Environment: [{x 3}] Applying continuation on value 3 Cont Head: CALL [] [2 Op(+)] Cont Full: CALL ENV Environment: [{x 3}] Applying continuation on value 5 Cont Head: ENV Cont Full: ENV Environment: [{x 3}] Applying continuation on value 5 Cont Head: Cont Full: Environment: Applying continuation on value 5 Cont Head: Cont Full: Environment: Done 5 5
「Cont Full」では継続すべてを(個々の部分は簡略した形で)表示し、「Cont Head」ではその一番先頭(左側)の詳細を表示している。
パッと見で直感的とは言えないが、慣れてくるとインタプリタの状態がかなりわかるようになる。例えば
Evaluating expression 1 Cont Head: CALL [2] [Op(*)] Cont Full: CALL CALL ENV Environment: [{x 3}]
であれば
- 次のステップは
1
という式を評価すること - 現在関数呼び出し二つの内側にいる
- 直近の関数呼び出しでは、すでに関数に当たる部分が評価されていてその値は
*
である - まだ未評価の引数があってその式は
2
である(整数式も整数値も同じように表示されてしまうのがややこしい・・・) - 現在の変数環境には
x
という変数に3
という値が束縛されている
ということを表している。
限定継続のような複雑な機能を実装・デバッグする上でこういう情報は非常に役に立つ。
次回
めざそう言語処理系の沼 〜shift/resetへの旅 〜 その13 マクロ
簡単なマクロ機構を実装していく。
背景
shift/resetが実装されたらやってみたいことの一つが@dico_lequeさんの発表「モナドをつくろう」をいろいろ試してみること。
スライド19に出てくるreify
がマクロなので、それを再現できるようにしたい。
(define-syntax reify (syntax-rules () ((_ expr) (reset (let ((v expr)) (some v)))))
Schemeのマクロは強力でいろんな機構があるが、このマクロ自体は比較的簡単。
(reify expr)
を
(reset (let ((v expr)) (some v))
に置換する、というもの。何故これがマクロなのかというと、ただの関数だと関数適用の前に引数が評価されるので、expr
の中でshift
があった場合reify
内で使われているreset
に捕捉されない問題が生じるため。
このreify
が再現できる最低限のマクロ機構と考えると
- マクロ本体の中に出てくる仮引数と一致する変数を、実引数に当たる式と置換する
- 引数の式はマクロ適用時は評価せず、式のままマクロ本体に代入される
- マクロはクロージャを持たず、マクロ定義時ではなく呼び出し時の変数環境を使って変数解決するダイナミックスコープ
という形で実装してみる。
ちなみに何故マクロだけダイナミックスコープにしているかというと、レキシカルスコープにするとマクロの引数となる式がマクロ定義時の変数環境で評価されることになってしまうからで、それを避けるような機能を実装するのも可能だが関数のレキシカルスコープに比べてかなり大変になる、というのが理由。
(ちなみに後々reify
を書いてみるときは、some
は(fn [x] (cons "Some" x))
で代用するつもり。代数的データ型がないので・・・)
使い方
fn
と同じように、macro
というキーワードで無名マクロを作成できるようにする。そのまま使うも変数に束縛して使うも自由:
((macro [x y] (do [y x])) (println "hello") (println "goodbye"))) (let [m (macro [x y] (do [y x]))] (m (println "hello") (println "goodbye")))
普通の引数と違ってマクロ適用の時点では引数が評価されないので、上記の例は二つとも"hello"の前に"goodbye"が出力される。
コード解説
前回とのコード差分:
Comparing v0.12...v0.13 · zehnpaard/kontlang · GitHub
Exp.t
にMacro
コンストラクタを追加(比較のためにFn
も載せている):
type t = | Fn of string list * t | Macro of string list * t
Val.t
にも同様:
type t = | Fn of string * string list * (string * t) list ref * Exp.t | Macro of string list * Exp.t
Exp.Macro
とVal.Macro
がまったく同一なのがわかるかと思う。仮引数のリストと本体の式だけ。Fn
の場合、Val.Fn
はExp.Fn
の内容に加えてクロージャに当たる(string * t) list ref
が追加されているが、Macro
の場合はダイナミックスコープにしてあるのでクロージャはなし。
次にマクロの根本である「部分式の置換」を行う関数substitute
を持つ新しいMacro
モジュールを定義:
let substitute e ss es = let env = List.combine ss es in let rec f = function | Exp.Int _ as x -> x | Exp.Str _ as x -> x | Exp.Macro(ss, e) -> Exp.Macro(ss, f e) | Exp.Var s as e -> (match List.assoc_opt s env with | Some e' -> e' | None -> e) | Exp.Call(e, es) -> Exp.Call(f e, List.map f es) | Exp.If(e1, e2, e3) -> Exp.If(f e1, f e2, f e3) | Exp.Cond(ees) -> Exp.Cond(List.map (fun (e1, e2) -> (f e1, f e2)) ees) | Exp.Let(ves, e2) -> Exp.Let(List.map (fun (v, e) -> (v, f e)) ves, f e2) | Exp.Lets(ves, e2) -> Exp.Lets(List.map (fun (v, e) -> (v, f e)) ves, f e2) | Exp.Fn(params, body) -> Exp.Fn(params, f body) | Exp.LetFn(fns, e) -> Exp.LetFn(List.map (fun (v, ps, e) -> (v, ps, f e)) fns, f e) | Exp.LetRec(fns, e) -> Exp.LetRec(List.map (fun (v, ps, e) -> (v, ps, f e)) fns, f e) | Exp.Do es -> Exp.Do (List.map f es) in f e
substitute
関数内で(変数*式)リストのenv
とそれを使って再帰的に部分式を置換していくf
関数が定義されている。
マクロ変数環境であるenv
はExecute.eval
で使われているEnv.t
とは型からして違うことに注意(Env.t
は(string * Val.t) list list
)。
f
の実引数が変数Exp.Var
だった場合、マクロ変数環境を調べて式が束縛されていれば完全に置換、そうでないなら変数のまま残す。
f
の実引数がそれ以外の式だった場合、その式の部分式にf
を再帰的に適用する。
これらを使う形でExecute
モジュールを変更する。まずはExecute.eval
:
let eval env cont = function ... | Exp.Macro(ss, e) -> ApplyCont(env, cont, Val.Macro(ss, e))
Exp.Macro
式はそのままVal.Macro
値に変換してから残りの継続に渡す。
Execute.apply_cont
:
let apply_cont env cont v = match cont with ... | Cont.Call(e::es as es', []) :: cont' -> (match v with | Val.Macro (ss, me) -> let paramcount = List.length ss in let argcount = List.length es' in if paramcount = argcount then Eval(env, cont', Macro.substitute me ss es') else failwith @@ Printf.sprintf "Macro called with incorrect number of args: expected %d received %d" paramcount argcount | _ -> Eval(env, Cont.Call(es, [v]) :: cont', e))
マクロの実行も関数と同じくCont.Call
継続を使う。ただし、「引数の式が一つも評価されていない状態のCont.Call
」ケースを場合分けに追加していて、ここでオペレータ部分がVal.Macro
ならマクロ呼び出し。別の値なら今までどおり引数の評価に移行する。
マクロ呼び出しでは:
- 仮引数と実引数の数が一致することを確認
- それらと
Macro.substitute
を使ってマクロ本体の式を置換 - その置換された式を
Cont.Call
を外した残りの継続で評価
という流れとなる。
これでマクロも実装できた。
次回
次回は評価ステップごとにインタプリタの状態が見えるようにする。
めざそう言語処理系の沼 〜shift/resetへの旅 〜 その12 文字列、入出力とDo
今まで作ってきた言語には副作用がなかったが、やはり入出力くらいはほしいよね、というわけで
- 式と値に文字列を追加
- 標準入出力からの読み取り・書き込みのための組み込み関数
- 副作用のある処理を順次実行して、最後の式の値をとる
Do
式
を言語に追加する。
前回からの差分:
Comparing v0.11...v0.12 · zehnpaard/kontlang · GitHub
式と値に文字列を追加
式のExp.t
に追加:
type t = ... | Str of string
値のVal.t
にも追加:
type t = ... | Str of string
式を評価して値にするExecute.eval
:
let eval env cont = function ... | Exp.Str s -> ApplyCont(env, cont, Val.Str s)
これだけ。
標準入出力
組み込み関数にいくつか追加していく。
まず複数の文字列を結合するconcat
:
let concat_op ss = let f = function | Val.Str s -> s | _ -> failwith "Non-string passed to concat" in Val.Str (String.concat "" @@ List.map f ss)
文字列を標準出力するprint
と出力の最後に改行を入れるprintln
:
let print_op = function | [Val.Str s] -> print_string s; Val.Nil | _ -> failwith "Non-string passed to print" let println_op = function | [Val.Str s] -> print_endline s; Val.Nil | _ -> failwith "Non-string passed to println"
これらは文字列を引数にとり、Val.Nil
を返す。
標準入力から文字列を取ってくるread
:
let read_op = function | [] -> Val.Str (read_line ()) | _ -> failwith "Args passed to read"
これらはすべてOCamlの関数をラップしているだけなので楽。
あとは変数環境に入るようbuiltins
に名前と一緒に入れるだけ:
let builtins = [ ... ; "concat", Val.Op("concat", concat_op) ; "print", Val.Op("print", print_op) ; "println", Val.Op("println", println_op) ; "read", Val.Op("read", read_op) ]
Do
式
副作用のある処理を記述できるようになったので、そういった処理を順次行うための構文がほしくなる。
というわけでこんなDo
式を導入する:
(do [(println "hello") (print "please enter your name: ") ( read)])
最後の式の値がDo
式全体の値となるので、上記のコードの場合、read
で受け取った入力の文字列が結果となる。
Exp.t
に追加:
type t = ... | Do of t list
Cont
にも追加:
type cont = ... | Do of Exp.t list
Exp.Do
もCont.Do
もExp.t list
を持っている。
ただしこのデータの持つ意味が少し違って、Exp.Do
のExp.t list
はDo
式の持つすべての部分式なのに対してCont.Do
の場合は「まだ未評価の式」を保持する形となる。
Execute.eval
:
let eval env cont = function ... | Exp.Do(e::es) -> Eval(env, Cont.Do es::cont, e) | Exp.Do([]) -> failwith "Evaluating empty do"
Exp.Do
を評価するのには、Exp.t list
のtailを持つCont.Do
を継続に追加して、Exp.t list
のheadを評価する。
Execute.apply_cont
:
let apply_cont env cont v = match cont with ... | Cont.Do(e::es)::cont' -> Eval(env, Cont.Do es::cont', e) | Cont.Do([])::cont' -> ApplyCont(env, cont', v)
値をCont.Do
が先頭にくる継続に適用するのに、Cont.Do
の持つExp.t list
が空リストかどうかで場合分けする。
空リストではない場合、apply_cont
の引数である値は無視して、Exp.t list
のtailを持つCont.Do
を継続に追加して、Exp.t list
のheadを評価する。
空リストの場合、apply_cont
の引数である値をそのまま残りの継続に適用する。
これでDo
も実装完了。
次回
次回は簡単なマクロ機能を実装する。
めざそう言語処理系の沼 〜shift/resetへの旅 〜 その11 Cond&リファクタリング
今回の変更は三点:
cond
による条件分岐Val.Fn
とVal.RecFn
を統一- 式の中の自由変数を検出する
get_free
をリファクタ
前回とのコード差分:
Comparing v0.10...v0.11 · zehnpaard/kontlang · GitHub
cond
による条件分岐
すでにif
による条件分岐は実装してあるが
if ... then ... else if ... then ... else ...
上記のような複数の条件を順次調べて分岐するようなロジックは
(if ... ... (if ... ... ...))
のような形になり、ネストがどんどん深くなっていってしまう。このように書けるようにしたい:
(cond [... ...] [... ...] [true ...])
(true
はelseと同じ意味になる)
まずExp
モジュールにExp.Cont
を追加:
type t = ... | Cond of (t * t) list
条件式と、その条件が真だった場合の結果となる式のタプルのリスト。
ある条件式を評価している間、「現在Cond
の一部を評価している」というコンテキストを保持する必要があるのでCont.t
にもコンストラクタを追加:
type cont = ... | Cond of Exp.t * (Exp.t * Exp.t) list
現在評価している条件式が真だった場合の結果となる式と、残りの条件式・結果式のタプルのリスト。以前評価した結果やそれに対応する式は必要ない(条件が一つ真だった時点でCont.Cond
は外した上で対応する結果式を評価するので)。
あとはExecute
モジュールでVal.Cond
やCont.Cond
のケースを追加:
let eval env cont = function ... | Exp.Cond((e1,e2)::ees) -> Eval(env, Cont.Cond(e2, ees)::cont, e1) | Exp.Cond _ -> failwith "Evaluating cond with no matching condition" let apply_cont env cont v = match cont with ... | Cont.Cond(e, ees) :: cont' -> (match v with | Val.Bool b -> Eval(env, cont', if b then e else Exp.Cond(ees)) | _ -> failwith "Non-boolean in condition position of Cond expression")
eval
ではVal.Cond
のデータの先頭からタプルを取り、継続にCont.Cond
を追加してからタプルの条件式の部分を評価。
apply_cont
では、条件式の評価結果が真なら結果式を、そうでないなら残った(条件式*結果式)タプルのリストで新しくExp.Cond
を作成してそれを評価する。
もし何も該当する条件がなかった場合は実行時エラーになる。nil
を返すようにしてもよかったのだが、この言語は他のところでも少し厳しめの実装になっているのでそっちに寄せた。
Val.Fn
とVal.RecFn
を統一
以前、再帰関数を定義できるようにした時にVal.t
型に新しくRecFn
コンストラクタを追加した。関数を作成した後に、クロージャにその関数(あるいは相互再帰関係にある他の関数)を追加できるように、クロージャを表す型を(string * Val.t) list
から(string * Val.t) list ref
に変更したいためだ。
しかしこのRecFn
の定義は普通のFn
でも使いまわせる。そうするとVal.t
に対するパターンマッチの場合分けを少し減らし、大部分同じで微妙に違うFn
とRecFn
に対する処理を統一できる。
最終的にはVal.Fn
の定義はこうなる:
type t = ... | Fn of string * string list * (string * t) list ref * Exp.t
あとはExecute
モジュールでの場合分けで、今まであったVal.Fn
の部分を削除してVal.RecFn
というコンストラクタをVal.Fn
に書き換えるだけ。(細かいので割愛)
get_free
をリファクタ
ある式のなかに出てくる自由変数を検出するget_free
にいくつか不満があったので変更した。
修正点は以下のとおり:
- 戻り値である自由変数のリストを
concat
しないようにする - 変数環境
Env.t
型の引数を取らないようにする get_free
関数をExecute
からExp
に移す
1の「戻り値である自由変数のリストをconcat
しないようにする」に関しては、get_free
が再帰的に部分式に適用された結果を愚直にconcat
で繋げていて効率が非常に悪い(そのconcat
部分だけでO(n log n)
)。それを避けるために「出てきた自由変数を追加するためのリスト」を引数に加える。部分式が複数ある場合、fold_left
などを使ってこのリストを使い回す。これでそのO(n log n)
部分をO(n)
に下げられる。
2の「変数環境Env.t
型の引数を取らないようにする」については、今まで式の中で出てきた変数が束縛されているかどうかを調べるのに、eval
で使っているのと同じ変数環境の型Env.t
を使っていた。なので例えば(let [x 5] (+ x x))
という式の自由変数を調べる時に、(+ x x)
のx
が束縛されていることを表すために変数環境に("x", Val.Nil)
のような変数とダミー値のタプルを入れていた。ダミー値が無駄だし、get_env
関数が不必要にEnv
モジュールに依存してしまう、ということで「現在束縛されている変数の集合」をExp.t
型のenv
引数ではなくただの文字列のリストであるbound
引数で表すことにした。
3「get_free
関数をExecute
からExp
に移す」については、2でEnv
への依存がなくなったのでget_free
の残る依存性はExp
に対するものだけ。さらに「式の自由変数を検出する」という関数の目的からも、Exp
モジュールに入っているのが一番自然なのでそのように変更。
最終的にExp.get_free
はこのようになった:
let rec get_free bound free = function | Int _ -> free | Var s -> if (List.mem s bound) then free else s::free | Call(e, es) -> List.fold_left (get_free bound) free @@ e::es | If(e1, e2, e3) -> List.fold_left (get_free bound) free [e1; e2; e3] | Cond(ees) -> let f free (e1, e2) = get_free bound (get_free bound free e1) e2 in List.fold_left f free ees | Let(ves, e2) -> let vs, es = List.split ves in let free' = List.fold_left (get_free bound) free es in get_free (vs @ bound) free' e2 | Lets((s, e1)::ves, e2) -> let free' = get_free bound free e1 in get_free (s::bound) free' @@ Lets(ves, e2) | Lets ([], e2) -> get_free bound free e2 | Fn(params, body) -> get_free (params @ bound) free body | LetFn(fns, e) -> let fnames, paramss, bodys = Utils.split3 fns in let f free params body = get_free (params @ bound) free body in let free' = List.fold_left2 f free paramss bodys in get_free (fnames @ bound) free' e | LetRec(fns, e) -> let fnames, paramss, bodys = Utils.split3 fns in let f free params body = get_free (fnames @ params @ bound) free body in let free' = List.fold_left2 f free paramss bodys in get_free (fnames @ bound) free' e
Execute
モジュールでの使い方は:
| Exp.Fn(params, body) as e -> let free = Utils.dedupe @@ Exp.get_free [] [] e in (* ここ *) let fvalsr = ref @@ List.map (fun v -> v, Env.find v env) free in ApplyCont(env, cont, Val.Fn("anon", params, fvalsr, body))
新しいget_free
の戻り値でも自由変数の重複はありえるのでUtils.dedupe
を使う必要がある。まあdedupe
もO(n)
だしまあいいか、というところ。
次回
次回は文字列と入出力、そして複数の式を評価して最後の式の値をとるDo
構文を追加する。
めざそう言語処理系の沼 〜shift/resetへの旅 〜 その10 Consとリスト
今回は言語で簡単なデータ構造を扱えるようにする。具体的にはイミュータブルなconsセル(とnil値)を導入して、linked listを作れるようにする。
ついでにLisperならお馴染みのcar
、cdr
やリストを便利に作れる・使えるlist
、apply
なども定義する。
こんな感じで使える:
(let [x (cons 1 2)] (+ (car x) (cdr x))) (apply + (list 1 2 3 4 5))
前回との差分:
Comparing v0.9...v0.10 · zehnpaard/kontlang · GitHub
テスト以外で変更したのはVal
とBuiltins
だけ。パーサも評価器も変更なし。
まずVal
モジュールにCons
と`Nilを追加:
type t = | Nil ... | Cons of t * t
Val.Cons
は要素2のタプル。Val.Nil
は今後副作用ありの計算の結果に使ったりもする予定だが、ここではリストの終端を表すようにしている。
つまり
(cons 1 (cons 2 (cons 3 nil)))
が[1 2 3]
というリストとしてみなされる。
ちなみに
(cons 1 (cons 2 (cons 3 4)))
と終端がnil
でない場合、dotted listといって[1 2 3 . 4]
のように表記される。
ある値が(nil
で終わっている)リストか確認する関数is_list
:
let rec is_list = function | Nil -> true | Cons(_, x) -> is_list x | _ -> false
や、list、dotted_listをOCamlのリストやリストとVal.tのタプルに変換するヘルパー関数:
let rec cons_to_list = function | Nil -> [] | Cons(x, xs) -> x::(cons_to_list xs) | _ -> failwith "Converting non-cons-list into arg list" let rec cons_to_dotted_list acc = function | Nil -> failwith "Dotted list cannot end with nil" | Cons(x, xs) -> cons_to_dotted_list (x::acc) xs | v -> (List.rev acc, v)
なども追加してある。
Builtins
モジュールに追加された組込関数など:
let builtins = [ ... ; "nil", Val.Nil ; "cons", Val.Op("cons", cons_op) ; "car", Val.Op("car", car_op) ; "cdr", Val.Op("cdr", cdr_op) ; "list", Val.Op("list", list_op) ; "apply", Val.Op("apply", apply_op) ; "nil?", Val.Op("nil?", nilp_op) ; "cons?", Val.Op("cons?", consp_op) ]
nil
やcons
も予約語ではなく、ただの変数。
cons
、car
、cdr
の定義:
let cons_op = function | [x; y] -> Val.Cons(x, y) | xs -> failwith @@ Printf.sprintf "Cons applied to %d args" (List.length xs) let car_op = function | [Val.Cons(x, _)] -> x | _ -> failwith "Non-cons passed to car" let cdr_op = function | [Val.Cons(_, y)] -> y | _ -> failwith "Non-cons passed to cdr"
パターンマッチで、cons
の場合引数が2つでない場合、car
やcdr
の場合引数が1つのVal.Cons
でない場合はエラーとなる。
list
とapply
の定義:
let rec list_op = function | [] -> Val.Nil | x::xs' -> Val.Cons(x, list_op xs') let apply_op = function | [Val.Op(_, op); Val.Cons _ as cons] -> op @@ Val.cons_to_list cons | _ -> failwith "Incorrect args passed to apply"
list
は引数のリストを再帰的に(cons x (cons y (... nil) ...))
の形に変換する。
apply
は第一引数の関数を第二引数のリストに適用する、のだがその時「ソース言語のリスト」を「OCamlのリスト」に変換している。なので
(apply + (list 1 2 3 4)) (+ 1 2 3 4)
が同じ結果となる。
あとはnil
やcons
かどうかを調べる述語:
let nilp_op = function | [Val.Nil] -> Val.Bool true | [_] -> Val.Bool false | _ -> failwith "nil? called with invalid number of args" let consp_op = function | [Val.Cons _] -> Val.Bool true | [_] -> Val.Bool false | _ -> failwith "cons? called with invalid number of args"
引数が1つ出なければエラー。1つの場合、cons?
は引数がVal.Cons
なら真、それ以外は偽。nil?
は引数がVal.Nil
なら真、それ以外は偽。
以上で実装完了。
これでmap
やfilter
が書ける!と思ったのだが、if
だけだと少し書きにくいのでcond
を追加して場合分けで分岐できるようにしたい。
というわけで次回はcond
の話。
めざそう言語処理系の沼 〜shift/resetへの旅 〜 その9 微調整
今回は細かいところを調整しただけ。
前回との差分:
Comparing v0.8...v0.9 · zehnpaard/kontlang · GitHub
調整ポイントは:
- レキシカル・スコープで関数作成時にクロージャに束縛する自由変数の重複を排除
Exp.Lets
を評価する時、変数に束縛されるべき式をすべて評価し終わった後に継続にCont.Env
を追加していたのを、実引数評価の前に追加するよう変更Cont.Let
で変数に束縛されるべき式をすべて評価し終わった後、それらをList.rev
していたのをやめた
地味かつ現行の機能にはなんの変化もないリファクタリング。
クロージャに束縛する自由変数の重複を排除
関数作成時に自由変数を全部検出するのだが、今までの実装だと例えば
(fn [y] (+ x y (+ x x) z))
などといった式の場合、自由変数が[+ x + x x z]
のような重複のあるリストになってしまって非効率。重複は排除したい。
Utils
モジュールにdedupe
関数を追加:
let dedupe xs = let hsh = Hashtbl.create 1024 in let rec f acc = function | [] -> acc | x::xs' -> if Hashtbl.mem hsh x then f acc xs' else (Hashtbl.add hsh x 0; f (x::acc) xs') in f [] xs
ミュータブルなデータ構造であるHashtbl
を使っているが、この関数の外には漏れないので問題なし。ほぼSet
として使っているが、イミュータブルなSet
に比べてHashtbl
はいろいろと性能がいい。OCaml公式サイトのチュートリアルにも
Sets and maps are very useful in compilation and meta-programming, but in other situations hash tables are often more appropriate
と書かれている。今回はSet
のような永続性はいらないのでHashtbl
を使う。
こうやって定義したUtils.dedupe
を、Execute.eval
でget_free
が使われているところに加える:
let eval env cont = function ... | Exp.Fn(params, body) as e -> let free = Utils.dedupe @@ get_free Env.empty e in (* ここ *) let fvals = List.map (fun v -> v, Env.find v env) free in ApplyCont(env, cont, Val.Fn("anon", params, fvals, body))
他にも二箇所ほどあるが、使い方は同じ。これでクロージャに追加する前に重複を排除できる。
Exp.Lets
評価時にCont.Env
を追加するタイミング
前回までのコードだとExp.Lets
を評価する時に:
let eval env cont = function ... | Exp.Lets((s, e1)::ves, e2) -> Eval([]::env, Cont.Lets(s, ves, e2) :: cont, e1)
eval
のタイミングで[]::env
と空リストを変数環境に追加しているが:
let apply_cont env cont v = match cont with ... | Cont.Lets(s, [], e2) :: cont' -> Eval(Env.extend_current s v env, Cont.Env::cont', e2)
「let*
式から出たらそのリストを外す必要がある」と継続にCont.Env
を追加するのはapply_cont
関数内で、Cont.Lets
の未評価の式がなくなった時点だ。現在エラー以外でeval
からapply_cont
の流れが止まることはないので問題が表面化しないのだが
- 変数環境に空リストを追加するのと継続に
Cont.Env
を追加するのとは対応しているのに、こんなに離れてしまっているのは気持ち悪い - 限定継続が追加されると
eval
とapply_cont
の流れが中断される恐れがある
という二点を踏まえて修正しておく。
let eval env cont = function ... | Exp.Lets((s, e1)::ves, e2) -> Eval([]::env, Cont.Lets(s, ves, e2)::Cont.Env::cont, e1) let apply_cont env cont v = match cont with ... | Cont.Lets(s, [], e2) :: cont' -> Eval(Env.extend_current s v env, cont', e2)
Cont.Env
が追加される場所がeval
に移すだけで修正完了。
Cont.Let
でList.rev
するのをやめる
これはどういうことかというと
(let [(x 10) (x 5)] (* x 2))
のような式がある場合、下にある変数束縛の方が優先されるべき(なので結果は10)。
変数環境に追加するリストは[(x 10) (x 5)]
ではなく[(x 5) (x 10)]
である必要がある。
元の:
| Cont.Let(s, [], vvs, e2) :: cont' -> let env' = Env.extend_list (List.rev ((s, v)::vvs)) env in Eval(env', Cont.Env::cont', e2)
だとList.rev
が余計。実際には先頭から評価してリストに加えていったものを、そのまま変数環境に加えるのが正しい順序になる。
それを踏まえてこう変える:
| Cont.Let(s, [], vvs, e2) :: cont' -> let env' = Env.extend_list ((s, v)::vvs) env in Eval(env', Cont.Env::cont', e2)
List.rev
を消しただけ。これで環境内で変数の出てくる順序が正しくなる。
それ以外
それ以外はコンストラクタの間のスペースを消したり、パーサの部分表記を統一したり、といったこまごました変更。やはり機能には影響なし。
次回は言語にcons
、car
、cdr
を追加してLispらしいリストを作れるようにする。
めざそう言語処理系の沼 〜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
はいらない) どこかのタイミングで修正してみよう。
というわけで再帰も実装完了。「クロージャをミュータブルにする」という方針が決まっていれば比較的簡単に既存の非再帰関数のコードを修正できる。
次回はいくつかマイナー修正を加える。