ゼロからはじめるスクリプト言語製作: 関数を定義する(13日目)
前回の実装によって、ユーザーは変数を定義して 自由に計算ができるようになった。今回は、いよいよ関数の概念をスクリプト言語に組み込んでいく。
ということで今回の実装ゴールを以下のように課して、実装を進めていくことにしよう。
> ($ 'square (lambda (' v) (' * v v)))
===> square
> (square 5)
===> 25
言葉で言い表してみると要件2は少々厄介に見えるかもしれないが、これは変数スコープの概念を示している。上述のとおりシンボル square が functionv 型として定義されているとき、square に続く 5 の部分が引数の値にあたる。この値を参照するために、lambda の引数シンボルリストにあったシンボル名 v が参照され、symbolv に変換して変数スコープに加えることで、ようやく square の処理本体に進む準備が整う。
そして square の処理本体が終わって変数スコープから抜けたときは、シンボル v は利用できなくなっている必要がある。
functionv の生成
functionv 型は連載6日目に基本型として導入したうちの1つで、↓以下のようなコードになっていた。
public delegate expr evaluator(expr args);
public class functionv : atomv<evaluator>
{
public functionv(evaluator val) : base(val)
{
}
public expr eval(expr args) => _val(args);
}
見てのとおり functionv を new で生成するには、evaluator というデリゲートを準備して引き渡す必要がある。要件1に従うと lambda には引数シンボルリストと処理本体の S 式が渡されてくるので、これらを組み合わせて evaluator を合成する必要がある。
evaluator を生成する処理を Core.binder() としたとき、lambda の処理内容となる Core.lambda() は↓以下のようなコードになった。
public static expr lambda(expr args)
{
cell? arg0 = args.astype<cell>();
cell? arg1 = arg0?.next().astype<cell>();
cell? arg2 = arg1?.next().astype<cell>();
if (arg2 is not null) throw new Exception("wrong number of args");
expr keys = arg0?.element().eval() ?? throw new Exception("invalid element type");
expr value = arg1?.element().eval() ?? throw new Exception("invalid element type");
evaluator func = binder(keys, value);
return new functionv(func);
}
このコードの大部分は、変数宣言の処理内容とほとんど同じになっていて、違うのは最後の2行ほどである。
参考までに Core.assign() を↓以下に再掲しておこう。
public static expr assign(expr args)
{
cell? arg0 = args.astype<cell>();
cell? arg1 = arg0?.next().astype<cell>();
cell? arg2 = arg1?.next().astype<cell>();
if (arg2 is not null) throw new Exception("wrong number of args");
expr key = arg0?.element().eval() ?? throw new Exception("invalid element type");
expr value = arg1?.element().eval() ?? throw new Exception("invalid element type");
return key.cast<symbolv>().assign(value);
}
evaluator の生成
evaluator を生成する処理 Core.binder() は、要件2の引数スコープを実現するものとしたい。まずは functionv に対する引数の数と lambda で指定された引数の数が一致するかどうかを確認し、評価結果を変数スコープに登録していく必要がある。
Core.binder() のコードは、↓以下のようになった。
private static symbolv binder0(expr key, expr arg)
{
cell? key0 = key.astype<cell>();
cell? arg0 = arg.astype<cell>();
symbolv key0_ = key0?.element().cast<symbolv>() ?? throw new Exception("invalid element type");
expr arg0_ = arg0?.element().eval() ?? throw new Exception("invalid element type");
return new symbolv(key0_._val).assign(arg0_);
}
private static evaluator binder(expr keys, expr value)
{
return (expr args) => {
var symbols = new List<symbolv>();
for (cell? key = keys.astype<cell>(), arg = args.astype<cell>(); key is not null || arg is not null; (key, arg) = (key.next().astype<cell>(), arg.next().astype<cell>()))
{
if (key is null || arg is null)
{
symbols.ForEach((symbolv v) => v.Dispose());
throw new Exception("wrong number of args");
}
symbols.Add(binder0(key, arg));
}
expr val = value.eval();
symbols.ForEach((symbolv v) => v.Dispose());
return val;
};
}
サブルーチン Core.binder0() は、引数シンボルリスト keys と引数 args から1組のシンボル名と評価結果を受け取り、1つの symbolv 型を生成している。これを引数の数だけ for 文で繰り返して、生成されたいくつかの symbolv を evaluator のための変数スコープ symbols に順次加えている。
これで要件1・2を満たすことができた。
lambda のシンボル定義を追加して、コンパイル&デバッグしたのが下図である。
※ 行頭「+」箇所は行追加。
static Core()
{
new symbolv("writeln").assign(new functionv(Core.writeln));
new symbolv("+").assign(new functionv(Core.add));
new symbolv("-").assign(new functionv(Core.sub));
:
+ new symbolv("lambda").assign(new functionv(Core.lambda));
}
変数スコープの将来について少し考える
前回までの変数はすべてがグローバル変数のようにふるまっていた。それに対し、今回初めて変数スコープの概念を導入したのだが、その実装はまだまだ簡易的なものとなっていて、今後も少しずつ改善していく余地が残っている。
すでにお気づきの読者がいるかもしれないが、現状の実装ではグローバルなスコープとローカルな変数スコープの間にシンボル名の重複があったとき、奇妙な副作用が発生してしまうのである。
↓以下の実行例を見てほしい。
グローバルなシンボル変数 v を定義してから、square を実行すると、writeln を実行する段階ではすでに v が消失してしまっているため、unknown symbol 例外が発生してしまった。
この問題については、次回以降に対策していこうと思う。
小さな発見
先に提示した Core.binder() では要件 4) を満たすために、変数スコープを示す symbols の破棄処理(symbols.ForEach() の呼び出し)を関数の出口2か所にそれぞれ配置していた。
実は スコープを抜けるときに自動的にリソースの破棄を行う仕組みが、C# には準備されている。これを活用して Core.binder() を書き直してみよう。
※ 行頭「+」箇所は行追加。行頭「-」箇所は行削除。
private static evaluator binder(expr keys, expr value)
{
return (expr args) => {
- var symbols = new List<symbolv>();
+ using var symbols = new symbolv.scope();
for (cell? key = keys.astype<cell>(), arg = args.astype<cell>(); key is not null || arg is not null; (key, arg) = (key.next().astype<cell>(), arg.next().astype<cell>()))
{
if (key is null || arg is null)
{
- symbols.ForEach((symbolv v) => v.Dispose());
throw new Exception("wrong number of args");
}
symbols.Add(binder0(key, arg));
}
- expr val = value.eval();
- symbols.ForEach((symbolv v) => v.Dispose());
- return val;
+ return value.eval();
};
}
using 構文を使って symbols を宣言しているので、return 文で関数を抜けるときも throw 文で関数を抜けるときも、symbols の破棄処理を徹底することができる。こうした方が、将来 Core.binder() に改修が入った時も 破棄処理の呼び忘れが発生することがないし、戻り値を準備する return 文周辺のコードも より直感的に書ける。
using 構文を使うため、symbols は IDisposable インターフェースをサポートする必要がある。そのため Type.cs のクラス symbolv に、内部クラス scope を定義している。
※ 行頭「+」箇所は行追加。
public class symbolv : atomv<string>, IDisposable
{
:
+ public class scope : List<symbolv>, IDisposable
+ {
+ public void Dispose() => ForEach((symbolv v) => v.Dispose());
+ }
}
今日はここまで、おつかれさま。
Program.cs は計 81 行、Type.cs は計 244 行、Core.cs は 187 行。コード量は前回から 43 行の増加。
変数と関数が扱えるようになったので、一気に言語っぽさが増してきたのを実感している。これがなんとも言えない趣きがある。