見出し画像

ゼロからはじめるスクリプト言語製作: 文字列操作とリフレクション(19日目)

スクリプト言語製作もいよいよ終盤を迎えている。前回までの実装でプログラミング言語の基本的な文法や概念は実用レベルに達しているものの、定義されているシンボルについては不足がまだあるように感じている。

特に文字列の扱いについては 非常に雑な実装のままとなっているので、今回はこれを改善していこう

一般的に文字列を操作する手段というのは、プログラミング言語側で潤沢に準備されていることが多い。例えば C# の String クラスには157ものメソッドがある。中にはニッチなものもあるかもしれないが、これらを闇雲に選別して模倣して実装するのは得策ではない。

解決策として .NET 言語のリフレクション機能を借りて、あらゆるメソッドを呼び出せるようにスクリプト言語を進化させていこうと思う。

では今日の作業に取り掛かろう。

ユーザー入力解析の改善

その前に下準備として、入力されたスクリプトを解析してトークンに切り出す処理を見直しておこう。この tokenize 処理は、これまで Program.ReadLine() の中でワンライナーとして実装されていた。

var tokenize = (string line) => line.Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries).Select(parser.parse);

「それぞれの要素はスペースで区切る」という単一ルールで実装されているため、スペースを含むような文字列を要素に記述することができなかった。
これを「ダブルクォーテーションで挟まれた部分は、優先的にひとかたまり(文字列)として切り出す」というルールを追加して、改善する必要がある。それを実装したのが↓以下の Type.parser.tokenize() のコードである。

public static IEnumerable<expr> tokenize(string line)
{
	var tokens = new List<string>();
	while (line.Length > 0)
	{
		if (line.Trim().StartsWith('"'))
		{
			var token = line.Trim().Substring(1).Split('"', 2, StringSplitOptions.None);
			if (token.Length >= 1)
			{
				tokens.Add($"\"{token[0]}\"");
			}
			line = (token.Length == 2) ? token[1] : "";
		}
		else
		{
			var token = line.Trim().Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
			if (token.Length >= 1)
			{
				tokens.Add(token[0]);
			}
			line = (token.Length == 2) ? token[1] : "";
		}
	}
	return tokens.Select(parse);
}

文字列のプロパティーにアクセスする

ここから先はリフレクション機能を使っていく。まずはプロパティーのアクセスからだ。
以下のようなシンボルで呼び出すことにしよう。

get <stringv> <symbolv> [<expr> …]: 指定された文字列プロパティーを返す
set <stringv> <symbolv> <expr>: 指定された文字列プロパティーに値を代入する

プロパティーアクセスには Type.InvokeMember() という関数を使う。関数のプロトタイプを↓以下に引用しておこう。

Object InvokeMember(String name, BindingFlags invokeAttr, Binder binder, Object target, Object[] args)

シンボル get の実装 Core.getProp() は↓以下のようになった。

public static expr getProp(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out stringv? v0, out symbolv? v1);
	if (v0 is null || v1 is null) throw new Exception("wrong number of args");
	System.Type type = v0._val.GetType();
	BindingFlags flags = BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static;
	var argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
	var r = type.InvokeMember(v1._val, flags, null, v0._val, argList);
	return Type.binder.ToExpr(r);
}

Type.InvokeMember() の引数 name にはプロパティー名(String 型の場合は "Length" や "Chars")を指定する必要がある。これは symbolv 型の引数から抽出したシンボル名で代用している。
引数 invokeAttr には BindingFlags.GetProperty という Enum 値を指定する必要がある(setter を呼び出す場合は代わりに BindingFlags.SetProperty を指定する)。
引数 target には文字列変数を指定する必要がある。これは stringv 型から取り出したものを渡している。
引数 args にはプロパティーにアクセスするために必要となる、追加の引数リストを指定することができる。多くの場合これは null で良いのだが、例えば String.Chars に対しては、Int32 型でインデックスを指定する必要がある。
引数 binder は必要が無いので null で良い。

