トイ言語実験日記
小さなプログラミング言語処理系を書きながらちょこちょこ実験してみたい。
とりあえずのネタとしてはマクロと継続。
当分はスキャナ・パーザはなしで、評価器だけで作っていこう。
フィボナッチとカウンタが実装できるだけの最小の評価器を書いてみた。
名前定義と条件分岐と再帰とクロージャ付きの関数だけを実装。
配列を使ったASTを自分で書いて実行する。
目が慣れればコードに見える(に違いない)。
run(["define", "fib", ["func", ["n"],
["if", ["=", "n", 0], 0,
["if", ["=", "n", 1], 1,
["+", ["fib", ["-", "n", 1]], ["fib", ["-", "n", 2]]]]]]])
assert run(["fib", 10]) == 55
run(["define", "make_adder", ["func", ["n"], ["func", ["m"], ["+", "n", "m"]]]])
assert run([["make_adder", 5], 6]) == 11
これが実行できるだけの機能以外付けないようにした。
とはいえこれでもうけっこういろいろ書けるはず。
足し算と引き算しかないのでかけ算は自分で書かないといけないけど。
割り算はちょっとがんばらないと書けない気がする。
コードはまるごと貼り付けられるレベル。
39行。
# environment
def define(env, name, val):
env["vals"][name] = val
def get(env, name):
return env["vals"][name] if name in env["vals"] else get(env["parent"], name)
# evaluator
def eval(expr, env):
match expr:
case None | bool(_) | int(_): return expr
case str(name): return get(env, name)
case ["func", params, body]: return ["func", params, body, env]
case ["define", name, val]:
return define(env, name, eval(val, env))
case ["if", cnd, thn, els]:
return eval(thn, env) if eval(cnd, env) else eval(els, env)
case [func, *args]:
return apply(eval(func, env), [eval(arg, env) for arg in args])
def apply(f_val, args_val):
if callable(f_val): return f_val(args_val)
_, params, body, env = f_val
env = {"parent": env, "vals": dict(zip(params, args_val))}
return eval(body, env)
# runtime
builtins = {
"+": lambda args: args[0] + args[1],
"-": lambda args: args[0] - args[1],
"=": lambda args: args[0] == args[1],
}
top_env = {"parent": None, "vals": builtins}
def run(src):
return eval(src, top_env)
テストも書く。
コーナーケースとかまでは考えていない。
# tests
assert run(None) == None
assert run(5) == 5
assert run(True) == True
assert run(False) == False
run(["define", "a", 5])
assert run("a") == 5
assert run(["if", True, 5, 6]) == 5
assert run(["if", False, 5, 6]) == 6
assert run(["+", 5, 6]) == 11
assert run(["-", 11, 5]) == 6
assert run(["=", 5, 5]) == True
assert run(["=", 5, 6]) == False
assert run([["func", ["n"], ["+", 5, "n"]], 6]) == 11
run(["define", "fib", ["func", ["n"],
["if", ["=", "n", 0], 0,
["if", ["=", "n", 1], 1,
["+", ["fib", ["-", "n", 1]], ["fib", ["-", "n", 2]]]]]]])
assert run(["fib", 0]) == 0
assert run(["fib", 1]) == 1
assert run(["fib", 2]) == 1
assert run(["fib", 3]) == 2
assert run(["fib", 10]) == 55
run(["define", "make_adder", ["func", ["n"], ["func", ["m"], ["+", "n", "m"]]]])
assert run([["make_adder", 5], 6]) == 11
print("Success")
実際にはコードとテストをひとつのファイルに書いている。
実行してSuccessと表示されたら成功。
$ python toig.py
Success
すでにデバッグが地獄になることが見えているので最低限のエラー処理は入れるようにしよう。
そういう意味ではeval(expr, env)
とrun(src)
を分けたのも自衛策だった。
eval(expr, env=top_env)
と書けばrun(src)
はいらないんだけれども
意図してないところでeval(expr)
と書いてしまうと変になるので。
文字列を渡して字句解析構文解析することもあるだろうということで引数はsrc
という名前にした。
以下のエラー処理を追加。
- 変数の二重定義
- 未定義の変数の参照
- 関数の引数の数の不一致
- 想定外の形の式
これくらいでもだいぶ助けになると思う。
テストでは、エラーになるかどうかだけを確認して、どういうエラーになるかまでは見ていない。
どういうエラーになるかまで見ていると、後で行った修正でエラーの出方が変わってしまい、テストが通らなくなることがしばしばあるので。
あと、こっそりdefine
が右辺の値を返すようにしている。
schemeでは名前を返すかnullを返すのが普通な感じだけれども使い道が思いつかない。
a = b = 5
が許されるなら["define", "a", ["define", "b", 5]]
だって許されるのではないか。
ソース:https://github.com/koba925/toig/blob/add_error/toig.py
差分:https://github.com/koba925/toig/compare/minimum...add_error
今後なにか実験をするとき、代入くらいはほしくなるはず。
代入できるようにすると、func
に(if
もかな)複数の式を並べて書きたくなるはず。
複数の式が並べて書けるようになると途中経過が知りたくなるはず。
ということでassign
とdo
とprint
を実装した。
do
は複数の式を並べるだけのもので、最後の式の値を返す。
スコープも作らない。
reduce
使えば1行。
前回の結果を捨てる感じが出ているのではなかろうか。
case ["do", *exprs]:
return reduce(lambda _, e: eval(e, env), exprs, None)
func
自体はひとつの式しか書けないままにしている。
なんかそのほうがピュアな感じが出るかと。
あとdefine
とassign
でスコープができてることを確認するテストを少し付けた。
ソース:https://github.com/koba925/toig/tree/assign_do_print
差分:https://github.com/koba925/toig/compare/add_error...assign_do_print
代入を作ったらカウンタを作る。
assign
が右辺の値を返すようにしたおかげで以前ならこう書かなければいけなかったものが
["define", "make_counter", ["func", [], ["do",
["define", "c", 0],
["func", [], ["do",
["assign", "c", ["+", "c", 1]],
"c"]]]]]
こう書けている。
["define", "make_counter", ["func", [], ["do",
["define", "c", 0],
["func", [], ["assign", "c", ["+", "c", 1]]]]]]
世の中なんでこうなっていないのかというと、
副作用のある式は本来値を返すべきではない(文であるべき)、ということだろうか。
しらんけど
Gemini Code Assistが事実上無制限に無償で使えるというので使ってみているのだけれど
GitHub CopilotやTabnineと比べて精度がかなり劣っている気がする。
このコードのバグを挙げてくれと聞いてみると4つ挙げてくれたが
どれも間違いでしかもかなり明らかな間違いだった。
Copilotに同じ質問をしてみたら、基本的には大丈夫だと前置きしたうえで
assign
やget
でenv
にループがあると無限ループしちゃうからチェックしたら?
とか
apply
でcallable
でないときはlist
かどうか確認した方がよくない?
などと一理ある答えだった。
コード補完もいまひとつ。
CopilotやTabnineだと、え、なんでそんなことまでわかるの?という感じで
Tab押せば終わってしまうケースも多かったのだけれど
Geminiはこちらの意図をうまく読み取ってくれない感じ。
だからこそ無償で出してデータ集めようとしてるんだろうけど。
育ってくれるかなあ?
なにか設定がぬけてたりするんだろうかもしかして。
テストの中で、今a
がいくつでc
は未定義で・・・とか考えてると破綻しそうだったり
ここだけちょっとテストしたいとかやりにくかったりとかするので
やっぱりテストは分けることにした。
環境を好きな時にリセットできるよう、環境の初期設定をinit_env()
に入れた。
クラスを定義した方が自然なんだろうか。
自分で定義した変数とbuiltins
が混ざらないようにするため階層を分けている。
def init_env():
global top_env
top_env = {
"parent": {"parent": None, "vals": builtins.copy()},
"vals": {}
}
ソース:https://github.com/koba925/toig/tree/separate_tests
差分:https://github.com/koba925/toig/compare/assign_do_print...separate_tests
さてマクロの実装に手を付けよう。
とはいってもASTが配列なので配列を扱えないとお話にならないのでまずは配列のサポートから。
最低限の機能をつけてあとは必要になったら付け足そう。
組み込み関数を追加。
配列を作るところくらいは特殊なことをしなきゃいけないかと思ったけど
引数が配列で渡されるのでそのまま返すだけだった。
特殊と言えば特殊だが。
"arr": lambda args: args,
"is_arr": lambda args: n_ary(1, lambda arr: isinstance(arr, list), args),
"len": lambda args: n_ary(1, lambda arr: len(arr), args),
"getat": lambda args: n_ary(2, lambda arr, ind: arr[ind], args),
"setat": setat,
"slice": slice,
あと+
は既存のものがそのまま配列でも使えるので、これくらいあればやりたいことはできるだろう。
slice
はなくても自前で書けそうだがPythonのお力を拝借しておく。
上で呼び出されてる関数はこのあたり。
def n_ary(n, f, args):
assert len(args) == n, \
f"Argument count doesn't match: `{args}` @ n_ary"
return f(*args[0:n])
def setat(args):
assert len(args) == 3, \
f"Argument count doesn't match: `{args}` @ setat"
args[0][args[1]] = args[2]
return args[0]
def slice(args):
if len(args) == 1:
return args[0][:]
elif len(args) == 2:
return args[0][args[1]:]
elif len(args) == 3:
return args[0][args[1]:args[2]]
assert False, f"Invalid slice: args=`{args}` @ slice"
引数の数で処理を変えられるようにした方が便利かなあ?
そのためにはまず可変長の引数をうまくつかえるようにしないとか。
やりたくなったらやろう。
あとquote(クォート)を作った。
None
と数とTrue
とFalse
だけではやっぱりマクロの書きようがないので。
Schemeではquote
だけど短縮形がないので"q"
一文字にする。
def eval(expr, env):
match expr:
...
case ["q", expr]: return expr
...
expr
をなにもせずに返すだけというもの。
run(["q", "abc"])
とすると"abc"
が返される。
run(["q", ["+", 5, 6]])
とすると["+", 5, 6]
が(11にならずに)そのまま返される。
本来の目的とはすこし違うけれどもちょっとした文字列代わりにも使える。
if __name__ == "__main__":
init_env()
run(["print", ["q", "hello"], ["q", "world"]])
これで例のメッセージが!
$ python toig.py
hello world
ソース:https://github.com/koba925/toig/tree/array_quote
差分:https://github.com/koba925/toig/compare/separate_tests...array_quote
準備はできたきがするのでマクロ自体を作りこむ。
まずはmacro
というキーワードでfunc
とそっくりなものを作る。
...
case ["func", params, body]:
return ["func", params, body, env]
case ["macro", params, body]:
return ["macro", params, body, env]
...
作ったときのenv
を覚えているので静的スコープになるわけだがその方がよいのかどうかはよくわからない。
func
だったらapply
する、のところでmacro
も扱えるようにする。
...
case [op, *args]:
return eval_op(op, args, env)
...
def eval_op(op, args, env):
match eval(op, env):
case ["macro", params, body, macro_env]:
return eval(expand(body, params, args, macro_env), env)
case f_val:
return apply(f_val, [eval(arg, env) for arg in args])
def expand(body, params, args, env):
assert len(params) == len(args), \
f"Argument count doesn't match: `{args}` @ expand"
env = {"parent": env, "vals": dict(zip(params, args))}
return eval(body, env)
func
をapply
する時と違い、渡された引数を評価しないでexpand
に渡しているところがひとつめのミソ。
expand
は引数をそのまま環境に追加したらapply
同様にbody
をeval
して返す。
さらにexpand
が返してきたものをASTとしてもう一度eval
するところがふたつめのミソ。
これらによって動的にプログラムを書いて動かしているわけだ。
ソース:https://github.com/koba925/toig/tree/macro
差分:https://github.com/koba925/toig/compare/array_quote...macro
マクロのいちばん簡単な例としてよく出てくるwhen
を作る。
〇〇のときは××する、というもの。
要するにthenしかないif。
["when", ["not", ["=", "b", 0], ["/", "a", "b"]]
と書いたら
["if", ["not", ["=", "b", 0]], ["/", "a", "b"], None]
と変換してから実行する。
これをこう書く。
run(["define", "when", ["macro", ["cnd", "thn"],
["arr", ["q", "if"], "cnd", "thn", None]]])
これで["when", ["not", ["=", "b", 0]], ["/", "a", "b"]]
と書けば
b
が0でなければa
÷b
を、b
が0だったらNone
を返してくれる。
(ここでこっそり*
・/
・not
を追加で作っている)
これを関数も書けんじゃねとこんなふうに作ってしまうと、
run(["define", "when-f", ["func", ["cnd", "thn"],
["if", "cnd", "thn", None]]])
run(["when-f", ["not", ["=", "b", 0]], ["/", "a", "b"]])
実行したときにまず["not", ["=", "b", 0]]
と["/", "a", "b"]
を両方評価してから
"when-f"
にapply
しようとするのでdivision by zeroになってしまう。
マクロがどんな式を作ってくれてるのか見るためにexpand
も追加している。
...
case ["expand", [op, *args]]:
return eval_expand(op, args, env)
...
def eval_expand(op, args, env):
match eval(op, env):
case ["macro", params, body, macro_env]:
return expand(body, params, args, macro_env)
case unexpected:
assert False, f"Macro expected: {unexpected} @ eval"
つまりexpand
したら評価せずにそのまま返しているだけ。
["expand", ["when", ["not", ["=", "b", 0]], ["/", "a", "b"]]]
を評価すれば
["if", ["not", ["=", "b", 0]], ["/", "a", "b"], None]
を返してくれる。
もうひとつ簡単な例としてand
とor
を書いた。
and
とor
でマクロを使うのはショートサーキットを実現するため。
ショートサーキットとは["and", "a", "b"]
としたとき、
"a"
が偽なら"b"
にかかわらず全体が偽になるので"b"
は評価しない、というもの。
別法として["func", [], ...]
で囲って遅延させてやるというのもあるがいちいちそう書くのも面倒。
when
、and
、or
は今後も使いたくなりそうなので標準ライブラリにする。
といっても関数内でマクロ定義をrun
しているだけ。
今の形ならこれで十分。
例によって環境を1階層分使って、組み込み関数やユーザ定義の名前と分離するようにしている。
def stdlib():
run(["define", "when", ["macro", ["cnd", "thn"],
["arr", ["q", "if"], "cnd", "thn", None]]])
run(["define", "and", ["macro", ["a", "b"], ["arr", ["q", "if"], "a", "b", False]]])
run(["define", "or", ["macro", ["a", "b"], ["arr", ["q", "if"], "a", True, "b"]]])
global top_env
top_env = {"parent": top_env, "vals": {}}
使うときはinit_env()
とstdlib()
を呼んでからrun()
。
init_env()
stdlib()
run(["print", ["q", "hello"], ["q", "world"]])
ソース:https://github.com/koba925/toig/tree/and_or_stdlib
差分:https://github.com/koba925/toig/compare/macro...and_or_stdlib
やってみて初めて気が付いたんだけれども、expand
では一段しかマクロを展開しない。
["expand", ["and", ["or", ["=", 5, 6], ["=", 7, 7]], ["=", 8, 8]]]
は
["if", ["or", ["=", 5, 6], ["=", 7, 7]], ["=", 8, 8], False]
になる。
与えられた引数を評価せずにそのまま置き換える、なので当然なんだけれども。
マクロを全部展開しきろうと思ったら再帰的にたどっていかないといけないのか。
実用的には1段で足りるので(むしろ全部展開されると見づらそう)全部展開は宿題にして進む。
ちょっと複雑な題材としてwhile
をやってみる。
最初の版としては、こう書いたら、
["while", ["<", "a", 5], ["do",
["assign", "b", ["+", "b", ["arr", "a"]]],
["assign", "a", ["+", "a", 1]]]]]
こういうコードが出るようにしよう。
いろいろ気になるところはあるけどとりあえず。
["do",
["define", "loop", ["func", [],
["when", ["<", "a", 5],
["do",
["do",
["assign", "b", ["+", "b", ["arr", "a"]]],
["assign", "a", ["+", "a", 1]]],
["loop"]]]]],
["loop"]]
できあがりの式を見ながらマクロを書く。
["define", "while", ["macro", ["cnd", "body"], ["arr", ["q", "do"],
["arr", ["q", "define"], ["q", "loop"], ["arr",
["q", "func"], ["arr"], ["arr",
["q", "when"], "cnd",
["arr", ["q", "do"], "body", ["q", ["loop"]]]]]],
["q", ["loop"]]]]]
これが動くようになった。
["do",
["define", "a", 0],
["define", "b", ["arr"]],
["while", ["<", "a", 5], ["do",
["assign", "b", ["+", "b", ["arr", "a"]]],
["assign", "a", ["+", "a", 1]]]]]
次はこれを動かす。
["do",
["define", "r", ["arr"]],
["define", "c", ["arr"]],
["while", ["<", ["len", "r"], 3],
["do",
["assign", "c", ["arr"]],
["while", ["<", ["len", "c"], 3],
["assign", "c", ["+", "c", ["arr", "x"]]]],
["assign", "r", ["+", "r", ["arr", "c"]]]]]]
今は外側のwhile
と内側のwhile
がどちらもloop
を定義しようとするのでエラーになる。
スコープを切ればいいだろう。
func
でくるんで即実行。JavaScriptでよく見かけるあれ。
直接while
の中でやってもいいけどそれだけやるマクロを書こう。
これでいいはず。
["define", "scope", ["macro", ["body"],
["arr", ["arr", ["q", "func"], ["arr"], "body"]]]]
while
の定義をくるむ。
["define", "while", ["macro", ["cnd", "body"], ["arr", ["q", "scope"],
["arr", ["q", "do"],
["arr", ["q", "define"], ["q", "loop"], ["arr",
["q", "func"], ["arr"], ["arr",
["q", "when"], "cnd",
["arr", ["q", "do"], "body", ["q", ["loop"]]]]]],
["q", ["loop"]]]]]]))
できた。
ソース:https://github.com/koba925/toig/tree/while_scope
差分:https://github.com/koba925/toig/compare/and_or_stdlib...while_scope
while
の定義はarr
とq
の嵐で少々見づらい。
quasiquote(準クォート)を作ろう。
quote(q
)は引数を全く評価せずそのまま返し、arr
は引数をすべて評価して返してくれるもの。
quasiquoteは基本的にはquoteなんだけれども、指定した部分だけは評価するというもの。
quoteがstrだとしたらquasiquoteはf-string(わかりづらい
quasiquoteでは長いのでqq
とし、評価したい部分は!
をつけることにする。
そうするとwhile
はこんなふうに書けるはず。
["define", "while", ["macro", ["cnd", "body"], ["qq",
["scope", ["do",
["define", "loop", ["func", [],
["when", ["!", "cnd"], ["do", ["!", "body"], ["loop"]]]]],
["loop"]]]]]]
だいぶ見やすい。
def is_arr(elem): return isinstance(elem, list)
def eval_quasiquote(elem, env):
if not is_arr(elem): return elem
quoted = []
for e in elem:
match e:
case ["!", e]: quoted.append(eval(e, env))
case _: quoted.append(eval_quasiquote(e, env))
return quoted
これでできた。意外とシンプル。
ほかのマクロもqq
で書き直す。
run(["define", "scope", ["macro", ["body"],
["qq", [["func", [], ["!", "body"]]]]]])
run(["define", "when", ["macro", ["cnd", "thn"],
["qq", ["if", ["!", "cnd"], ["!", "thn"], None]]]])
run(["define", "and", ["macro", ["a", "b"],
["qq", ["if", ["!", "a"], ["!", "b"], False]]]])
run(["define", "or", ["macro", ["a", "b"],
["qq", ["if", ["!", "a"], True, ["!", "b"]]]]])
and
やor
はかえって長くなってしまってるけれども見やすさはこちらの方が上、
・・・な気がする。
ソース:https://github.com/koba925/toig/tree/quasiquote
差分:https://github.com/koba925/toig/compare/while_scope...quasiquote
次はfor e in [5, 6, 7]:
的なやつを書いてみよう。
どうせ使うならwhile
よりそっちのほうがいい。
こう書いたら
["for", "i", ["arr", 5, 6, 7], ["assign", "sum", ["+", "sum", "i"]]]
こう展開されるようにする。
動くことも確認。
["scope", ["do",
["define", "__index", 0],
["define", "i", None],
["while", ["<", "__index", ["len", ["arr", 5, 6, 7]]], ["do",
["assign", "i", ["getat", ["arr", 5, 6, 7], "__index"]],
["assign", "sum", ["+", "sum", "i"]],
["assign", "__index", ["inc", "__index"]]]]]]
ひとつ変数を導入する必要があって、["assign", "sum", ["+", "sum", "i"]]
の中で使ってる変数とぶつからないようにしないといけないんだけれどもアンダースコアつけたくらいで逃げる。
展開前後を見比べてマクロを書く。
["define", "for", ["macro", ["e", "l", "body"], ["qq",
["scope", ["do",
["define", "__index", 0],
["define", ["!", "e"], None],
["while", ["<", "__index", ["len", ["!", "l"]]], ["do",
["assign", ["!", "e"], ["getat", ["!", "l"], "__index"]],
["!", "body"],
["assign", "__index", ["inc", "__index"]]]]]]]]]
qq
があるとやっぱり楽だなあ。
エラトステネスのふるいを書いてみた。
run(["define", "sieve", ["+",
["*", ["arr", False], 2],
["*", ["arr", True], 28]]])
run(["define", "j", None])
run(["for", "i", ["range", 2, 30],
["when", ["getat", "sieve", "i"],
["do",
["assign", "j", ["*", "i", "i"]],
["while", ["<", "j", 30], ["do",
["setat", "sieve", "j", False],
["assign", "j", ["+", "j", "i"]]]]]]])
run(["print", "sieve"])
run(["define", "primes", ["arr"]])
run(["for", "i", ["range", 0, 30],
["when", ["getat", "sieve", "i"],
["assign", "primes", ["append", "primes", "i"]]]])
run(["print", "primes"]) # → [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
range()
追加とかしてたらbuiltins
やstdlib
が太った。
この辺はコアじゃないから太ってもあまり気にしない。
builtins = {
...
"!=": lambda args: n_ary(2, op.ne, args),
...
"<=": lambda args: n_ary(2, op.le, args),
">=": lambda args: n_ary(2, op.ge, args),
...
}
...
def stdlib():
run(["define", "id", ["func", ["x"], "x"]])
run(["define", "inc", ["func", ["x"], ["+", "x", 1]]])
run(["define", "dec", ["func", ["x"], ["-", "x", 1]]])
run(["define", "unfoldl", ["func", ["x", "p", "h", "t"], ["do",
["define", "_unfoldl", ["func", ["x", "b"],
["if", ["p", "x"],
"b",
["_unfoldl", ["t", "x"], ["+", "b", ["arr", ["h", "x"]]]]]]],
["_unfoldl", "x", ["arr"]]]]])
run(["define", "range", ["func", ["s", "e"],
["unfoldl", "s", ["func", ["x"], [">=", "x", "e"]], "id", "inc"]]])
run(["define", "append", ["func", ["l", "a"], ["+", "l", ["arr", "a"]]]])
run(["define", "prepend", ["func", ["a", "l"], ["+", ["arr", "a"], "l"]]])
...
slice()
はmatch
で書けばいいじゃんと思って書き直し。
def slice(args):
match args:
case [arr]: return arr[:]
case [arr, start]: return arr[start:]
case [arr, start, end]: return arr[start:end]
case _: assert False, f"Invalid slice: args=`{args}` @ slice"
コミットを分けたりはしていない(誰に向かって言っているのか
ソース:https://github.com/koba925/toig/tree/for-in
差分:https://github.com/koba925/toig/compare/quasiquote...for-in
let
(ぽいもの)も作ってみよう。
let
はローカル変数を作る構文で、
["let", [["a", 5], ["b", 6]], ["+", "a", "b"]]
と書くと11になる。
上のlet
はこんな風に展開するようにする。
["scope", ["do",
["define", "a", 5],
["define", "b", 6],
["+", "a", "b"]]]
手でこう書いてもそう変わらないのでstdlib
には入れないつもり。
[["a", 5], ["b", 6]]
の処理と、do
の中身の処理がこれまでよりも少し複雑になる。
[["a", 5], ["b", 6]]
をdefine
の並びに変換する処理は関数にする。
["define", "defines", ["func", ["bindings"],
["map", "bindings", ["func", ["b"], ["qq",
["define",
["!", ["first", "b"]],
["!", ["last", "b"]]]]]]]]
(map
はこっそりstdlib()
に追加している)
この関数をどこに覚えておくかというと、マクロ内部に閉じ込めておく。
静的スコープにしているのでこれができる。
do
の中身についてはこんな感じで書ければいいんだけれども、
["qq", ["scope", ["do", ["!", ["defines", "bindings"]], ["!", "body"]]]]]]])
これではこうなってしまう(do
の後ろにカッコがひとつ多い)。
["scope", ["do", [
["define", "a", 5],
["define", "b", 6],
["+", "a", "b"]]]]
配列の+
を使えばやってできないことはなくてたとえばこう書ける(後半のqq
)。
["define", "let", ["macro", ["bindings", "body"], ["do",
["define", "defines", ["func", ["bindings"],
["map", "bindings", ["func", ["b"], ["qq",
["define",
["!", ["first", "b"]],
["!", ["last", "b"]]]]]]]],
["qq", ["scope", ["!", ["append",
["+",
["q", ["do"]],
["defines", "bindings"]],
"body"]]]]]]]
いちおうlet
の第一弾ができた。
このへんがめんどくさいので書きやすくする。
["qq", ["scope", ["!", ["append",
["+",
["q", ["do"]],
["defines", "bindings"]],
"body"]]]]
qq
にunquote-splicingという機能をつける。
!!
のときはappend
の代わりに+
を使う。
["do"]
に[6, 7]
をsplicingでくっつけると、["do", [6, 7]]
とならずに["do", 6, 7]
となる。
def eval_quasiquote(elem, env):
if not is_arr(elem): return elem
quoted = []
for e in elem:
match e:
case ["!", e]: quoted.append(eval(e, env))
case ["!!", e]:
vals = eval(e, env)
assert isinstance(vals, list), f"Cannot splice in quasiquote: {e}"
quoted += vals
case _: quoted.append(eval_quasiquote(e, env))
return quoted
!!
の処理でassertを入れているのは警告よけ。
これでさっきの式がこう書ける。
["qq", ["scope", ["do",
["!!", ["defines", "bindings"]],
["!","body"]]]]
考えてみると、do
の構文を["do", <expr>, <expr>, ...]
とせずに、可変部分を配列にして["do", [<expr>, <expr>, ...]]
としたほうがスジがよかったのかもしれない。ASTとしては。人間が読み書きすることを考えるとネストが浅くなる方がうれしいんだけれども。
なにはともあれ、マクロもちょっと書きやすくなってきたのでは。
ソース:https://github.com/koba925/toig/tree/let_qqspl
差分:https://github.com/koba925/toig/compare/for-in...let_qqspl
cond
を書いてみよう。
cond
はif
の親戚のようなもので[<条件>, <式>, <式>, ...]
という形を並べて、
条件に合ったところの式を評価するというもの。
["define", "fib", ["func", ["n"],
["if", ["=", "n", 0], 0,
["if", ["=", "n", 1], 1,
["+", ["fib", ["-", "n", 1]], ["fib", ["-", "n", 2]]]]]]]
と同じものをcond
で書くとこうなる。
["define", "fib", ["func", ["n"],
["cond",
[["=", "n", 0], 0],
[["=", "n", 1], 1],
[True, ["+", ["fib", ["-", "n", 1]], ["fib", ["-", "n", 2]]]]]]]]]
条件をTrue
にした行はほかがどれも実行されなかったときに実行される。
if
のelse
にあたる部分。
このcond
はこう展開されるようにする。
["if", ["=", "n", 0], 0,
["if", ["=", "n", 1], 1,
["if", True, ["+", ["fib", ["-", "n", 1]], ["fib", ["-", "n", 2]]],
None]]]]
if
-True
はもうちょっとキレイにできそうだけれども気が向いたら。
[<条件>, <式>, <式>, ...]
と式を並べられるようにするかどうかも考えどころ。
今のところ式を並べるのはdo
に任せているので、その原則を崩すかどうか。
そしてcond
自身も可変長の引数を取るのでまずは可変長引数を受け取れるようにしないと。
これも配列に入れてしまうという手はあるけど。
関数とかif
とかはピュアな関数を扱っている限り複数の式を書ける必要がない。
cond
は複数の節を並べられるということに意味がある。
ということで並べて書ける意味が大きい。
ということにしておく。
可変長引数を試したいというだけだったりもする。
今まで
env = {"parent": env, "vals": dict(zip(params, args))}
の1行で済ませていたものを以下の関数を呼ぶようにした。
なんか急に膨れ上がったけれどもエラー処理の行数が多いせいで、じっさいやってることはたいしたことはない。
from itertools import zip_longest
def extend(env, params, args):
sentinel, new_env = object(), {}
for i, (param, arg) in enumerate(zip_longest(params, args, fillvalue=sentinel)):
match param:
case str(param):
assert arg is not sentinel, \
f"Argument count doesn't match: `{params}, {args}` @ extend"
new_env[param] = arg
case ["*", rest]:
assert i == len(params) - 1, \
f"Rest param must be last: `{params}` @ extend"
new_env[rest] = args[i:]; break
case unexpected:
assert param is not sentinel, \
f"Argument count doesn't match: `{params}, {args}` @ extend"
assert False, f"Unexpected param at extend: {unexpected}"
return {"parent": env, "vals": new_env}
とはいえもうちょっとなんとかならないかという気もする。
for i in range...
とかあんまりやりたくないのだけれどかといってzip_longest
とか若干やりすぎな気がしなくもない。
再帰で書いた方がいいかもしれない。
これで["macro", ["op", ["*", "r"]], ["+", ["arr", "op"], "r"]]
みたいに書けるようになった。
expand
だけでなくapply
でもextend
を呼んでいるので、関数でも使える。
そういえばmacro
とかquasiquote
のテストを書いてなかったなと気が付いて付け足してみたら通らなかった。
["qq", ["!", ["+", 5, 6]]]
という単純な式が正しく評価できない。
["!", ["+", 5, 6]]
の処理を配列の中でしか行ってなかった。
単に["arr", ["+", 5, 6]]
と書けばいいだけなのでユースケースはないんだけれども気持ちわるいので直した。
def eval_quasiquote(expr, env):
def qqelems(elems):
quoted = []
for elem in elems:
match elem:
case ["!!", e]:
vals = eval(e, env)
assert isinstance(vals, list), f"Cannot splice in quasiquote: {e}"
quoted += vals
case _: quoted.append(eval_quasiquote(elem, env))
return quoted
match expr:
case ["!", elem]: return eval(elem, env)
case [*elems]: return qqelems(elems)
case _: return expr