見出し画像

ゼロからはじめるスクリプト言語製作: リフレクションをもっと極める(21日目)

前回の実装によって、ユーザーは任意の .NET クラスを指定し、生成することができるようになった。生成したインスタンスのメソッドを呼び出すこともできるので、.NET ライブラリを活用した記述が可能になった。

今回は .NET ライブラリの活用における細かな制約について触れながら、それぞれを取り除いていき、.NET ライブラリを最大限活用できることを目標にした。

Int32 以外の整数型引数を含むメソッドを呼び出せない問題

前々回の記事で、引数に整数型を求めているメソッドを呼び出すには、numberv 型の要素を Int64 型から Int32 型に変換しなければならない必要性と、そのデメリットについて述べた。

例として、文字列から部分文字列を得る String.Substring() メソッドの呼び出しを考えてみよう。

引数のマッピング問題(縮小変換のサポート)

C# ランタイム側では String.Substring() の受付可能な引数型として "Int32" か "Int32, Int32" を期待している。一方でスクリプトエンジン側では、引数部分の numberv 型を一律 Int32 型に変換しているのが現状の実装だ。
これで String.Substring() の呼び出しについてはうまくマッチしてくれるのだが、それ以外のすべての .NET ライブラリを考えた場合、引数に Int32 以外の整数型を求めるメソッドも存在するはずで、そういったメソッドはスクリプト側から呼び出せないことになってしまうのだ。

この問題に対処するため、リフレクションを介したメソッド呼び出しの中核である Type.InvokeMember() の使い方を見直していく必要がある。
↓以下に Core.invokeMemberFunc() のコードを抜粋した。

public static expr invokeMemberFunc(objectv v0, expr args)
{
	:
	var argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
	var r = type.InvokeMember(member, flags, null, v0._val, argList);
	return Type.binder.ToExpr(r);
}

実はここに問題の本質が眠っている。
よく見ると引数リスト argList(Object[] 型)を準備してから、Type.InvokeMember() を呼び出している。引数リストを準備する段階で、呼び出したいメンバーメソッドが「どのような型の引数を求めているのか」を把握できていないのだ。

任意に呼び出したいメンバーメソッド member の整数引数は、SByte / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 のうちのどれであれば良いのか。どの型に変換しておくとうまくマッチしてくれるのか。
スクリプトを入力するユーザーにこれを意識させたくないのなら、スクリプトエンジン側がこの引数型のニーズに応じて、numberv 型の内部変数 _val を変換しなければならない

この解決策は「引数リストを expr[] 型のまま引き渡して、Type.DefaultBinder によるマッチングをやめて、代わりに縮小変換をサポートするような Binder を自作する」となってしまうのだ。
少し長い道のりになるが、順番に説明していこう。

自作した Binder の導入

リフレクションの Binder クラスは抽象クラスになっており、6つの仮想メソッドを実装することでカスタマイズが可能になっている。
そのうち今回カスタマイズが不要なメソッドは↓以下の3つである。

public abstract System.Reflection.FieldInfo BindToField (System.Reflection.BindingFlags bindingAttr, System.Reflection.FieldInfo[] match, object value, System.Globalization.CultureInfo? culture);

public abstract System.Reflection.MethodBase? SelectMethod (System.Reflection.BindingFlags bindingAttr, System.Reflection.MethodBase[] match, Type[] types, System.Reflection.ParameterModifier[]? modifiers);

public abstract System.Reflection.PropertyInfo? SelectProperty (System.Reflection.BindingFlags bindingAttr, System.Reflection.PropertyInfo[] match, Type? returnType, Type[]? indexes, System.Reflection.ParameterModifier[]? modifiers);

これらについては Type.DefaultBinder の実装を暫定的に流用しておこう。自作したコードは↓以下のようなものになった。

public class binder : Binder
{
	public binder() : base()
	{
	}

	public override FieldInfo BindToField(BindingFlags bindingAttr, FieldInfo[] match, object value, CultureInfo? culture) => System.Type.DefaultBinder.BindToField(bindingAttr, match, value, culture);
	public override MethodBase? SelectMethod(BindingFlags bindingAttr, MethodBase[] match, System.Type[] types, ParameterModifier[]? modifiers) => System.Type.DefaultBinder.SelectMethod(bindingAttr, match, types, modifiers);
	public override PropertyInfo? SelectProperty(BindingFlags bindingAttr, PropertyInfo[] match, System.Type? returnType, System.Type[]? indexes, ParameterModifier[]? modifiers) => System.Type.DefaultBinder.SelectProperty(bindingAttr, match, returnType, indexes, modifiers);

