見出し画像

ゼロからはじめるスクリプト言語製作: オブジェクト操作とリフレクション(20日目)

前回は .NET 言語のリフレクション機能を利用して、String 型のメソッドやプロパティーへのアクセスを実現した。またスクリプト言語に objectv 型を導入して、.NET オブジェクトを保持できるようにした。

今回はメソッドやプロパティーへのアクセスを一般化させて、String 型だけでなくユーザーがプログラミングしたすべてのオブジェクト型の操作をリフレクションで利用できるよう、改良を加えていこう。

新たなインスタンスの生成

前回の実装で Type.InvokeMember() からの戻り値を格納するために、新たに objectv 型を導入した。メソッドやプロパティーを呼び出す代わりに、オブジェクトのコンストラクターを呼び出せれば、新たなインスタンスを生成しそれを保持できるはずだ。
ここでシンボル new を↓以下のように定義しよう。

new <typev> [<expr> …]: 指定されたオブジェクトを生成する

2つめ以降の要素は、コンストラクター引数として扱う。
最初の要素には typev という新たな型を導入することにした。typev は Type 型の値を格納することができるものとする。
typev 型の要素を生成するためのシンボルも、合わせて定義しておこう。

type <symbolv> [<typev> …]: 指定された型情報を得る

2つめ以降の要素は、ジェネリックの型パラメーターとして参照するためのもの、ということにしよう。

ではこれらの実装が具体的にどうなったのか見ていこう。まずシンボル new の実装 Core.newObject() は↓以下のようになった。

public static expr newObject(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out typev? v0);
	if (v0 is 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 argList = argLeft is null ? null : list_(argLeft).astype<cell>()?.ToArgs();
	var r = Activator.CreateInstance(type, argList);
	return Type.binder.ToExpr(r);
}

リフレクション機能を介してインスタンスを生成するには Activator.CreateInstance() を使う。関数のプロトタイプは↓以下のとおりだ。

public static object? CreateInstance (Type type, params object?[]? args)

Core.newObject() の実装は、これまでに見てきたリフレクション実装をかなり流用していることが分かる。
途中で ContainsGenericParameters というプロパティーを確認しているのは、最初の要素に指定された型がジェネリックだった場合にインスタンスの生成が失敗してしまうのを防ぐためだ。

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

public static expr type(expr args)
{
	cell? argLeft = evalArgs(args.astype<cell>(), out symbolv? v0);
	if (v0 is null) throw new Exception("wrong number of args");
	System.Type type = System.Type.GetType(v0._val) ?? throw new Exception($"wrong type name [{v0._val}]");
	if (type.ContainsGenericParameters)
	{
		if (argLeft is null) throw new Exception($"type name(s) needed for [{v0._val}]");
		var typeParam = new List<System.Type>();
		argLeft = evalArgs(argLeft, out typev? v);
		while (v is not null)
		{
			typeParam.Add(v._val);
			argLeft = evalArgs(argLeft, out v);
		}
		type = type.MakeGenericType(typeParam.ToArray());
	}
	else
	{
		if (argLeft is not null) throw new Exception("wrong number of args");
	}
	return new typev(type);
}

ここでも、最初の要素に指定された型がジェネリックかどうかで、少し処理が追加になる部分がある。ジェネリックの場合は追加の要素を typev 型として評価していき、それらを型パラメーターとして Type.MakeGenericType() を呼び出すことで、インスタンス化が可能な型(非ジェネリックな型)を得ている。

これらをシンボルとひも付けて、実行してみたのが以下だ。

new symbolv("type").assign(new functionv(Core.type));
new symbolv("new").assign(new functionv(Core.newObject));
DateTime 型オブジェクトの生成に成功

オブジェクトのメソッドにアクセスする

前回実装した Core.invokeMemberFunc() は、最初の要素に stringv 型を指定する仕様だった。これを objectv 型に置き換えよう。
※ 行頭「-」箇所は行削除。行頭「+」箇所は行追加。

	public static expr invokeMemberFunc(expr args)
	{
-		cell? argLeft = evalArgs(args.astype<cell>(), out stringv? v0, out symbolv? v1);
+		cell? argLeft = evalArgs(args.astype<cell>(), out objectv? 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);
	}

コードを上記のように修正した場合、最初の要素に objectv 型を与えて、メソッドの呼び出しが可能になる。一方で stringv 型を指定するとエラーになってしまい、String クラスのメソッドが一切使えなくなってしまう。

この問題を回避するために、どうしたら良いか。
ということで型クラスの継承関係を見直して、stringv 型を objectv 型の派生クラスとして定義しなおすことにした。

型クラスの依存関係の見直し

見直した後の型クラスの継承関係は↓以下のようになった。

