Zenn
Open26

トイ言語実験日記

kb84tkhrkb84tkhr

フィボナッチとカウンタが実装できるだけの最小の評価器を書いてみた。
名前定義と条件分岐と再帰とクロージャ付きの関数だけを実装。
https://github.com/koba925/toig/tree/minimum

配列を使った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

これが実行できるだけの機能以外付けないようにした。
とはいえこれでもうけっこういろいろ書けるはず。
足し算と引き算しかないのでかけ算は自分で書かないといけないけど。
割り算はちょっとがんばらないと書けない気がする。

kb84tkhrkb84tkhr

コードはまるごと貼り付けられるレベル。
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)
kb84tkhrkb84tkhr

テストも書く。
コーナーケースとかまでは考えていない。

# 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")
kb84tkhrkb84tkhr

実際にはコードとテストをひとつのファイルに書いている。
実行してSuccessと表示されたら成功。

$ python toig.py
Success
kb84tkhrkb84tkhr

すでにデバッグが地獄になることが見えているので最低限のエラー処理は入れるようにしよう。

そういう意味ではeval(expr, env)run(src)を分けたのも自衛策だった。
eval(expr, env=top_env)と書けばrun(src)はいらないんだけれども
意図してないところでeval(expr)と書いてしまうと変になるので。
文字列を渡して字句解析構文解析することもあるだろうということで引数はsrcという名前にした。

kb84tkhrkb84tkhr

以下のエラー処理を追加。

  • 変数の二重定義
  • 未定義の変数の参照
  • 関数の引数の数の不一致
  • 想定外の形の式

これくらいでもだいぶ助けになると思う。
テストでは、エラーになるかどうかだけを確認して、どういうエラーになるかまでは見ていない。
どういうエラーになるかまで見ていると、後で行った修正でエラーの出方が変わってしまい、テストが通らなくなることがしばしばあるので。

あと、こっそり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

kb84tkhrkb84tkhr

今後なにか実験をするとき、代入くらいはほしくなるはず。
代入できるようにすると、funcに(ifもかな)複数の式を並べて書きたくなるはず。
複数の式が並べて書けるようになると途中経過が知りたくなるはず。

ということでassigndoprintを実装した。
doは複数の式を並べるだけのもので、最後の式の値を返す。
スコープも作らない。
reduce使えば1行。
前回の結果を捨てる感じが出ているのではなかろうか。

        case ["do", *exprs]:
            return reduce(lambda _, e: eval(e, env), exprs, None)

func自体はひとつの式しか書けないままにしている。
なんかそのほうがピュアな感じが出るかと。

あとdefineassignでスコープができてることを確認するテストを少し付けた。

ソース:https://github.com/koba925/toig/tree/assign_do_print
差分:https://github.com/koba925/toig/compare/add_error...assign_do_print

kb84tkhrkb84tkhr

代入を作ったらカウンタを作る。
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]]]]]]

世の中なんでこうなっていないのかというと、
副作用のある式は本来値を返すべきではない(文であるべき)、ということだろうか。
しらんけど

kb84tkhrkb84tkhr

Gemini Code Assistが事実上無制限に無償で使えるというので使ってみているのだけれど
GitHub CopilotやTabnineと比べて精度がかなり劣っている気がする。

このコードのバグを挙げてくれと聞いてみると4つ挙げてくれたが
どれも間違いでしかもかなり明らかな間違いだった。

Copilotに同じ質問をしてみたら、基本的には大丈夫だと前置きしたうえで
assigngetenvにループがあると無限ループしちゃうからチェックしたら?
とか
applycallableでないときはlistかどうか確認した方がよくない?
などと一理ある答えだった。

コード補完もいまひとつ。
CopilotやTabnineだと、え、なんでそんなことまでわかるの?という感じで
Tab押せば終わってしまうケースも多かったのだけれど
Geminiはこちらの意図をうまく読み取ってくれない感じ。

だからこそ無償で出してデータ集めようとしてるんだろうけど。
育ってくれるかなあ?
なにか設定がぬけてたりするんだろうかもしかして。