	:
}

次はメソッドのマッチングを担っている Binder.BindToMethod() というメソッドだ。

public abstract System.Reflection.MethodBase BindToMethod (System.Reflection.BindingFlags bindingAttr, System.Reflection.MethodBase[] match, ref object?[] args, System.Reflection.ParameterModifier[]? modifiers, System.Globalization.CultureInfo? culture, string[]? names, out object? state);

メソッド引数が多めで少々たじろいでしまうが、やるべきことはそう多くはない。今回は中身が expr[] 型である引数リスト args と、マッチング候補のメソッド配列 match を比較した上で、マッチしたメソッド1つを戻り値として返すように実装すればよい。

一つ一つの引数の比較において、例えば stringv 型は String 型に変換可能として扱いたいし、-12345 という値を保持している numberv 型は Int16 / Int32 / Int64 のどれにでも変換可能で、SByte / Byte / UInt16 / UInt32 / UInt64 には変換不可能として扱いたい(保持している値に応じた条件付き縮小変換)。
これを愚直に実装した場合は for 文の二重ループになってしまうのだが、C# の LINQ 構文のおかげで↓以下のような視認性のよいコードにすることができた。

	public override MethodBase BindToMethod(BindingFlags bindingAttr, MethodBase[] match, ref object?[] args, ParameterModifier[]? modifiers, CultureInfo? culture, string[]? names, out object? state) => BindToMethod_(bindingAttr, match, ref args, modifiers, culture, names, out state);

	private MethodBase BindToMethod_(BindingFlags bindingAttr, MethodBase[] match, ref object?[] args, ParameterModifier[]? modifiers, CultureInfo? culture, string[]? names, out object? state)
	{
		object?[] args_ = (object?[])args.Clone();
		if (match == null) throw new ArgumentNullException();
		try
		{
			var fittedMethod = match
				.Where((MethodBase methodBase) =>
				{
					ParameterInfo[] parameters = methodBase.GetParameters();
					return (args_.Length == parameters.Length) &&
						args_
							.Zip(parameters, (arg, param) => new { arg, param })
							.All(t => TryChangeType(t.arg, t.param, culture, out var _));
				})
				.First();
			args = args_
				.Zip(fittedMethod.GetParameters(), (arg, param) => new { arg, param })
				.Select(t => TryChangeType(t.arg, t.param, culture, out var newValue) ? newValue : null)
				.ToArray();
			state = new State { args = args_, method = fittedMethod };
			return fittedMethod;
		}
		catch
		{
			state = null;
			return null!;
		}
	}

変数 fittedMethod にはメソッド配列 match の中から選択された1つのメソッドが代入される。メソッド選択は、渡された引数配列 args(expr[] 型)とそのメソッドが求める引数配列(ParameterInfo[] 型)のすべてが、Binder.TryChangeType() を満たすことが条件となっている。
変数 args には ref 修飾子が付いており、最終的なメソッド呼び出しに合わせてこのメソッド内で変換しておく必要がある。
なお変数 state にも何やらデータを保存をしているが、これについては後述する。

引数の比較をする Binder.TryChangeType() は、Binder の別メソッド Binder.ChangeType() をラップしたものになっていて、型変換の成否を戻り値に返す仕様となっている。

	private bool TryChangeType(object? value, ParameterInfo param, CultureInfo? culture, out object? newValue)
	{
		var type = param.ParameterType;
		try
		{
			newValue = ChangeType(value, type, culture);
			return newValue != null;
		}
		catch
		{
			newValue = null;
			return false;
		}
	}

個々の引数を目的の型(つまりメソッドが求める引数型)へと変換する処理は、Binder.ChangeType() が提供することになっている。

public abstract object ChangeType (object value, Type type, System.Globalization.CultureInfo? culture);

