見出し画像

ゼロからはじめるスクリプト言語製作: 関数を定義する(13日目)

前回の実装によって、ユーザーは変数を定義して 自由に計算ができるようになった。今回は、いよいよ関数の概念をスクリプト言語に組み込んでいく。

ということで今回の実装ゴールを以下のように課して、実装を進めていくことにしよう。

<要件1>
1) シンボル lambda は2つの引数を取り、1つめは symbolv 型を複数含む S 式、2つめは S 式でなければならない
2) 1つめの S 式を引数シンボルリストとし、2 つめの S 式を処理内容とする1つの functionv 型を返す

> ($ 'square (lambda (' v) (' * v v)))
===> square

<要件2>
3) 生成された functionv の呼び出しを始めると、引数として与えられた S 式の要素を1つずつ評価して、引数シンボルリストに指定されていたシンボル名を持つ symbolv 型を一時的に生成する
4) 生成された functionv の呼び出しが終わると、一時的に生成されていた symbolv は削除される

> (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() は「lambda の1つめの S 式(引数シンボルリスト)を参考に symbolv を自動生成してから、2つめの S 式(処理本体)を評価する」ような evaluator を生成する

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 行の増加。

変数と関数が扱えるようになったので、一気に言語っぽさが増してきたのを実感している。これがなんとも言えない趣きがある。

この記事が気に入ったらサポートをしてみませんか?