めざそう言語処理系の沼 〜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
を外した残りの継続で評価
という流れとなる。
これでマクロも実装できた。
次回
次回は評価ステップごとにインタプリタの状態が見えるようにする。