自作したコードでは↓以下のようなものになった。

	public override object ChangeType(object? value, System.Type myChangeType, CultureInfo? culture) => ChangeType_(value, myChangeType, culture);

	private object ChangeType_(object? value, System.Type type, CultureInfo? culture)
	{
		if (value is null) return null!;
		if (value is cell c)
		{
			if (!type.IsArray) throw new Exception("incompatible type");
			type = type.GetElementType() ?? throw new Exception("incompatible type");
			var value_ = Array.CreateInstance((System.Type)type, c.Count());
			Array.Copy(c.Select((expr arg) => ChangeType_(arg, type, culture)).ToArray(), value_, c.Count());
			return value_;
		}
		return Convert.ChangeType(value, type);
	}

if 文のブロックは再帰処理のためであり、引数が cell 型で かつ引数型が配列であったケースをさばくための記述となっているが、この Binder.ChangeType_() の実質的な処理は最後の Convert.ChangeType() を呼び出す箇所になる。

変換元のオブジェクトに IConvertible インターフェースを導入しておくことで、このように Convert.ChangeType() を介して型変換処理を呼び出すことができるようになる。
stringv 型を String 型に変換できるかどうか、numberv 型を Int16 型に変換できるかどうか、などのチェックはそれぞれの変換元の型が IConvertible インターフェースをどのように実装するかで制御ができる。

IConvertible の導入

IConvertible についての詳しい説明は以下のページを参照してほしい。

スクリプトエンジンがサポートした型変換を↓以下に列挙した。

stringv 型を String 型へ
stringv 型を Char 型へ(※)
boolv 型を Boolean 型へ
numberv 型を Char 型へ(※)
numberv 型を SByte 型へ(※)
numberv 型を Byte 型へ(※)
numberv 型を Int16 型へ(※)
numberv 型を UInt16 型へ(※)
numberv 型を Int32 型へ(※)
numberv 型を UInt32 型へ(※)
numberv 型を Int64 型へ
numberv 型を UInt64 型へ(※)
numberv 型を Float 型へ
numberv 型を Double 型へ
floatv 型を Float 型へ(※)
floatv 型を Double 型へ
※マークは条件付き縮小変換。保持している値がオーバーフローするときは変換が失敗する

一例として stringv 型の IConvertible 実装を↓以下に抜粋しておこう。
※ 行頭「-」箇所は行削除。行頭「+」箇所は行追加。

-	public class atomv<T> : expr where T : notnull
+	public class atomv<T> : expr, IConvertible where T : notnull
	{
		:
+		public virtual TypeCode GetTypeCode() => throw new NotImplementedException();
+		public virtual object ToType(System.Type conversionType, IFormatProvider? provider) => Convert.ChangeType(_val, conversionType);
+		public virtual bool ToBoolean(IFormatProvider? provider) => throw new NotImplementedException();
+		public virtual char ToChar(IFormatProvider? provider) => throw new NotImplementedException();
+		public virtual sbyte ToSByte(IFormatProvider? provider) => throw new NotImplementedException();
+		public virtual byte ToByte(IFormatProvider? provider) => throw new NotImplementedException();
+		public virtual short ToInt16(IFormatProvider? provider) => throw new NotImplementedException();
+		public virtual ushort ToUInt16(IFormatProvider? provider) => throw new NotImplementedException();
		:
	}

	public class stringv : objectv, IEquatable<stringv>, IComparable<stringv>
	{
		:
+		public override char ToChar(IFormatProvider? provider) => Convert.ToChar(_val);
+		public override string ToString(IFormatProvider? provider) => _val;
		:
	}

stringv 型は String 型と Char 型への変換をサポートしたいので、ToString() と ToChar() がオーバーライドされている。先に述べたように、その型にとって変換可能な型についてはオーバーライドが提供されていて、変換不可能な型については例外がスローされるようになっている。

ここまで実装が進むと、ようやく自作した Binder が動作するようになる。

	public static expr invokeMemberFunc(objectv v0, expr args)
	{
		:
		var argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
-		var r = type.InvokeMember(member, flags, null, v0._val, argList);
+		var r = type.InvokeMember(member, flags, new binder(), v0._val, argList);
		return Type.binder.ToExpr(r);
	}
パースした整数値を List<Int16> 型の変数 l へと追加していく様子

これでユーザーは Int32 以外の整数型引数を持つメソッドを呼び出すことができるようになった。縮小変換を介したメソッド呼び出しが実現したことになる。

