C#でもSigilでデリゲートをダイナミックに生成したい

はじめに

飽きもせずに.NET黒魔術シリーズです。

SigilといってもEPUBの方のSigilではないです。そっちの情報を求めていた人はまわれ右してお帰りください。

やる気が起きなかった時にネットサーフィンしていたところ、こんな記事を見つけました。

www.infoq.com

github.com

このライブラリは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
  • newobjStringBuilderインスタンスを生成
    • (空) → 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
  • callvirtStringBuilder::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)];
}

IsSupportedTypeTypeを引数に取り、その型の引数をとるオーバーロードがあればtrueを返し、なければfalseを返します。

同様にGetAppendTypeを引数に取り、その型の引数をとるオーバーロードがあればそのMethodInfoを返し、なければobjectMethodInfoを返します。

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の知識は必要になってしまいます。

最後にサンプルコードをのっけて終わりにしたいと思います。

github.com

おわり