設計変更後のクラス継承図

※ 行頭「-」箇所は行削除。行頭「+」箇所は行追加。

-	public class atomv<T> : expr
+	public class atomv<T> : expr where T : notnull
	{
		public atomv(T val) => _val = val;
-		public override string ToString() => _val?.ToString() ?? "";
+		public override string ToString() => _val.ToString() ?? "";
		public override expr eval() => this;
-		public readonly T _val;
+		public virtual T _val { get; }
	}
	:

-	public class stringv : atomv<string>, IEquatable<stringv>, IComparable<stringv>
+	public class stringv : objectv, IEquatable<stringv>, IComparable<stringv>
	{
		:

+		public override string _val
+		{
+			get => (string)base._val;
+		}
	}

-	public class boolv : atomv<bool>, IEquatable<boolv>, IComparable<boolv>
+	public class boolv : objectv, IEquatable<boolv>, IComparable<boolv>
	{
		:

+		public new bool _val
+		{
+			get => (bool)base._val;
+		}
	}

-	public class numberv : atomv<long>, IEquatable<numberv>, IComparable<numberv>
+	public class numberv : objectv, IEquatable<numberv>, IComparable<numberv>
	{
		:

+		public new long _val
+		{
+			get => (long)base._val;
+		}
	}

-	public class floatv : atomv<double>, IEquatable<floatv>, IComparable<floatv>
+	public class floatv : objectv, IEquatable<floatv>, IComparable<floatv>
	{
		:

+		public new double _val
+		{
+			get => (double)base._val;
+		}
	}

stringv 型だけでなく、ついでに boolv 型、numberv 型、floatv 型についても同様の変更を行っている。結果、何かの値を保持しているものは objectv 型を継承することになった。

その場合、.NET オブジェクトを保持しているメンバー変数 objectv._val は Object 型として宣言され(atomv<> の定義により)、stringv._val も Object 型のメンバー変数ということになってしまう副作用があるため、既存コードとの折り合いが悪い。

それをさらに回避するため、objectv._val をプロパティー(自動プロパティー)として宣言しなおす必要があった。

スクリプト記法の見直し

ついでにもう1点、↓以下の部分についてスクリプト記法の見直しを行っておこう。

. <objectv> <symbolv> [<expr> …]: 指定されたオブジェクトのメソッドを実行する
: <typev> <symbolv> [<expr> …]: 指定されたクラスのメソッドを実行する

使用頻度も踏まえ、より簡潔に記述できることを重視するのであれば、objectv 型と typev 型は「引数を伴って評価可能な型である」として扱う方が良いという結論に至った。

変更後の仕様は↓以下のようになる。

<objectv> <symbolv> [<expr> …]: 指定されたオブジェクトのメソッドを実行する
<typev> <symbolv> [<expr> …]: 指定されたクラスのメソッドを実行する

実装面では、functionv 型・enumerationv 型のみで定義されていた eval(expr) メソッドを基底クラス側で仮想メソッドとして定義しなおすようにした。
※ 行頭「-」箇所は行削除。行頭「+」箇所は行追加。

	public abstract class expr : IEquatable<expr>, IComparable<expr>, IEnumerable<expr>
	{
		:
+		public virtual expr eval(expr args) => throw new Exception("eval against non-function");
	}
	:

	public class objectv : atomv<object>, IEquatable<objectv>
	{
		:
+		public delegate expr evaluator(objectv v0, expr args);
+		public static void Initialize(evaluator func) => _invoker = func;
+		public override expr eval(expr args) => _invoker is null ? throw new Exception("objectv invoker not initialized") : _invoker(this, args);

+		private static evaluator? _invoker = null;
	}
	:

	public class functionv : atomv<evaluator>
	{
		public functionv(evaluator val) : base(val)
		{
		}

-		public expr eval(expr args) => _val(args);
+		public override expr eval(expr args) => _val(args);
	}
	:

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

		public override expr eval()
		{
			expr arg0 = _car.eval();
-			enumerationv? enum_ = arg0.astype<enumerationv>();
-			if (enum_ is not null)
-			{
-				return enum_.eval(_cdr);
-			}
-			functionv? func = arg0.astype<functionv>();
-			if (func is not null)
-			{
-				return func.eval(_cdr);
-			}
-			throw new Exception("eval against non-function");
+			return arg0.eval(_cdr);
		}
	}

もろもろ実装を修正し、コンパイル、実行したのが↓以下の図だ。

仕様変更後のメソッド呼び出し
String.Format や Int32.Parse や Int64.ToString など、いろいろな呼び出しにも対応!
Dictionary<String,Int32> を生成してみた例