ちなみにジェネリックな型の名前を指定するときは、Type.AssemblyQualifiedName という命名ルールを使わなければならない。バッククォート ` やブラケット [] などの記号にはそれぞれに意味があり、詳しくは以下のページに情報が載っている。

ref/out/in 修飾子を含むメソッドを呼び出せない問題

メソッドの引数には ref 修飾子・out 修飾子・in 修飾子が付いていることがあるが、これまでのスクリプトエンジンはこれらを正しく扱うことができなかった。
例えば Int32.TryParse() では、パースが成功したあとその結果を呼び出し元へと返すために、out 修飾子を伴う引数が宣言に用いられている。これらの修飾子が付いている引数を、リフレクション機能を介してどのように処理すればよいかを検討していこう。

Int32.TryParse() のプロトタイプは↓以下のようになっている。

public static bool TryParse (string? s, out int result);

このメソッドを Type.InvokeMember() で呼び出そうとした場合に、Binder.BindToMethod_() の中でどんなことが観測できるか。それを紹介していこう。

1) 引数の型が参照渡しであることを示している

引数の型には "String, Int32&" が要求された。
ここで "Int32&" は Int32 型の参照渡しを示している。また参照渡しの型に対して Type.GetElementType() を呼び出すことで、元の型(ここでは Int32 型)を得られることも分かった。

2) 引数の属性が修飾子の種類を示している

引数に関連する属性値などが ParameterInfo クラスに格納されており、2番目の引数は IsByRef プロパティーが trueIsOut プロパティーが true、IsIn プロパティーが false であった。

この属性情報は Type 型のインスタンスからは得ることができず、MethodBase.GetParameters() から得る必要があった。

3) 引数リストに準備すべき内容

.NET の型で構成された引数リスト args を準備する際、out 修飾子付きの引数に対応する箇所(Int32.TryParse() の場合は args[1])についてはどんな値であってもメソッド呼び出しに成功していた。null でも問題無かった。

4) メソッド呼び出し後の引数リストの内容

メソッドの呼び出しを終えた後の引数リスト args を確認してみると、out 修飾子に対応する箇所にはパース後の値が埋め込まれていた。

これらの観測によって、スクリプトエンジンが修飾子付きの引数を判別し、引数リストをどのように準備するかを規定することができた。

引数のマッピング問題(ref/out/in 修飾子の対処)

out 修飾子
・条件: ParameterInfo の IsByRef が true、IsOut が true
・引数リストの準備: 不要(null でよい)
・メソッド呼び出し後の処理: 値をスクリプトエンジンの型に変換して反映

in 修飾子
・条件: ParameterInfo の IsByRef が true、IsIn が true
・引数リストの準備: 値を .NET の型に変換してコピーする
・メソッド呼び出し後の処理: 不要

ref 修飾子
・条件: ParameterInfo の IsByRef が true、IsOut と IsIn が false
・引数リストの準備: 値を .NET の型に変換してコピーする
・メソッド呼び出し後の処理: 値をスクリプトエンジンの型に変換して反映

このあたりは、Type.DefaultBinder のリファレンスコードも少し参考にした。

「メソッド呼び出し後の処理」と書いた部分は、Binder.ReorderArgumentArray() をオーバーライドすることで実現する。

public abstract void ReorderArgumentArray (ref object?[] args, object state);

自作したコードは↓以下のようなものになった。

	public override void ReorderArgumentArray(ref object?[] args, object state) => ReorderArgumentArray_(ref args, state);

	public void ReorderArgumentArray_(ref object?[] args, object state)
	{
		args = args
			.Zip(((State)state).args, ((State)state).method.GetParameters())
			.Select(t =>
			{
				var (src, dst, param) = t;
				if (dst is symbolv s && param.ParameterType.IsByRef && !param.IsIn)
				{
					// param is ref/out
					s.assign(binder.ToExpr(src));
				}
				return dst;
			})
			.ToArray();
	}

ここに出てくる引数 state は Binder.BindToMethod_() に登場したものと同じもので、Binder.BindToMethod_() が引数リスト args を .NET の型へ変換した前の状態を表している。

また Binder.TryChangeType() と Binder.ChangeType_() にも改修の必要があった。
※ 行頭「+」箇所は行追加。

	private bool TryChangeType(object? value, ParameterInfo param, CultureInfo? culture, out object? newValue)
	{
		var type = param.ParameterType;
+		if (value is symbolv s && param.ParameterType.IsByRef)
+		{
+			if (param.IsOut)
+			{
+				// param is out
+				newValue = null;
+				return true;
+			}
+
+			// param is ref/in
+			type = type.GetElementType() ?? throw new Exception("incompatible type");
+			value = s.eval();
+		}
		try
		{
			newValue = ChangeType(value, type, culture);
			return newValue != null;
		}
		catch
		{
			newValue = null;
			return false;
		}
	}

	private object ChangeType_(object? value, System.Type type, CultureInfo? culture)
	{
		if (value is null) return null!;
+		if (value is symbolv s)
+		{
+			if (!type.IsByRef) throw new Exception("incompatible type");
+			type = type.GetElementType() ?? throw new Exception("incompatible type");
+			value = s.eval() ?? throw new Exception("incompatible type");
+			return ChangeType_(value, type, culture);
+		}
		if (value is cell c)
		{
			if (!type.IsArray) throw new Exception("incompatible type");
			type = type.GetElementType() ?? throw new Exception("incompatible type");
			var value_ = Array.CreateInstance((System.Type)type, c.Count());
			Array.Copy(c.Select((expr arg) => ChangeType_(arg, type, culture)).ToArray(), value_, c.Count());
			return value_;
		}
		return Convert.ChangeType(value, type);
	}

ここまでで ref/out/in 修飾子のサポートが実現したはずだ。
ビルド&実行してみたのが↓以下の図だ。

引数に out 修飾子を持つメソッドの呼び出しに成功!

Enum 値を期待するメソッドを呼び出せない問題

.NET ライブラリのメソッドの呼び出しについては、別の問題点もある。

Type.InvokeMember() で呼び出したいメソッドが引数として Enum 値を求めているところへ Int32 などの整数値を渡した場合、Binder はそのメソッドにマッチしていないとみなしてしまう。そのメソッドの呼び出しは MissingMethodException で失敗してしまう。

ということで、整数値を特定の Enum 型に変換するシンボル cast を追加定義して、その実処理部分を Core.castObject() として実装した。

public static expr castObject(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out typev? v0, out numberv? v1);
	if (v0 is null || v1 is null || argLeft is not null) throw new Exception("wrong number of args");
	System.Type type = v0._val;
	if (type.ContainsGenericParameters) throw new Exception($"generic type name specified [{v0._val}]");
	var r = Enum.ToObject(type, v1._val);
	return Type.binder.ToExpr(r);
}
1度目の Split では空文字を含む5つ、2度目の Split では4つの文字列を得られた

ロードされていない .NET アセンブリを利用できない問題

.NET ライブラリへのアクセスについても、課題が残っていた。

String クラスや DateTime クラスなど System 名前空間のものであれば、ユーザーは問題なく利用できるが、それ以外は利用できなかったのだ。

これを補うために、.NET アセンブリの動的ローディングに対応するためのシンボル import を定義して、その実処理部分を Core.importType() として実装した。

public static expr importType(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out symbolv? v0, out symbolv? v1);
	if (v0 is null || v1 is null || argLeft is not null) throw new Exception("wrong number of args");
	Assembly asm = Assembly.Load(v0._val) ?? throw new Exception($"wrong assembly name [{v0._val}]");
	System.Type type = asm.GetType($"{v0._val}.{v1._val}") ?? throw new Exception($"wrong type name [{v1._val}]");
	return new typev(type);
}

例えばデフォルトでは System.Net.Http アセンブリはロードされていないので、ユーザーはこのアセンブリに含まれる HttpClient クラスを利用することができなかった。
これが、今回導入したシンボル import を使うと↓以下のように回避できる。

とある Web サイトを wget したところ

この入力例は↓以下のような C# のコードを模したものになっている。

using System.Net.Http;
var c = new HttpClient();
var t = await c.GetAsync("https://~~~.com/");
Console.WriteLine(t.IsCompleted);
Console.WriteLine(t.Result);

小さな発見

今回は C# コーディングにおいていろいろな学びがあったので、いくつかまとめてみた。

修飾子付き引数を匿名関数からアクセスできない

まず Binder.BindToMethod_() を実装する際に引っ掛かってしまった制約について紹介しよう。

LINQ 構文などで匿名関数を記述するとき、匿名関数の中では ref/out/in 修飾子の付いた変数を読み書きできず、コンパイルエラーになってしまうのだ。
↓以下のコードを実装するときに Where ブロックから引数 args を参照したかったのだが、そういう制約があるので、やむなく変数 args_ に内容をコピーして代わりにそれを参照するようにした。

	private MethodBase BindToMethod_(BindingFlags bindingAttr, MethodBase[] match, ref object?[] args, ParameterModifier[]? modifiers, CultureInfo? culture, string[]? names, out object? state)
	{
		object?[] args_ = (object?[])args.Clone();
		if (match == null) throw new ArgumentNullException();
		try
		{
			var fittedMethod = match
				.Where((MethodBase methodBase) =>
				{
					ParameterInfo[] parameters = methodBase.GetParameters();
					return (args_.Length == parameters.Length) &&
						args_
							.Zip(parameters, (arg, param) => new { arg, param })
							.All(t => TryChangeType(t.arg, t.param, culture, out var _));
				})
				.First();
			:

こんなコードをコードレビューで見つけたなら、意図が分からないし「冗長なコード」として思わず指摘してしまうだろう。でもこの Clone() 処理は文法上不可欠なのだ。

なぜそのような制約が規定されているのかとても不思議なので、無意識に同じ過ちを繰り返してしまいそうだ。しっかり覚えておこう。

同じ Object[] 型でもふるまいは同じではない

コンパイルは成功するが、実行時にエラーとなる、という厄介なバグに時間を取られたので紹介しておこう。Type.InvokeMember() の引数にメソッドのパラメーターを渡すときの話だ。

InvokeMember(String name, BindingFlags invokeAttr, Binder binder, Object target, Object[] args, ParameterModifier[] modifiers, CultureInfo culture, String[] namedParameters)

引数 args は Object[] 型となっていて、スクリプトエンジンの中では↓以下のコードのように argList(Object[] 型)を渡している。

public static expr invokeMemberFunc(objectv v0, expr args)
{
	:
	var argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
	var r = type.InvokeMember(member, flags, new binder(), v0._val, argList);
	return Type.binder.ToExpr(r);
}

argList の生成に使っている cell.ToArgs() メソッドでは、リスト構造になっている cell 型のインスタンスから一つずつ expr 要素を取り出して配列を構築している。cell 型は IEnumerable<expr> インターフェースをサポートするようにしたので、cell.ToArgs() は GetEnumerator() を暗黙的に使って↓以下のように書ける(完成前はこのように実装されていた)。

	public class cell : expr, IEnumerable<expr>
	{
		:
		public object?[] ToArgs() => this.Select((var arg) => arg).ToArray();
		:
	}

しかしこのコードは文法的には間違っていないものの、実行してみると後段の Type.InvokeMember() の中で ArrayTypeMismatchException 例外を引き起こしてしまうのだ。

試行錯誤の結果、正しく動作するのは↓以下のコードであることが分かった。

	public class cell : expr, IEnumerable<expr>
	{
		:
		public object?[] ToArgs() => this.Select((object arg) => arg).ToArray();
		:
	}

違いが分かるだろうか。
Select ブロックの引数型を明示して object にしたのだ。しかしこれは次の疑問を招く。var と書いたのと何が違うというのか。

実は違ったのだ。
var と書いたとき、ToArray() には expr[] 型が渡される。それを Object[] 型にアップキャストして返しているということになる。
それに対して object と書いたときは、ToArray() には Object[] 型が渡される。
この違いが影響して、できあがりの argList は異なる挙動となり、特に前者は配列を上書きするときに expr 以外の型を受け付けなくなってしまう。Binder.BindToMethod() の中では引数リストを編集して .NET の型に上書きするので、この制約が問題になってしまうのだ。

↓以下のサンプルコードは String[] 型の args を Object[] 型にアップキャストする方法と、それを再度 String[] 型にダウンキャストする方法をいくつか示したものだ。

public static void Main (string[] _) {
	string[] args = new string[] { "aaa", "bbb", "ccc" };
	object[] args0 = (object[])args;
	Console.WriteLine($"0: {args0} {string.Join(',', args0)}");
	//args0[0] = 1;    // ng

	object[] args1 = args
		.Select((object arg) => arg)
		.ToArray();
	Console.WriteLine($"1: {args1} {string.Join(',', args1)}");
	//args1[0] = 1;    // ok

	object[] args2 = args1
		.Select((object arg) => (string)arg)
		.ToArray();
	Console.WriteLine($"2: {args2} {string.Join(',', args2)}");
	//args2[0] = 1;    // ng

	var type = Type.GetType("System.String");
	object[] args3 = args1
		.Select((object arg) => Convert.ChangeType(arg, type))
		.ToArray();
	Console.WriteLine($"3: {args3} {string.Join(',', args3)}");
	//args3[0] = 1;    // ok

	object[] args4 = args1
		.Cast<string>()
		.ToArray();
	Console.WriteLine($"4: {args4} {string.Join(',', args4)}");
	//args4[0] = 1;    // ng

	object[] args5 = (object[])Array.CreateInstance(type, args.Length);
	Array.Copy(args1, args5, args5.Length);
	Console.WriteLine($"5: {args5} {string.Join(',', args5)}");
	//args5[0] = 1;    // ng
}

// Output:
// 0: System.String[] aaa,bbb,ccc
// 1: System.Object[] aaa,bbb,ccc
// 2: System.String[] aaa,bbb,ccc
// 3: System.Object[] aaa,bbb,ccc
// 4: System.String[] aaa,bbb,ccc
// 5: System.String[] aaa,bbb,ccc

先の説明のように、どんな Object 型でも書き込めるように配列 args をアップキャストしたいなら、args1 のコードのように Select ブロックをはさむ必要があるのだろう。これは配列の長さに応じた処理が掛かる。
args0 のコードのようにキャストだけで済むとラクで良いのだが、これでは意図した挙動にならないようだ(出力を見ると String[] 型のままだ)。

逆に、ある Object[] 型の配列 args1 の要素がすべて String 型であると分かっているときに、これを String[] 型に変換するにはどうしたら良いだろうか。
これは args2 のコードで実現している。
この場合も args1 のコードと同様に Select ブロックが必要だ。

args2 のコードでは変換先の型情報(= String 型)はコンパイル時に指定されており、それに依存するコードになっているが、今回製作しているスクリプトエンジンではユーザーが入力したスクリプトによって実行時に変換先の型が与えられる(Type 型の変数で与えられる)ものと考える必要がある。

これを実現するのに、args3 のコードのように Select で変換するだけで済むと良いが、これでは意図した挙動にならないようだ(出力を見ると Object[] 型のままだ)。
args4 のコードはとても惜しいが、Cast ブロックに Type 引数を指定できないので、「ランタイム時に型情報を与える」という部分が実現されていない。
試行錯誤の結果、args5 のコードのように、Array.CreateInstance() と Array.Copy() を組み合わせる必要があった。

Zip が生成するオブジェクト

LINQ 構文の Zip は2つの配列を見比べたりするのに役に立つ。
Binder.BindToMethod_() には↓以下のようなコードが登場する。

args = args_
	.Zip(fittedMethod.GetParameters(), (arg, param) => new { arg, param })
	.Select(t => TryChangeType(t.arg, t.param, culture, out var newValue) ? newValue : null)
	.ToArray();

Zip の2つめの引数 resultSelector には、2つの配列から得たそれぞれの要素を束ねる方法を指定することができる。
new のあとに型情報を指定していないが、これは初期化子に与えた引数 arg と param から型推論してくれる匿名型という機構が使われているようだ。

Binder.ReorderArgumentArray_() では3つの配列を見比べて処理した。
↓以下のコードのように Zip には配列を2つ与えることもできる。

	public void ReorderArgumentArray_(ref object?[] args, object state)
	{
		args = args
			.Zip(((State)state).args, ((State)state).method.GetParameters())
			.Select(t =>
			{
				var (src, dst, param) = t;
				if (dst is symbolv s && param.ParameterType.IsByRef && !param.IsIn)
				{
					// param is ref/out
					s.assign(binder.ToExpr(src));
				}
				return dst;
			})
			.ToArray();
	}

ただしこの場合は resultSelector を指定することはできない。
代わりに Select ブロックの中で Deconstruct するようにしている。

これで完成?

Program.cs は 82 行、Type.cs は 544 行、Core.cs は 437 行。コード量は全体で 1063 行になり、前回から 213 行も増加した。とうとう 1000 行を超えてしまったので、これで区切りを付けて ひとまず完成ということにしようと思っている。

今後また追加したい機能を思いついたり、試したい C# 言語の機能などを見つけたら、挑戦がてら記事にしていこうと思う。


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