この Type.InvokeMember() の呼び出しの前後では、スクリプト言語的に重要な2つの仕事をしている。

まず引数リストについては、cell 型に格納された要素を順次取り出して、まとめて Object[] 型で表現し直いている。取り出した要素がまた cell 型であった場合の対処も必要になる。そのために Type.cell.ToArgs() というメソッドを追加で定義した(後述)。

また戻り値を Object 型から expr 型に変換する必要がある。このために Type.binder.ToExpr() というメソッドを追加で定義した。将来的なことを考えて、このメソッドはどんな戻り値でもうまく変換してくれるように実装しておきたかった。結果、見ての通り非常に泥臭い処理内容となっている。

public class binder
{
	public static expr ToExpr(object? obj)
	{
		if (obj is null) return new nil();
		if (obj is string s) return new stringv(s);
		if (obj is char c) return new stringv(Convert.ToString(c));
		if (obj is bool b) return new boolv(b);
		if ((obj is sbyte) || (obj is byte) || (obj is short) || (obj is ushort) || (obj is int) || (obj is uint) || (obj is long) || (obj is ulong)) return new numberv(Convert.ToInt64(obj));
		if ((obj is float) || (obj is double)) return new floatv(Convert.ToDouble(obj));
		if ((obj is object[] objs)) return new cell(objs.Select((object? arg) => ToExpr(arg)));
		if ((obj is object)) return new objectv(obj);
		throw new Exception("no compatible type");
	}
}

.NET では整数型が多数定義されているが、これらはすべてスクリプト言語の中では numberv 型ひとつで扱うことになる。numberv 型の内部は long 型(符号付き 64 ビット整数)なので、大抵の型はキャストして代入ができる。
そして引数 obj に対応する型がスクリプト言語で定義されていない場合にも対応できるよう、ここで新たに objectv 型を導入している。これは Object 型のものなら何でも保持できる(つまりすべてを格納できる)。

これでプロパティーアクセスが可能になった。
↓以下はコンパイルして実行したみたところだ。

文字列のプロパティーへのアクセスができた!(初めてのリフレクション)

文字列のメソッドにアクセスする

次はメソッドの呼び出しだ。
以下のようなシンボルで呼び出すことにしよう。

. <stringv> <symbolv> [<expr> …]: 指定された文字列メソッドを実行する

メソッドの呼び出しにも Type.InvokeMember() 関数を使う。
シンボル . の実装 Core.invokeMemberFunc() は↓以下のようになった。

public static expr invokeMemberFunc(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out stringv? v0, out symbolv? v1);
	if (v0 is null || v1 is null) throw new Exception("wrong number of args");
	System.Type type = v0._val.GetType();
	BindingFlags flags = BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance;
	var argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
	var r = type.InvokeMember(v1._val, flags, null, v0._val, argList);
	return Type.binder.ToExpr(r);
}

プロパティーアクセスの時と違うのは、引数 invokeAttr に BindingFlags.InvokeMethod という Enum 値を与えるところだ。上記のコードを Core.getProp() と比較すると、invokeAttr の指定値以外の違いはまったく無い。
ちなみに引数リストを生成している list_(…) の部分は、シンボル list の実装を流用している。

メソッドを呼び出している様子は↓以下のとおりだ。

文字列のメソッドの呼び出しに成功!

文字列のクラスメソッドにアクセスする

最後はクラスメソッドの呼び出しだ。
以下のようなシンボルで呼び出すことにしよう。

: <stringv> <symbolv> [<expr> …]: 指定された文字列型のクラスメソッドを実行する

クラスメソッドの呼び出しにも Type.InvokeMember() 関数を使う。
シンボル : の実装 Core.invokeClassFunc() は↓以下のようになった。