今日の実装はここまで、おつかれさま。
Program.cs は 82 行、Type.cs は 388 行、Core.cs は 424 行。コード量は 74 行の増加。

小さな発見

これまでのところ、クラスのデータを宣言する手段としてフィールドを多用してきた。筆者は C++ に慣れていたため自然とフィールドを使ってしまっていたが、C# を習得して使っていくならばプロパティーの方を積極的に使っていくべきだろう。

ということもあって、プロパティーとフィールドの違いについておさらいしてみた。

1) アクセサーがあるか無いか

アクセサーがあるのがプロパティー、無いのがフィールド。
宣言の書き方が違ってくる。

public class MyClass<T>
{
	:
	public T _value1;
	public T _value2 { get; set; }
}

_value1 はフィールドで、_value2 はプロパティーとなる。この記述の仕方だと、_value2 には↓以下のアクセサーが擬似的に定義される。

public class MyClass<T>
{
	:
	private T __value2;
	public T get__value2()
	{
		return __value2;
	}
	public void set__value2(T value)
	{
		__value2 = value;
	}
}

2) 読み出し専用で宣言するときに readonly 修飾子を使えるかどうか

setter を省略して宣言すべきなのがプロパティー、readonly 修飾子を使うべきなのがフィールド。

public class MyClass<T>
{
	:
	public readonly T _value1;
	public T _value2 { get; }
}

この場合 _value1 も _value2 も読み出し専用となるため、値を変更しようとするコードはエラーとなる(ただしコンストラクターを除く)。

なおフィールドにおいては readonly の代わりに const を使う選択肢もある。こうするとコンパイル時に値が決定され、コード全体のどこから参照しても値は一意になる。readonly にした場合は、コンストラクターでフィールドを上書きすることが可能なため、インスタンスによって値を別物にすることができる。

またプロパティーにおいては、以下のように getter と setter でアクセス修飾子を非対称にすることもできる。

public class MyClass<T>
{
	:
	public T _value2 { get; private set; }
}

クラス外のコードから見たときだけプロパティーを読み取り専用として扱いたいときは、このように記述できる。

3) 派生クラスでふるまいをオーバーライドできるかどうか

ふるまいをオーバーライドできるのがプロパティー、できないのがフィールド。
宣言時に virtual 修飾子・override 修飾子を使えるかどうかが異なる。

public class MyClass<T>
{
	:
	public virtual T _value2 { get; set; }
}

public class ExtendedClass<T> : MyClass<T>
{
	:
	public override T _value2
	{
		get => SomethingToRead(base._value2);
		set => base._value2 = SomethingToWrite(value);
	}
}

プロパティーというものが実際は private なフィールドとそのアクセサーの組み合わせであることを理解していれば、そのアクセサーに virtual や override を指定できるという点は難解ではないと思う。

ただし本日の実装例のように、アクセサーの戻り値の型を継承元から変更するような宣言を行おうとする場合、new 修飾子を使って継承元の宣言を隠す必要が生じる場合がある。

すこし今回の実装箇所から説明しよう。objectv 型に宣言されているプロパティー _val は Object 型ということになっている。

	public class atomv<T> : expr where T : notnull
	{
		:
		public virtual T _val { get; }
	}

	public class objectv : atomv<object>, IEquatable<objectv>
	{
		:
	}

この objectv 型を継承して stringv 型を宣言し、プロパティー _val を String 型として再度公開したいとすると、↓以下のコードで実現できる。

	public class stringv : objectv, IEquatable<stringv>, IComparable<stringv>
	{
		:
		public override string _val
		{
			get => (string)base._val;
		}
	}

同じように objectv 型を継承して boolv 型を宣言し、プロパティー _val を Boolean 型として再度公開したいとすると、なんとこの場合には↓以下のコードで実現できない

	public class boolv : objectv, IEquatable<boolv>, IComparable<boolv>
	{
		:
		public override bool _val
		{
			get => (bool)base._val;
		}
	}

// ERROR: 'boolv._val': type must be 'object' to match overridden member 'atomv<object>._val'

boolv 型にあるプロパティー名を同名の _val にしたければ、new 修飾子を追加して atomv<object>._val とは別ものにする必要があった。
「なぜ String 型への override は OK で、Boolean 型や Int64 型では NG なのか」「参照型と値型とで何が違うのか」、その理由についてははっきりしていない。そもそもで言うと、引数が一致して戻り値の型だけが異なるようなメソッドやプロパティーは、一般的ではないし、避けるべき実装なので、心得ておくことにしよう。

ともあれ C# 言語におけるプロパティーは、フィールドと比較して機能や自由度の面で使い勝手の良いものになっている。今後プログラムを組むときは、プロパティーの方を活用していくべきだろう。


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