C#でもSigilでデリゲートをダイナミックに生成したい
はじめに
飽きもせずに.NET黒魔術シリーズです。
SigilといってもEPUBの方のSigilではないです。そっちの情報を求めていた人はまわれ右してお帰りください。
やる気が起きなかった時にネットサーフィンしていたところ、こんな記事を見つけました。
このライブラリはILGenerator
をラップし、ILの誤りを可能な限り早く検出しわかりやすいエラーメッセージを提供してくれます。
というよりもILGenerator
は実行時でないと誤りがあるかわからないのと、誤りがあります程度のメッセージしか提供してくれずかなりつらぽよポイントは高いです。
今回はSigilを用いて動的にToString
をするデリゲートを作成してみましょう。好きですね。ToString
を動的に生成するの。
どうでもいいのですが、作者の笑顔がいいですね。
お手本
とりあえず、ベースとなるILをコンパイラに生成してもらいましょう。
class Target { public int Id { set; get; } public virtual string Name { set; get; } public DateTime Hoge { set; get; } }
こんなクラスがあって、
class Program { static void Main(string[] args) { var target = new Target() { Id = 1, Name = "あああああ", Hoge = DateTime.Now }; Console.WriteLine(ToString(target)); } static string ToString(object value) { var target = (Target)value; var builder = new StringBuilder(); builder .Append(nameof(Target) + ":{") .Append(nameof(Target.Id) + "=") .Append(target.Id) .Append(",") .Append(nameof(Target.Name) + "=") .Append(target.Name) .Append(",") .Append(nameof(Target.Hoge) + "=") .Append(target.Hoge) .Append("}"); return builder.ToString(); } }
こんな感じにToString
するとしましょう。
アセンブリをビルドし、ildasmで逆アセンブルすると以下のコードを生成すればいいことがわかります。
.maxstack 2 .locals init ([0] class Samplejunk.Target target, [1] class [mscorlib]System.Text.StringBuilder builder, [2] string V_2) IL_0000: nop IL_0001: ldarg.0 IL_0002: castclass Samplejunk.Target IL_0007: stloc.0 IL_0008: newobj instance void [mscorlib]System.Text.StringBuilder::.ctor() IL_000d: stloc.1 IL_000e: ldloc.1 IL_000f: ldstr "Target:{" IL_0014: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_0019: ldstr "Id=" IL_001e: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_0023: ldloc.0 IL_0024: callvirt instance int32 Samplejunk.Target::get_Id() IL_0029: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(int32) IL_002e: ldstr "," IL_0033: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_0038: ldstr "Name=" IL_003d: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_0042: ldloc.0 IL_0043: callvirt instance string Samplejunk.Target::get_Name() IL_0048: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_004d: ldstr "," IL_0052: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_0057: ldstr "Hoge=" IL_005c: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_0061: ldloc.0 IL_0062: callvirt instance valuetype [mscorlib]System.DateTime Samplejunk.Target::get_Hoge() IL_0067: box [mscorlib]System.DateTime IL_006c: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(object) IL_0071: ldstr "}" IL_0076: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string) IL_007b: pop IL_007c: ldloc.1 IL_007d: callvirt instance string [mscorlib]System.Object::ToString() IL_0082: stloc.2 IL_0083: br.s IL_0085 IL_0085: ldloc.2 IL_0086: ret
共通中間言語
C#とILのコードを比較しながら見てみましょう。
共通中間言語(Common Intermediate Language、略してCIL)はスタックベースの言語であり、オペランドをスタックへプッシュし命令を実行し計算結果をスタックから受け取ります。
ここではスタック遷移を以下の書式で表現します。
- スタックは左から右に伸びていく。つまり古い値が左で新しい値が右である。
- 左側のスタックが命令の実行前のスタックを表し、右側のスタックが命令の実行後のスタックを表す。
試しにやたら目につくcallvirt
を見てみましょう。
形式
callvirt <メソッド>
スタック遷移
..., <オブジェクト>, <引数1>, ..., <引数N> → ..., <返り値>(返り値のないメソッドの場合は無い)
callvirt
はメソッドを呼び出します。
また、virt
とついているあたり、コンパイル時のクラスに基づいたメソッド呼び出しではなく、実際のクラスに基づき呼び出すメソッドを決定します。
まぁ、要するにオーバーライドされている場合はオーバーライドしている派生先のメソッドが呼び出されるってことです。
callvirt
で呼び出す場合は最初に呼び出すオブジェクトをスタックへプッシュし、次に順に引数をスタックへプッシュしていきます。
呼び出し先のメソッドから制御が返されると、返り値がある場合はスタックの先頭に返り値が置かれます。
と、いうわけで先ほどのコードを先頭から見ていきましょう。
var target = (Target)value; // IL_0001: ldarg.0 // IL_0002: castclass Samplejunk.Target // IL_0007: stloc.0
ldarg.0
は0番目の引数をスタックへプッシュ(空) → object
castclass
でスタックの先頭のオブジェクトをキャストしスタックにプッシュobject → Target
stloc.0
でスタックの先頭のオブジェクトを0番目のローカル変数に代入Target → (空)
var builder = new StringBuilder(); // IL_0008: newobj instance void [mscorlib]System.Text.StringBuilder::.ctor() // IL_000d: stloc.1
newobj
でStringBuilder
のインスタンスを生成(空) → StringBuilder
stloc.1
でスタックの先頭のオブジェクトを1番目のローカル変数に代入StringBuilder → (空)
builder .Append(nameof(Target) + ":{") // IL_000e: ldloc.1 // IL_000f: ldstr "Target:{" // IL_0014: callvirt instance class [mscorlib]System.Text.StringBuilder [mscorlib]System.Text.StringBuilder::Append(string)
ldloc.1
で1番目のローカル変数をスタックにプッシュ(空) → StringBuilder
ldstr
で文字列定数"Target:{"をスタックにプッシュStringBuilder → StringBuilder, string
callvirt
でStringBuilder::Append(string)
を呼び出すStringBuilder
をスタックにプッシュするStringBuilder, string → StringBuilder
あとは似たようなコードなので割愛しますが、上のコードから以下のことが読み取れます。
StringBuilder::Append
の適切なオーバーロードがある場合はそのメソッドを呼び出す- 参照型で適切なオーバーロードがない場合は
StringBuilder::Append(object)
を呼び出す - 値型で適切なオーバーロードがない場合は
box
でボックス化を施したのちにStringBuilder::Append(object)
を呼び出す
つまりC#で書く場合はコンパイラがやってくれているオーバーロードの解決を自分でやらないといけないってことです。
Sigil
前半はSigilというよりはILそのものについてのお話しでしたが、ここからはSigilでどのように生成しましょうかというお話になります。
その前に、String::Append
のオーバーロードを解決するヘルパメソッドを作っておきましょう。
static bool IsSupportedType(Type type) { var appends = typeof(StringBuilder) .GetMethods(Public | Instance) .Where(it => it.Name == "Append") .Where(it => it.GetParameters().Length == 1) .ToDictionary(it => it.GetParameters()[0].ParameterType); return appends.ContainsKey(type); } static MethodInfo GetAppend(Type type) { var appends = typeof(StringBuilder) .GetMethods(Public | Instance) .Where(it => it.Name == "Append") .Where(it => it.GetParameters().Length == 1) .ToDictionary(it => it.GetParameters()[0].ParameterType); if (appends.ContainsKey(type)) return appends[type]; else return appends[typeof(object)]; }
IsSupportedType
はType
を引数に取り、その型の引数をとるオーバーロードがあればtrue
を返し、なければfalse
を返します。
同様にGetAppend
はType
を引数に取り、その型の引数をとるオーバーロードがあればそのMethodInfo
を返し、なければobject
のMethodInfo
を返します。
static Func<object, string> BuildDelegate(Type targetType) { var targetProperties = targetType.GetMembers(Public | Instance) .Where(it => it is PropertyInfo) .Select(it => (PropertyInfo)it) .Where(it => it.CanRead && it.GetIndexParameters().Length == 0); var e = Emit<Func<object, string>>.NewDynamicMethod(); using (var target = e.DeclareLocal(targetType)) using (var builder = e.DeclareLocal<StringBuilder>()) { // target = (Target)value; e.LoadArgument(0); e.CastClass(targetType); e.StoreLocal(target); // builder = new StringBuilder(); e.NewObject<StringBuilder>(); e.StoreLocal(builder); // builder.Append(nameof(Target) + ":{") e.LoadLocal(builder); e.LoadConstant(targetType.Name + "{"); e.CallVirtual(GetAppend(typeof(string))); var isNotFirst = false; foreach (var it in targetProperties) { if (isNotFirst) { // builder.Append(",") e.LoadConstant(","); e.CallVirtual(GetAppend(typeof(string))); } isNotFirst = true; // builder.Append(nameof(Target.Property) + "=") e.LoadConstant(it.Name + "="); e.CallVirtual(GetAppend(typeof(string))); // builder.Append(target.Property) e.LoadLocal(target); e.CallVirtual(it.GetGetMethod()); if (it.PropertyType.IsValueType && !IsSupportedType(it.PropertyType)) e.Box(it.PropertyType); e.CallVirtual(GetAppend(it.PropertyType)); } // builder.Append("}") e.LoadConstant("}"); e.CallVirtual(GetAppend(typeof(string))); // return builder.ToString() var toString = typeof(object).GetMethod("ToString"); e.CallVirtual(toString); e.Return(); } Console.WriteLine($".maxstack {e.MaxStackSize}"); Console.WriteLine(e.Instructions()); return e.CreateDelegate(); }
static void Main(string[] args) { var target = new Target() { Id = 1, Name = "あああああ", Hoge = DateTime.Now }; var d = BuildDelegate(typeof(Target)); Console.WriteLine(); Console.WriteLine(d(target)); }
using (var target = e.DeclareLocal(targetType)) using (var builder = e.DeclareLocal<StringBuilder>()) { // ... }
上記の構文で変数の宣言を行っています。
本来ILレベルでは変数のスコープはメソッドが最小単位なのですが、IDisposable
を用いた機構でブロックレベルでの変数スコープを表現しています。
これは面白いと思います。
あとはほとんどILと一対一で対応している命令を放り込んで最後に
e.CreateDelegate();
でデリゲートは完成です。
実行結果はこちら。
.maxstack 2 ldarg.0 castclass SigilCSharp.Target stloc.0 // SigilCSharp.Target _local0 newobj Void .ctor() stloc.1 // System.Text.StringBuilder _local1 ldloc.1 // System.Text.StringBuilder _local1 ldstr 'Target{' callvirt System.Text.StringBuilder Append(System.String) ldstr 'Id=' callvirt System.Text.StringBuilder Append(System.String) ldloc.0 // SigilCSharp.Target _local0 callvirt Int32 get_Id() callvirt System.Text.StringBuilder Append(Int32) ldstr ',' callvirt System.Text.StringBuilder Append(System.String) ldstr 'Name=' callvirt System.Text.StringBuilder Append(System.String) ldloc.0 // SigilCSharp.Target _local0 callvirt System.String get_Name() callvirt System.Text.StringBuilder Append(System.String) ldstr ',' callvirt System.Text.StringBuilder Append(System.String) ldstr 'Hoge=' callvirt System.Text.StringBuilder Append(System.String) ldloc.0 // SigilCSharp.Target _local0 callvirt System.DateTime get_Hoge() box System.DateTime callvirt System.Text.StringBuilder Append(System.Object) ldstr '}' callvirt System.Text.StringBuilder Append(System.String) callvirt System.String ToString() ret Target{Id=1,Name=あああああ,Hoge=2016/04/30 17:36:16}
ちゃんと動いていますね。
おわりに
どちらかと言うとSigilというよりはILそのものの解説になってしまいました。
とは言っても、Sigilの命令はILと一対一で対応しており、ILを生成するためのDSLを提供しているわけではないのでどうしてもILの知識は必要になってしまいます。
最後にサンプルコードをのっけて終わりにしたいと思います。
おわり