public static expr invokeClassFunc(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out stringv? v0, out symbolv? v1);
	if (v0 is null || v1 is null) throw new Exception("wrong number of args");
	System.Type type = v0._val.GetType();
	if (type.ContainsGenericParameters) throw new Exception($"generic type name specified [{v0._val}]");
	BindingFlags flags = BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Static;
	var argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
	var r = type.InvokeMember(v1._val, flags, null, null, argList);
	return Type.binder.ToExpr(r);
}

引数 invokeAttr の指定値から BindingFlags.Instance が減って BindingFlags.Static が加わっている点が、インスタンスメソッドの呼び出し時と違う。また引数 target には null を指定する。なので stringv の文字列の内容 v0._val は実質的に参照されていない。

クラスメソッドを呼び出している様子は↓以下のとおりだ。

文字列のクラスメソッドの呼び出しに成功!
最初の引数は参照されないので、空文字を指定している

小さな発見

リフレクション機能を Type.InvokeMember() の呼び出しを通じてアクセスすること自体は、そこまで難しい話ではないということが分かった。そして今回面倒だったのはその前後の、スクリプト言語側の表現力と .NET 言語側の表現力を仲介する部分であった。

リフレクション機能を利用する鍵は Type クラスにある。
Type.GetType() で変数の型に関する情報を取得したり、オブジェクトのメソッドやその引数に関する情報を得たりすることができる。ほかにも、ある型が別の型へ代入できるかどうかを調べたり、ジェネリック型に型パラメーターを組み合わせて型を生成したりもできる。リフレクション機能を理解する上で、Type クラスの理解は避けて通れない

今日の実装で活躍した Type.InvokeMember() も Type クラスのメソッドだ。
あるオブジェクト型で利用可能なすべてのメソッドの中から、まず指定した引数 name と一致するメソッドが選ばれ、それぞれのメソッドが受け付ける引数リストと引数 args で渡した引数リストがマッチするかどうかを調べて、最初にマッチしたメソッドが呼び出される。引数リスト同士がマッチするかどうかは、Type.DefaultBinder がうまく判断してくれる。

しかしながら、「Type.DefaultBinder がどういうルールでマッチしてくれるか」については、少し観察や経験が必要だった(これで1週間ほど溶かしてしまった…)。
例えば、引数リストを Object[] 型に変換する Type.cell.ToArgs() というメソッドを今回追加したが、その実装は↓以下のようになっている。

public class cell : expr, IEnumerable<expr>
{
	:

	public object?[] ToArgs() => this.Select((expr arg) =>
	{
		if (arg is nil) return null!;
		if (arg is cell c) return c.ToArgs();
		if (arg is stringv s) return s._val;
		if (arg is boolv b) return b._val;
		if (arg is numberv n) return Convert.ToInt32(n._val);
		if (arg is floatv f) return f._val;
		if (arg is objectv o) return o._val;
		throw new Exception("no compatible type");
	}).ToArray();

	:
}

注目すべきポイントは Convert.ToInt32() を介して numberv 型を Int32 型に変換するところだ。.NET オブジェクトのメソッドの多くは引数に Int32 型を求めているため、numberv 型を Int64/long 型に変換してしまうと Type.DefaultBinder はほとんどのメソッドにマッチしてくれず、MissingMethodException: Method 'System.String.~~' not found. という例外を発生してしまうのだ。Type.DefaultBinder はそれぞれの引数に対してある種の型変換をしてくれるのだが、Int64 型を Int32 型に縮小するような変換(強制縮小変換)はしてくれないというルールがあるためだ。

numberv 型を Int32 型に変換してしまう今回の Type.cell.ToArgs() のような実装は、実質的に 64 ビット整数型をメソッドの呼び出しに使えないという問題や、32 ビット未満の整数型を要求するメソッドを呼び出せないという問題を生じてしまっている。これについては次回以降に見直すことにしよう。

今日はここまで、おつかれさま。
Program.cs は 81 行、Type.cs は 356 行、Core.cs は 383 行。コード量は 94 行の増加。


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