はじめに
飽きもせずに.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の知識は必要になってしまいます。
最後にサンプルコードをのっけて終わりにしたいと思います。
おわり