kb84tkhrkb84tkhr

テストの中で、今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

kb84tkhrkb84tkhr

さてマクロの実装に手を付けよう。
とはいっても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と数とTrueFalseだけではやっぱりマクロの書きようがないので。
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

kb84tkhrkb84tkhr

準備はできたきがするのでマクロ自体を作りこむ。

まずは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)

funcapplyする時と違い、渡された引数を評価しないでexpandに渡しているところがひとつめのミソ。
expandは引数をそのまま環境に追加したらapply同様にbodyevalして返す。
さらにexpandが返してきたものをASTとしてもう一度evalするところがふたつめのミソ。
これらによって動的にプログラムを書いて動かしているわけだ。

ソース:https://github.com/koba925/toig/tree/macro
差分:https://github.com/koba925/toig/compare/array_quote...macro

kb84tkhrkb84tkhr

マクロのいちばん簡単な例としてよく出てくる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になってしまう。

kb84tkhrkb84tkhr

マクロがどんな式を作ってくれてるのか見るために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]

を返してくれる。

kb84tkhrkb84tkhr

もうひとつ簡単な例としてandorを書いた。
andorでマクロを使うのはショートサーキットを実現するため。
ショートサーキットとは["and", "a", "b"]としたとき、
"a"が偽なら"b"にかかわらず全体が偽になるので"b"は評価しない、というもの。

別法として["func", [], ...]で囲って遅延させてやるというのもあるがいちいちそう書くのも面倒。

whenandorは今後も使いたくなりそうなので標準ライブラリにする。
といっても関数内でマクロ定義を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

kb84tkhrkb84tkhr

やってみて初めて気が付いたんだけれども、expandでは一段しかマクロを展開しない。

["expand", ["and", ["or", ["=", 5, 6], ["=", 7, 7]], ["=", 8, 8]]]

["if", ["or", ["=", 5, 6], ["=", 7, 7]], ["=", 8, 8], False]

になる。

与えられた引数を評価せずにそのまま置き換える、なので当然なんだけれども。
マクロを全部展開しきろうと思ったら再帰的にたどっていかないといけないのか。

実用的には1段で足りるので(むしろ全部展開されると見づらそう)全部展開は宿題にして進む。

kb84tkhrkb84tkhr

ちょっと複雑な題材として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]]]]]
kb84tkhrkb84tkhr

次はこれを動かす。

["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

kb84tkhrkb84tkhr

whileの定義はarrqの嵐で少々見づらい。
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"]]]]])

andorはかえって長くなってしまってるけれども見やすさはこちらの方が上、
・・・な気がする。

ソース:https://github.com/koba925/toig/tree/quasiquote
差分:https://github.com/koba925/toig/compare/while_scope...quasiquote

kb84tkhrkb84tkhr

次は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()追加とかしてたらbuiltinsstdlibが太った。
この辺はコアじゃないから太ってもあまり気にしない。

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

kb84tkhrkb84tkhr

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の第一弾ができた。

kb84tkhrkb84tkhr

このへんがめんどくさいので書きやすくする。

                ["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

kb84tkhrkb84tkhr

condを書いてみよう。
condifの親戚のようなもので[<条件>, <式>, <式>, ...]という形を並べて、
条件に合ったところの式を評価するというもの。

["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にした行はほかがどれも実行されなかったときに実行される。
ifelseにあたる部分。

このcondはこう展開されるようにする。

                ["if", ["=", "n", 0], 0,
                ["if", ["=", "n", 1], 1,
                ["if", True, ["+", ["fib", ["-", "n", 1]], ["fib", ["-", "n", 2]]],
                None]]]]  

if-Trueはもうちょっとキレイにできそうだけれども気が向いたら。

[<条件>, <式>, <式>, ...]と式を並べられるようにするかどうかも考えどころ。
今のところ式を並べるのはdoに任せているので、その原則を崩すかどうか。

kb84tkhrkb84tkhr

そして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を呼んでいるので、関数でも使える。

kb84tkhrkb84tkhr

そういえば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
ログインするとコメントできます