Introduction to the pragmatic IL via C#

この記事はC# Advent Calendar 2017のための記事になります。12/1はmasanori_mslさんの【C#】処理の委譲で迷った話でした。そしてこの記事は12/2、のはずが今は12/4、つまり……。すみません。

ところでですが、私は今年の自身のテーマとして、「Extreme C#」を掲げています。C#で極限まで性能を出していく、ということを主題にして様々なものを公開してきました。その中でもILを書く技術というのは、どうしても欠かせないものです。実際、私が近年制作したライブラリはほとんどIL生成を含んでいます。

例えば、シリアライザ - ZeroFormatter, MessagePack for C#, Utf8Json。RPC - PhotonWire, MagicOnion。DI - MicroResolver。これらから、実際に使われた例と、そして実地でしか知り得ないTipsを紹介します。

この記事によって、IL書きが決して黒魔術ではなく、ごく当たり前の選択肢、になるのは行き過ぎにしても、必要な時に抵抗なく選べるようになってくれれば幸いです。

動的生成の本質

IL書けるのは凄いとか、黒魔術とか、そんなイメージがなくもないと思うんですが、とはいえ別に漠然とILを書いても、別に速いコードになるわけではありません。そして、最初のイメージとして浮かぶのは「リフレクションを高速にするもの」だと思いますが、本質的にはそうではありません。じゃあ何かっていうと、私は「生成時の最適なコード分岐の抽象化」というイメージで捉えています。

具体例としてUtf8Jsonのシリアライズを見てみましょう。

namespace ConsoleApp26
{
    // こんなどうでもいいクラスがあるとして
    public class Person
    {
        public int Age { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }

    class Program
    {
        static void Main(string[] args)
        {
            // これで生成したシリアライザが作られる(or 取り出される)
            var serializer = DynamicObjectResolver.Default.GetFormatter<Person>();

            // 生成型名:Utf8Json.Formatters.ConsoleApp26_PersonFormatter1
            Console.WriteLine(serializer.GetType().FullName);

            // まぁこんな風にシリアライズする
            var writer = new JsonWriter();
            serializer.Serialize(ref writer, new Person(), BuiltinResolver.Instance);
            Console.WriteLine(writer.ToString()); // {"Age":0,"FirstName":null,"LastName":null}
        }
    }
}

Utf8Jsonのシリアライザ生成は、DynamicObjectResolverのGetFormatterで行われています(普段はこれより高レベルなAPI、JsonSerializer.Serializeに隠れて裏で行われているので、露出はしていません)。シリアライザの生成ってどういうことかというと、概ねこんな感じです。

// このインターフェイスは公開
public interface IJsonFormatter<T> : IJsonFormatter
{
    void Serialize(ref JsonWriter writer, T value, IJsonFormatterResolver formatterResolver);
    T Deserialize(ref JsonReader reader, IJsonFormatterResolver formatterResolver);
}

// この型が動的に生成された
public class ConsoleApp26_PersonFormatter1
{
    public void Serialize(ref JsonWriter writer, T value, IJsonFormatterResolver formatterResolver)
    {
        // この中身をIL直書きで埋め込み
    }

    // Deserialize...
}

よし、じゃあいっちょその生成部分見りゃあいいってことっすね、と見に行くときっとわけわかんなくて挫折する(DynamicObjectResolver.cs#L734-L1389)と思うのでお薦めしません(あばー)。この記事を最後まで読んでくれれば分かるようになりますよ!

さて、ILを埋め込むというのは、そもそも普通のC#で書けるということなのです。動的生成というのは、汎用化/抽象化なので、Personが来たときにはこういうコードを生成しよう、というのは素のC#で書けます。IL直書きは別にマジックでもなんでもなく、原則C#で書けること以上のことはできませんから。

public class ConsoleApp26_PersonFormatter1 : IJsonFormatter<Person>
{
    // writerで手書きするならこんなもんですよね、的な。
    public void Serialize(ref JsonWriter writer, Person value, IJsonFormatterResolver formatterResolver)
    {
        if(value == null)
        {
            writer.WriteNull();
            return;
        }

        // なんとなく挙動のイメージは伝わるでしょう(伝わりますよね?)

        writer.WriteBeginObject(); // {

        writer.WritePropertyName("Age"); // "Age":
        writer.WriteInt32(value.Age);

        writer.WriteValueSeparator(); // ,
        writer.WritePropertyName("FirstName"); // "FirstName":
        writer.WriteString(value.FirstName);

        writer.WriteValueSeparator(); // ,
        writer.WritePropertyName("LastName"); // "LastName":
        writer.WriteString(value.LastName);

        writer.WriteEndObject(); // }
    }
}

素朴に考えると、上のようなコードになるでしょう。 value.Age などの部分が、IL生成をしない汎用的なコードだとリフレクションが必要なものですが、IL生成によってそれを避ける、つまり「リフレクションを高速にするもの」状態です。また、高速化のポイントとしてはルックアップを最小に抑える、というのが挙げられます。プロパティ単位でアクセサーを生成していると、プロパティ名で辞書引き(文字列の辞書引きは比較的コストの高い処理です!)ではなく、型単位で全てまとまったものを生成することで、より高速なコードが得られます。

「普通は」このぐらいのコードが出来ると満足してしまうところですが、真の魔術師になりたいなら、もっとアグレッシブに行きましょう。Utf8Jsonの最新版のコード生成はこうなっています。

public class ConsoleApp26_PersonFormatter1 : IJsonFormatter<Person>
{
    // プロパティ名は変わらないので、予めエンコード済みのキャッシュを持つ
    byte[][] stringByteKeys;

    public ConsoleApp26_PersonFormatter1()
    {
        stringByteKeys = new byte[][]
        {
            // Ageは一番最初なので{も含めて埋め込む。それ以外は二番目なので,も含めて埋め込む
            JsonWriter.GetEncodedPropertyNameWithBeginObject("Age"), // {"Age":
            JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator("FirstName"), // ,"FirstName":
            JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator("LastName") // ,"LasttName":
        };
    }

    public void Serialize(ref JsonWriter writer, Person value, IJsonFormatterResolver formatterResolver)
    {
        if (value == null)
        {
            writer.WriteNull();
            return;
        }

        // byte[]の長さが7だと「生成時」に知ってるので、長さに最適化したバイトコピーを使う
        // 32Bit環境か64Bit環境なのかも、「生成時」に知っているので、その環境向けのコードを吐く
        UnsafeMemory64.WriteRaw7(ref writer, this.stringByteKeys[0]);
        writer.WriteInt32(value.Age);

        UnsafeMemory64.WriteRaw13(ref writer, this.stringByteKeys[1]);
        writer.WriteString(value.FirstName);

        UnsafeMemory64.WriteRaw12(ref writer, this.stringByteKeys[2]);
        writer.WriteString(value.LastName);

        writer.WriteEndObject();
    }
}

初期化タイミングでキャッシュ出来るものは徹底的にキャッシュしよう、ですね。このぐらいまでなら手書きでもやってやれなくもないですが、そのbyte[]の長さに決め打たれたバイトコピーのメソッドを使う、というのは実質やれない、の領域です。また、「実行時」にしか知り得ない32Bitか64Bitという情報も含めて埋め込んでいけるのは実行時コード生成にだけ可能な芸当です(まぁif(IntPtr.Size == 4)ぐらいの分岐はJITで消えますが)。

さて、JSONのシリアライズはオプションによって様々に変更させることが求められます。例えば、「nullの場合は出力しない、名前をスネークケースにする」というオプション(DynamicObjectResolver.ExcludeNullSnakeCase)の場合、このようなコードを生成します。

public class ConsoleApp26_PersonFormatter1 : IJsonFormatter<Person>
{
    byte[][] stringByteKeys;

    public ConsoleApp26_PersonFormatter1()
    {
        // snake_caseのものをキャッシュ。nullかどうかで先頭が変わるので{や,は埋めこまない
        stringByteKeys = new byte[][]
        {
            JsonWriter.GetEncodedPropertyName("age"),
            JsonWriter.GetEncodedPropertyName("first_name"),
            JsonWriter.GetEncodedPropertyName("last_name")
        };
    }

    public void Serialize(ref JsonWriter writer, Person value, IJsonFormatterResolver formatterResolver)
    {
        if (value == null)
        {
            writer.WriteNull();
            return;
        }

        writer.WriteBeginObject(); // {

        var first = true;

        // structはnullチェックなし
        // if (value.Age != null)
        {
            if (!first)
            {
                writer.WriteValueSeparator();
            }
            else
            {
                first = false;
            }

            UnsafeMemory64.WriteRaw6(ref writer, this.stringByteKeys[0]);
            writer.WriteInt32(value.Age);
        }

        if (value.FirstName != null)
        {
            if (!first)
            {
                writer.WriteValueSeparator();
            }
            else
            {
                first = false;
            }

            UnsafeMemory64.WriteRaw13(ref writer, this.stringByteKeys[1]);
            writer.WriteString(value.FirstName);
        }

        if (value.LastName != null)
        {
            if (!first)
            {
                writer.WriteValueSeparator();
            }
            else
            {
                first = false;
            }

            UnsafeMemory64.WriteRaw12(ref writer, this.stringByteKeys[2]);
            writer.WriteString(value.LastName);
        }

        writer.WriteEndObject(); // }
    }
}

処理が多くなりましたね!そう、Defaultに比べるとExcludeNullは、条件分岐が増えることと、JSONとしてのプロパティの出力順番が不定のため、キャッシュのアグレッシブ度も下げざるを得ないため、実行速度が若干低下します。

今回別にJSONの解説をしたいわけではなくて、大事なのは、オプションによって最高速なコードは変わっていくということです。そこを共通化してオプションによってコード分岐させたりせずに、オプション毎に最適化されたコードを生成することが肝要です。とはいえ、徹底的にオプション毎にコード生成を分けるのは生成部分が肥大化するため、記述には大いに苦痛を伴うでしょう。それをありえないほどクソ丁寧に徹頭徹尾やってるからMessagePack for C#やUtf8Jsonはデタラメに高速なのです。

また、事前生成ではオプション毎の最適なコードの生成は事実上不可能(全ての組み合わせを用意することは出来ない!)ので、その点でもあらゆるパターンの最適化コードを作れる動的生成は有利です。もちろん、通常アプリケーションで使うオプションは固定なので、そのオプションに絞った生成をすればいい、とうのは回答の一つではありますが(実際、UnityのAOT環境であるIL2CPP向けのUtf8Json, MessagePack for C#では単一オプションでの生成を行う)。

ともあれ、IL生成とかなんとかいっても、環境固定・対象固定であれば、C#で書けるコードが動的に生成されている、というだけの話です。C#で見ると、まぁちょっと面倒くさいことやってるな、程度の話で、別に特別に複雑なことはやってないんですよね。

というわけで、コード生成をしたいと思ったら、考える順番として、必ず、C#だとどういうコードになるか、を想像して、いや、実際に書くところから始めましょう。それが出来上がれば、あとはILに起こすだけです。その起こすだけ、というのが難しそう!っていう話なのですが、実は現代はツールが充実しているので、以外と難しくありません!というわけで、本題に入っていきましょう。

動的生成の手段

それなりに色々あるので、何使えばいいのーガイド最新版。

CodeDom。今はRoslyn(C#実装のC#コンパイラ)があるので、レガシー互換したいとかの余程の謎事情がない限りは不要かな。特に、動的生成したい、という目的で選ぶ必要性はあまりないでしょう。

AssemblyBuilder。動的にアセンブリを生成します。アセンブリを生成するということは、動的にモジュールを作り、動的に型を作り、動的にメソッドを作ります。つまりなんでも出来ます。コードの埋め込みはIL手書き。今回の話のメイン。NuGetではSystem.Reflection.Emit

DynamicMethod。こちらは動的にデリゲートを作るというもの。コードの埋め込みはIL手書き。NuGetではSystem.Reflection.Emit.Lightweightということで、Lightweightエディションです。LCG(Lightweight CodeGen)と言われることもある。型そのものを作るAssemblyBuilderよりも出来ることが圧倒的に限られてしまうので、Lightweightに済ませたい局面以外では不要、と言いたいところなのですが、実はLCGでしか出来ないこともあるので、現実的にはAssemblyBuilderと併用していくことになります。

LCGでしか出来ないことというのは、private変数への外側からのアクセスです。AssemblyBuilderでは、本当に外側からC#を書いた時のような制限がかかりますが、LCGではその辺を無視することが可能です。動的生成ではリフレクション系を扱うことが多いはずで、privateへもアクセスしたいというのは多くの場合要件に含まれるでしょう。

ExpressionTree。できることはLCGと同じ(最終的にデリゲート生成ではLCGを通して作られているので)。ただし定義されているExpression以上のことはできないのと、正直いってIL書くのに慣れると、ExpressionTreeのほうが冗長で面倒くさいので、最近の私は使いません。特に.NET 4から足されたループなど「文」系の構文をExpressionTreeで書くのはかなりダルいので、無理して拘る必要はないでしょう。

ただしExpressionTreeによるCompileはXamarin iOSなどのAOT環境(動的コード生成不可)でも動くデリゲートが生成できます。何故なら、AOT環境の場合はExpressionTree専用のインタプリタで動かすデリゲートを生成するからです。もちろん、インタプリタになるので低速ですが、互換性維持的に楽なので、その点ではLCGではなくExpressionTreeを選ぶという選択肢はアリです。

Microsoft.CodeAnalysis.CSharp(Roslyn)。C#コンパイラ、ということでILを書かずとも、文字列としてのC#コードを書けばそこから実行時に使えるコードを生成できます。ILの知識も不要だしC#コンパイラの最適化も受けれるのでいいね!って話なのですが、あんま使われてないし、実際私もあまり使う気にはなれません。何故かというと、標準入りせず(5年前の.NET 4.5からは、コアフレームワーク標準入りという概念はなくなって、新規ライブラリはNuGetによる提供が主体になったため)、かなり大仰なパッケージを入れる必要があるため、依存関係にそれを仕込みたくないというのが一つ。もう一つは、割と面倒くさい。ソースコードをポンと放り投げれば出来上がり、というほどではなく、参照関係をかっちりかき集めてこなきゃいけないので、想像よりも遥かに手間がかかるんですね。一度テンプレートコードみたいなのを作ってしまえばいいといえばいいんですが……。また、初回生成時コストがかなり高いのが、初回のみなので無視できると言い張るにしても若干厳しいところもある。

と、いうわけでこの記事ではAssemblyBuilderとDynamicMethodを中心に扱っていきます。

動的生成のためのツール

よし、じゃあ早速書いていくぜ、の前にツールです。はやる気持ちは抑えて、何はともあれツールです。ツールがあると理解がめちゃくちゃ早まりますし、ハマりどころもなくなってめちゃくちゃ楽になります。とにかく現代はツールがめちゃくちゃ充実しています。別にildasmとニラメッコしたり、デバッグシンポルを入れるのに四苦八苦したりする必要はありません。シンプルに書いて、ひたすらツールに突っ込むのがとにかく近道です。

DnSpy。最強の.NET逆コンパイラ。DynamicAssemblyで生成したコードなら、そのまま中身確認どころかステップ実行のデバッグができる。ヤバい。もうこれで何も怖くない。残念ながらDynamicMethodにたいしてのデバッグは出来ないので、それだけのためにもDynamicAssembly中心にしたい(が、DynamicMethodのプライベートアクセスの機能は重要なので頑張って両対応させるのが、一手間でも最終的には一番いい)。

ILSpy。みんな大好き定番.NET逆コンパイラ。DynamicAssemblyならDLLとして出力することが可能なので、それを流し込めば生成した結果がC#コードとして見れる。IL手書きは、たいてい一発でうまくいかなくてC#として解析できない腐ったILを作ってしまったりするのですが、それはそれで、生成されたILを見ることができるので間違っている場所を探し出すことができます。アセンブリのリロードがDnSpyと違ってサクサクできるので、未だにDnSpyよりもこちらのほうが出番ずっと多し。なお、この生成コードをDLLとして出力して確認する、というデバッグ手法はコード生成がめちゃくちゃ楽になるので、絶対欠かせません(で、DynamicMethodだとそれができないので頑張って両対応させるのが一番)。

LINQPad。LINQPadの何がいいかというと、ILタブがあるところ。C#で書いたコードがどういうILに変換されるかは、LINQPadでミニマムなコードを書いて確認するのが一番手っ取り早い。いわばカンニングです。別にILの全てを知らなきゃIL手書きできないわけじゃないんです、普通にC#で書いて、書き写してくだけでいいんですよ。いやほんと。それを繰り返していくうちに、そのうち覚えていくでしょうしね。そう、別にミニマムなコードだけじゃなく、「コード生成をしたいと思ったら、考える順番として、必ず、C#だとどういうコードになるか、を想像して、いや、実際に書くところから始めましょう」と言いましたが、そのコード全体をLINQPadに通してILタブを見れば、それが生成すべきコードの答えです!汎用的にするため、ある程度は自分で展開しなきゃいけないんですが、「答え」が存在しているのといないのとでは、難易度は桁違いに変わります。

LINQPadSpy。別に必ず必要でもないんですが、これはいわばC# to C#です。どういうことかというと、LINQPadの生成結果をILSpyに流したものがその場で確認できます。C# to C#って同じ結果だろ?と言いたいところなのですが、C#コンパイラもまたコンパイル時コード生成するので、全然異なるコードになってたりするんですね。例えばC#のswitch文のコンパイラ最適化についてという記事では、switchが二分探索に化ける例を紹介しました。そういうのをサクッと確認できるようになります。このINQPadSpyは私がForkしてLINQPad 5に対応させたものになります。

PEVerify。Visual Studioを入れればついてきます(ildasm.exeとかsn.exeとかと同じ場所にある、例えば "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7 Tools\x64\PEVerify.exe" )。これの何がいいかというと、IL手書きに間違ったコード生成はつきもの、なんですが、 その場合にどこがどう間違ってるか教えてくれます。その場所に関してはILSpyで確認できるので、ILSpyとPEVerifyを合わせれば、修正が圧倒的なスピードでできます。これないと、ひたすら気合で探していくことになりますからね。ちなみにunsafeコードがあると、その部分はダメだと指摘が来ますが、別にそれはそのままでいいので、ノイズになるのは諦めましょう。

Ildasm。99%、ILSpyがあれば不要な代物。ILSpyのほうが使いやすく、見やすいですからね。ただ、たまーに残り1%の部分でIldasmでしか表示できないものがあったりします。例えば.data領域に詰まった文字列定数のbytearrayなんかは、ILSpyだと見る術がありませんが、Ildasm経由で逆コンパイル結果を出力すると、そこの部分も見れたりします。別に見れると何があるというわけでもないですが、正しい理解のために、信頼できる無加工の生の出力をしてくれる、という性質は貴重なものがあります。めったに使いませんが。

ILの基礎

よし、じゃあ早速書くぞ、って話なのですが、まあ待ってください。まずは基礎の基礎ぐらいは軽く頭に入れておきましょう。ぶっちゃけ何も知らなくてもLINQPadで吐いたコードをカンニングコピペでなんとかなるといえばなんとかなる(ほんと!)んですが、さすがに少しぐらいは知ってたほうがエラー対処も容易になるので、覚えておきましょう。

C#コンパイラの仕事はILを作ることです。で、ILはスタックマシンとして解釈され実行されます。どういうことかというと、Stackに命令をPushしたりPopしたりして計算するそうな。

まぁ、LINQPadでふんいきを見てみましょう。

image

足し算は、Ldarg_0, Ldarg_1(引数ロード)がStackへPush。Add(足し算)がその詰まれた2つをPopして加算して、計算結果をPush。Ret(return)で、その最後の一つの値を返してStackを空に。というのが基本の流れです。

ところでLINQPadを使う場合の注意事項として、右下に最適化ボタンがあるので、必ずONにしておきましょう。

image

最適化がONじゃないとnop(何もしない命令、デバッガがこれで止まるようになるのでデバッグビルドで必要だけどリリースビルドでは不要)が大量に埋め込まれるので、見にくくなるためです。

さて、このldargやretがOpCodeという代物で、今のとこ226種類あります。ええ、via C#なのでC#で確認してみましょう。LINQPadで以下のコードを打ちます。

typeof(OpCodes).GetFields().Select(x => x.GetValue(null)).OfType<OpCode>().Dump();

image

とりあえずNameとStackBehaviourPopとStackBehaviourPushに注目。StackBehaviourPopが幾つ取り出すか、StackBehaviourPushが幾つ詰むか。ldarg.0(0番目の引数をロードする)はPop0, Push1。add(足し算)はPop1_pop1(Pop2じゃないんですね)で、Push1。二個消費して、一個返すということ。。

と、いうイメージで、一個のStackにPushしたりPopしたりして結果を作る。メソッドは大抵最後にreturnで戻り値を返すわけですが、その場合はStackに一個だけ値を残しておいて、OpCodes.Retを叩けばおk、と。

というわけで実際のIL生成としてDynamicMethodにした場合は、こうなります。さっきの足し算コードに、+ 99を追加というのにしましょう。

// (int x, int y) => x + y + 99
var dm = new DynamicMethod("Sum99", typeof(int), new[] { typeof(int), typeof(int) });
var il = dm.GetILGenerator();

// 引数0と引数1を詰んで加算、更に+99してreturn。
il.Emit(OpCodes.Ldarg_0);    // [x]
il.Emit(OpCodes.Ldarg_1);    // [x, y]
il.Emit(OpCodes.Add);        // [(x + y)]
il.Emit(OpCodes.Ldc_I4, 99); // [(x + y), 99]
il.Emit(OpCodes.Add);        // [(x + y + 99)]
il.Emit(OpCodes.Ret);        // []

// そしてCreateDelegateでFuncを作る
var sum = (Func<int, int, int>)dm.CreateDelegate(typeof(Func<int, int, int>));

// 129
Console.WriteLine(sum(10, 20));

AssemblyBuilderもDynamicMethodも基本の流れは一緒です。 GetILGenerator でILGeneratorを取得して、EmitでOpCodeの埋め込み。そして最後にCreateTypeかCreateDelegateする。Emitメソッドは引数にOpCodeと、パラメータを受け取ります。パラメータは定数であったりメソッド呼び出しであればMethodInfoなど様々。全然タイプセーフじゃないので間違ったパラメータ突っ込んじゃうことは多数ですが頑張って慣れましょう。なお、こういうのは完全に頭に叩き込んでおいてソラで手書きする必要は全くありません。基本はLINQPadで書いてカンニングコピペです。

もう少し基礎知識を続けます、習うより慣れろ、ではあるものの、ある程度OpCodeの種類も知っておいたほうが良いでしょう。大雑把に解説しておきます。

読み込む系 - ldarg., ldloc., ldc.i4.*, ldfld, ldsfld, など。ldはロードで、それぞれargは引数(argument)、locはローカル変数(local)、i4は整数(4byte integer)、fldはフィールド、sfldはスタティックフィールド、の読み込みをします。つまりPop0, Push1。長いILを書いてる時に(正しくはLINQPadからコピペって書き写している時に)スタティックとそうでないやつの書き間違いを起こすことが稀によくある。よくあるミスなのでエラーになった時はその辺を真っ先に疑います。

ldargaやldfldaなど、最後にaがついてるやつがいますが、これはaddressだけ読むもので、参照系を扱う場合に使い分けが必要です。よくわからない場合は逆コンパイル結果を見ればOK。これもまた長いILを打ってるとたまに間違えて、死ぬ場合多数。

また、.0, .1, .2, .3 や .s というのが後者についてるものがありますが(ldc.i4.1, ldc.i4.sなど)、これは最適化です。i4だと-1 ~ 8までは引数不要でそのOpCode自体が数字も示して読み込めますよ、と。sはshort formで、これまた最適化で、1バイト以内に収まるものはこちらを使ったほうが良い、という扱いです。

面倒な場合は全部Ldc_I4でいいじゃん、ってところなのですが、何も考えずとも最適に扱えるよう、こういう拡張メソッドを用意しておくのは賢いやりかたです。

public static void EmitLdc_I4(this ILGenerator il, int value)
{
    switch (value)
    {
        case -1:
            il.Emit(OpCodes.Ldc_I4_M1);
            break;
        case 0:
            il.Emit(OpCodes.Ldc_I4_0);
            break;
        case 1:
            il.Emit(OpCodes.Ldc_I4_1);
            break;
        case 2:
            il.Emit(OpCodes.Ldc_I4_2);
            break;
        case 3:
            il.Emit(OpCodes.Ldc_I4_3);
            break;
        case 4:
            il.Emit(OpCodes.Ldc_I4_4);
            break;
        case 5:
            il.Emit(OpCodes.Ldc_I4_5);
            break;
        case 6:
            il.Emit(OpCodes.Ldc_I4_6);
            break;
        case 7:
            il.Emit(OpCodes.Ldc_I4_7);
            break;
        case 8:
            il.Emit(OpCodes.Ldc_I4_8);
            break;
        default:
            if (value >= -128 && value <= 127)
            {
                il.Emit(OpCodes.Ldc_I4_S, (sbyte)value);
            }
            else
            {
                il.Emit(OpCodes.Ldc_I4, value);
            }
            break;
    }

Ldc_I4に限らず、慣れてきたら幾つか予め容易しておくと色々はかどります。この辺のユーティリティが勢揃いフルセットなのがSigilなのですが、これはこれでToo Muchなきらいもあるし、ツール類から流したりコピペったりする分には素のほうがやりやすかったりなので、むしろ最初のうちは素のままやっていったほうが良いでしょう。Sigilの検証などは一見良さそうなのですが、素で書いてILSpy/ILVerifyに流したほうが結局情報豊富だったりしますしね。

なお、Utf8JsonのILGeneratorExtensionsを参考までに。基本的には素朴にやれるものしか定義していません。

代入する系 - stloc, starg, stfld, stsfld, など。stはストアということで代入、まんまですね。スタックへの挙動はPop1, Push0です。そりゃそーだ。

算術演算系 - add, sub, mul, div, など。まぁこれはまんまですね。二項演算子なので、みんなPop1_pop1, Push1です

分岐系 - br, brtrue, beq, bgt, ble, bne, blt, など。brはbranchで、ようするところif + gotoです。C#でifで書いたものは、全てbr*に変換されています。値をPopして、それを元にしてジャンプするかどうかを決めます。beqはbranch equal, bneはbranch not equal, bleはbranch less than equal, bltはbranch less than, bgeはbranch greater than equal, bgtはbranch greater thanと、3文字で圧縮されると呪文のようでわかりにくくあるんですが、概ねそういうことですね。switchもありますが、C#のswitchとは異なることに注意。C#のswitchはコンパイラが場合によって二分探索に置き換えたりしますが、OpCodeのswitchは[0..]のジャンプテーブル(goto先が詰まってる)しかありません。

その他 - callはメソッド呼ぶ。Pop数は引数によりけりなので不定(Varpop)。callvirtというものもあって、違いはcallvirtが仮想メソッド呼び出し(インターフェース経由とかの場合)、callが直呼び出しということで、よくわかんなかったらcallvirtに倒しときゃとりあえず安全、という雑な言い方もできますが、例によって出し分け拡張メソッドを作っておくと、何も考えなくてラクかもしれません。

public static void EmitCall(this ILGenerator il, MethodInfo methodInfo)
{
    if (methodInfo.IsFinal || !methodInfo.IsVirtual)
    {
        il.Emit(OpCodes.Call, methodInfo);
    }
    else
    {
        il.Emit(OpCodes.Callvirt, methodInfo);
    }
}

こうやってIL眺めてると、高速なのはきっとCallのほうなんだろうなぁ、みたいなイメージが湧いてきます。取っ掛かりは、そういう雑なイメージからでいいんですよ。

retはreturn。voidのメソッドであってもメソッドの最後は必ずretでしめます。

dup。これはスタックの値を複製する。例えば連続してインスタンスのプロパティに代入する場合なんかに、インスタンスをdupしたりします。ようはオブジェクト初期化子なんかそうですね。

image

スタックの状態を書くと、

newobj(myclass)
dup(myclass, myclass)
ldc.i4(myclass, myclass, 15)
callvirt(myclass)
dup(myclass, myclass)
ldstr(myclass, myclass, "HogeHoge")
callvirt(myclass)
ret()

と、いうわけです。dupは何かとよく出てくるんですが、スタックの状況によって増えるものが違うんで混乱の原因ではありますね。まぁ、大抵はインスタンスのはずです。手書きの際に条件分岐などでdupすべきスタックの状態がグチャグチャでよくわからん!ってなる場合は、ローカル変数を作ってしまって、それをロードする、という形で逃げる手も割と良い手段です。LINQPadからのカンニングコピペは基本ですが、時に自分の意志で逸脱できるようになれば上級者!

AssemblyBuilderことはじめ

というわけで本編。AssemblyBuilderを始めましょう。習うより慣れろ、ということでまずやってみましょう。注意点としては、まずは.NET Coreや.NET Standardじゃなく、.NET Frameworkで作ってみてください(Linux環境下の人はmonoで!)。理由は、.NET Coreではアセンブリの保存ができないため、デバッグ難易度が跳ね上がるからです。

const string ModuleName = "FooBar";

// .NET 4.5から。それ以前ではAppDomain.CurrentDomain.DefineDynamicAssemblyをかわりに使う
// AssemblyBuilderAccessは.NET Coreでは現状Runしか使えないが、デバッグに超便利なので少なくともデバッグ用にだけはRunAndSaveの口を確保しておきたい
// 一つのAssemblyに複数ModuleをDefineすることが可能ですが、何かと混乱を招くので、わかりやすさのためにも1:1にしておくと良い
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(ModuleName), AssemblyBuilderAccess.RunAndSave);

// 基本的にはmoduleBuilderをstatic変数などに保持しておいて、必要な際に都度DefineTypeで動的に型定義していく
var moduleBuilder = assemblyBuilder.DefineDynamicModule(ModuleName, ModuleName + ".dll"); // RunAndSaveの場合、ここでファイル名を指定しておく

// Foo型を定義
var typeBuilder = moduleBuilder.DefineType("Foo", TypeAttributes.Public);

// Foo型からSumインスタンスメソッドを定義
var sum = typeBuilder.DefineMethod("Sum", MethodAttributes.Public, typeof(int), new[] { typeof(int), typeof(int) });

// そしてメソッドの中身をEmit
var il = sum.GetILGenerator();
il.Emit(OpCodes.Ldarg_1); // インスタンスメソッドの場合、arg0がthisになる
il.Emit(OpCodes.Ldarg_2);
il.Emit(OpCodes.Add);
il.Emit(OpCodes.Ret);

// CreateTypeで型を実体化する
var fooType = typeBuilder.CreateType(); // これで「型」のできあがり
var instance = Activator.CreateInstance(fooType); // まぁ大抵は?生成したインスタンスをキャッシュするのでしょう

var result = fooType.GetMethod("Sum").Invoke(instance, new object[] { 10, 20 });
Console.WriteLine(result); // 30, ちゃんとSumが呼べてる。

// 保存する時はDefineDynamicModuleの時に指定したのと同じ名前で吐くのが安全のために良い
#if DEBUG
assemblyBuilder.Save(ModuleName + ".dll");
#endif

これでFooBarモジュールにSumメソッドを持つFoo型ができました。DefineDynamicAssembly -> DefineDynamicModuleは定形なので、こんなもんだと思ってください。ここで作るAssemblyBuilder/ModuleBuilderはアプリケーション中でずっと使いまわします(さすがに一つの型毎にAssembly生成してたら過剰すぎるので!)。

DefineTypeにより型定義、このDefineTypeはスレッドセーフなので安心して(?)グローバルに保存しているModuleBuilderから呼び出せます(ただしmonoでは非スレッドセーフなので、mono環境での実行を意識するならDefineTypeにlockかけましょう、例えばUnityとかね……)。

型を定義したら次はメソッド、ということでDefineMethod。Defineには他にDefineField, DefineConstructor, DefinePropertyなどあります。そして中身の記述のためILGeneratorを取り出し、Emit。最後にCreateTypeしてできあがり、です。

ここまでで通常は終わりですが、デバッグ時はSaveを呼んで、中身を確認すると色々と楽になれます。今回はFooBar.dllができたので、ILSpyで開いてみましょう。

image

問題なし、と。まぁ問題ない場合は問題なしでいいんですが、たいてい問題アリなので(特に長いコード書いてくと本当に辛い!)、こうして見れるのめちゃくちゃ大事です。

或いはdnSpyを使うという手もあります。dnSpyの場合はそのままステップ実行までできます!やり方は簡単で、Startボタンを押して、exeを指定。

image

あとは、Invokeしているところに止めて、F11連打してくと、Sumの呼び出しまでステップ実行で降りていけます。そうなるとロードしたインメモリアセンブリも表示されていて中身丸見えに。

image

なので、dnSpyを使っていくならSaveしなくても大丈夫です。ただ、そもそもILが腐っている場合にILSpyならSaveして腐ったILを見ることができますがdnSpyでは無理なので、ILのデバッグ的には腐ったILを修正していくフェーズのほうが多いので、できればSave可能な環境を作ったほうが良いでしょう。

でも最終成果物は.NET StandardなのでSaveできないんです!って場合は、というかもう今からライブラリ作る人はみんなそうだと思うんですが、そういう人はメインライブラリは.NET Standardで作って、それとは別に.NET Frameworkのコンソールアプリを作って、プロジェクト参照でライブラリを引っ張り、コンパイラシンボルで.NET Frameworkからの参照のときのみSaveの口を開けておく、みたいなやり方で確保するのがオススメです。例えばUtf8JsonはこんなAssemblyBuilder用のヘルパーを使っています。

using System.Reflection;
using System.Reflection.Emit;

namespace Utf8Json.Internal.Emit
{
    internal class DynamicAssembly
    {
#if NET45 || NET47
        readonly string moduleName;
#endif
        readonly AssemblyBuilder assemblyBuilder;
        readonly ModuleBuilder moduleBuilder;

        public ModuleBuilder ModuleBuilder { get { return moduleBuilder; } }

        public DynamicAssembly(string moduleName)
        {
#if NET45 || NET47
            this.moduleName = moduleName;
            this.assemblyBuilder = System.AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName(moduleName), AssemblyBuilderAccess.RunAndSave);
            this.moduleBuilder = assemblyBuilder.DefineDynamicModule(moduleName, moduleName + ".dll");
#else
#if NETSTANDARD
            this.assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(moduleName), AssemblyBuilderAccess.Run);
#else
            this.assemblyBuilder = System.AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName(moduleName), AssemblyBuilderAccess.Run);
#endif

            this.moduleBuilder = assemblyBuilder.DefineDynamicModule(moduleName);
#endif
        }

#if NET45 || NET47

        public AssemblyBuilder Save()
        {
            assemblyBuilder.Save(moduleName + ".dll");
            return assemblyBuilder;
        }

#endif
    }
}

PEVerifyことはじめ

最初のうちどころか、慣れてきても、大抵はEmitには失敗します。どっか間違えます。例えばスタックにあまったものが存在している場合

var il = sum.GetILGenerator();
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldarg_2);
il.Emit(OpCodes.Add);
il.Emit(OpCodes.Ldc_I4, 999); // 一個余計なものを足す
il.Emit(OpCodes.Ret);

これは、Sumを呼んだ時に実行時エラーとして「System.InvalidProgramException: JIT コンパイラで内部的な制限が発生しました。」がでます。この「JIT コンパイラで内部的な制限が発生しました。」はもう悲すぃぐらいに付き合うことになるでしょう。こいつの倒し方ですが、まぁようするにどこでエラーが起きたかを突き止めていくということ。で、役に立つ(?)のが、スタックをとりあえず空にしてダミーでreturnする方。

// こういうヘルパーメソッド用意しておくと便利
public static void EmitPop(this ILGenerator il, int count)
{
    for (int i = 0; i < count; i++)
    {
        il.Emit(OpCodes.Pop);
    }
}

// で、こういうふうにしてひたすら探る
var il = sum.GetILGenerator();
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldarg_2);
il.EmitPop(2); // 二個消す(いくつPopすれば分からない場合も多いけど、そのときは1, 2, 3...と適当にPop数を増やして例外が起きないように探ればOK)
il.Emit(OpCodes.Ldc_I4_1);
il.Emit(OpCodes.Ret);
// --- ここまでは大丈夫だった --
/*
il.Emit(OpCodes.Add);
il.Emit(OpCodes.Ldc_I4, 999); // 一個余計なものを足す
il.Emit(OpCodes.Ret);
*/

Popとダミーのリターンで、どこまでのEmitは大丈夫で、どこからがダメなのかを探していきます。このやり方で9割ぐらいは最終的に見つかります。例えばldargとldarg_Sの間違いとかはサクッと見つかりますね。残り1割は、しょうがないケースなので頑張ろう。

この原始的なやり方は最後の最後まで役に立ちます。が、もう少し楽をしたいので、PEVerifyを使いましょう。PEVerifyによって95%ぐらいのエラーを一撃必殺で見抜くことができます。アセンブリのSaveとセット販売で用意しておくとデバッグが捗ります。

// ようはこういうヘルパーメソッドを用意しておく
static void Verify(params AssemblyBuilder[] builders)
{
    var path = @"C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6.1 Tools\x64\PEVerify.exe";

    foreach (var targetDll in builders)
    {
        var psi = new ProcessStartInfo(path, targetDll.GetName().Name + ".dll")
        {
            CreateNoWindow = true,
            WindowStyle = ProcessWindowStyle.Hidden,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        };

        var p = Process.Start(psi);
        var data = p.StandardOutput.ReadToEnd();
        Console.WriteLine(data);
    }
}

// Invokeタイミングで死ぬのでDLLの生成自体は可能。SaveしてVerifyを通すようにしておきましょう。
try
{
    var result = fooType.GetMethod("Sum").Invoke(instance, new object[] { 10, 20 });
    Console.WriteLine(result); // ↑のとこで例外を吐く
}
finally
{
    assemblyBuilder.Save(ModuleName + ".dll");
    Verify(assemblyBuilder);
}

PEVerifyによって、例えばこういうメッセージが得られます。

[IL]: エラー:[FooBar.dll : Foo::Sum][オフセット 0x00000008] スタックに含めることができるのは、戻り値だけです。

ILSpyでDLLをIL Viewにして見てみると

image

オフセットはIL_0008に対応していて、retのあたりがダメなんだ、ということが分かります。で、まぁメッセージとニラメッコして、なんとなくスタックの数がおかしいんだろうなあ、と辺りをつけましょう。

さて、もう一個よくみる例外が「共通言語ランタイムが無効なプログラムを検出しました。」です。これもようするところ間違えたILをEmitしてるってことなんですが。例えばこういうコードをEmitしてPEVerifyにかけましょう。

var il = sum.GetILGenerator();
il.Emit(OpCodes.Ldarg_1);
// il.Emit(OpCodes.Ldarg_2); // スタック足りなくしてみる
il.Emit(OpCodes.Add);
il.Emit(OpCodes.Ret);

こういう結果が得られます!

[IL]: エラー:[FooBar.dll : Foo::Sum][オフセット 0x00000001] スタックのアンダーフロー

腐ったILを生成すると、ILSpyのC#ビューがウンともスンとも言わなくなります。

image

が、ILビューは生きているので頑張りましょう。

image

オフセット0x00000001、つまりaddのところでスタック足りてませんよ、っていうことでした。OK。まぁこのぐらい短いとどうってことないですが、長いILだとスタックの数がオカシイのは分かるけど、どのへんイジりゃあいいんだこれ、って混乱したりしなかったりしますが、場所さえ突き止められれば、あとは気合でなんとでもなります。問題なし。

DynamicMethodことはじめ

DynamicMethodは、ようするところAssemblyBuilderからDefineAssembly/DefineModule/DefineTypeを抜いたものです。デリゲート生成しかできませんが、AssemblyBuilderをstaticなどっかに保存しておく、とか別に大したことないといえば大したことないけど、面倒っちゃあ面倒なので、いーんじゃないでしょうか。それと、大事なことが一つ。DynamicMethodならプライベートな変数やメソッドにアクセスできます。

// こんな型があるとして、ぷらいべーとなフィールドを高速に書き換えれるアクセサを用意してみましょう
public class Person
{
    int age; // private field!

    public Person(int age)
    {
        this.age = age;
    }

    public int GetAge()
    {
        return age;
    }
}

// DefineMethodとほぼ同等に戻り値、引数の型を並べて作る
// ただしDynamicMethodだけの要素として、ModuleとSkipVisibilityに注意!
var dynamicMethod = new DynamicMethod("SetAge", null, new[] { typeof(Person), typeof(int) }, m: typeof(Person).Module, skipVisibility: true);

// ILGeneratorに関してはDefineMethodとかわりなし
var il = dynamicMethod.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // staticメソッドなので0はじまり
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Stfld, typeof(Person).GetField("age", BindingFlags.NonPublic | BindingFlags.Instance));
il.Emit(OpCodes.Ret);

// 最後にCreateDelegateでデリゲートを作る
var setAge = (Action<Person, int>)dynamicMethod.CreateDelegate(typeof(Action<Person, int>));

var person = new Person(10);
setAge(person, 999);

Console.WriteLine(person.GetAge()); // 999

よくあるゲッターへのアクセサ/セッターへのアクセサ、です。汎用的なものにすると引数/戻り値がobject型にならざるを得なくて、ボクシングが避けられずエクストリームなパフォーマンス追求には使えないんですが、カジュアル用途でやってくには十分以上に便利でしょう。

DynamicMethodの注目点はm:とskipVisibility:です。これを指定しておくとプライベート変数へのアクセスが可能になるほか、実はパフォーマンス的にも有利なので、別にプライベートへのアクセスがなくても、必ず指定するようにしておくと良いでしょう。

キャッシュが型単位だったり、インターフェイス単位で使う、などの場合にDynamicMethodだとやりづらくはあるんですが、コンストラクタにデリゲートを渡して、各メソッドはそれを移譲して呼び出すだけの入れ物型を用意してあげれば、DynamicMethodでも型付きのものとほぼ同様のことが可能です。DynamicAssemblyでのコンストラクタでキャッシュ用のフィールドを初期化する、といったケース(Utf8Jsonではエンコード済みのプロパティ名とか)も、同じようにコンストラクタで渡してあげれば良いでしょう。

例えばUtf8Jsonでは、基本はDynamicAssemblyで生成したシリアライザを使いますが、AllowPrivateオプションのシリアライザを使う場合は、DynamicMethod経由で生成し、以下の入れ物を通して型をキャッシュしています。

internal delegate void AnonymousJsonSerializeAction<T>(byte[][] stringByteKeysField, object[] customFormatters, ref JsonWriter writer, T value, IJsonFormatterResolver resolver);
internal delegate T AnonymousJsonDeserializeFunc<T>(object[] customFormatters, ref JsonReader reader, IJsonFormatterResolver resolver);

internal class DynamicMethodAnonymousFormatter<T> : IJsonFormatter<T>
{
    readonly byte[][] stringByteKeysField;
    readonly object[] serializeCustomFormatters;
    readonly object[] deserializeCustomFormatters;
    readonly AnonymousJsonSerializeAction<T> serialize;
    readonly AnonymousJsonDeserializeFunc<T> deserialize;

    public DynamicMethodAnonymousFormatter(byte[][] stringByteKeysField, object[] serializeCustomFormatters, object[] deserializeCustomFormatters, AnonymousJsonSerializeAction<T> serialize, AnonymousJsonDeserializeFunc<T> deserialize)
    {
        this.stringByteKeysField = stringByteKeysField;
        this.serializeCustomFormatters = serializeCustomFormatters;
        this.deserializeCustomFormatters = deserializeCustomFormatters;
        this.serialize = serialize;
        this.deserialize = deserialize;
    }

    public void Serialize(ref JsonWriter writer, T value, IJsonFormatterResolver formatterResolver)
    {
        if (serialize == null) throw new InvalidOperationException(this.GetType().Name + " does not support Serialize.");
        serialize(stringByteKeysField, serializeCustomFormatters, ref writer, value, formatterResolver);
    }

    public T Deserialize(ref JsonReader reader, IJsonFormatterResolver formatterResolver)
    {
        if (deserialize == null) throw new InvalidOperationException(this.GetType().Name + " does not support Deserialize.");
        return deserialize(deserializeCustomFormatters, ref reader, formatterResolver);
    }
}

DynamicMethodの困った点は、Saveできないこと。dnSpyでのステップ実行もできません。これはデバッガビリティが恐ろしく落ちます。特に解決策という解決策もないんですが、しいていえばILGeneratorからの流れはDynamicAssemblyと変わらないので、Emit部分をメソッドで分けて、生成部分を共通化してやると良いでしょう。

その際の注意点は、引数の順番がズレること。これは、ArgumentFieldという構造体を用意して、Ldargなどはそれ経由で呼ぶようにして解決しました。

internal struct ArgumentField
{
    readonly int i;
    readonly bool @ref;
    readonly ILGenerator il;

    public ArgumentField(ILGenerator il, int i, bool @ref = false)
    {
        this.il = il;
        this.i = i;
        this.@ref = @ref;
    }

    public ArgumentField(ILGenerator il, int i, Type type)
    {
        this.il = il;
        this.i = i;
        this.@ref = (type.IsClass || type.IsInterface || type.IsAbstract) ? false : true;
    }

    public void EmitLoad()
    {
        if (@ref)
        {
            il.EmitLdarga(i);
        }
        else
        {
            il.EmitLdarg(i);
        }
    }

    public void EmitStore()
    {
        il.EmitStarg(i);
    }
}

もう一つは、インスタンスの呼び出し/インスタンスフィールドの呼び出しができないこと(DynamicMethodはインスタンスが存在しませんからね!)。そこでフィールドキャッシュのLoadなどは、Actionで外から渡すようにして、両者が共通でない部分は外出しするようにしました。正直言って、手間だし、ややグチャグチャしてしまうところもあるのですが、やる価値はあります。SaveなしでIL手書きと戦うのは本当にキツいので……。

ILGeneratorことはじめ

基本、今まで見た通りEmitするだけなんですが、まだループや分岐に関しては説明していないですね!で、ILにはそれらへの気の利いた文法はありません。全部labelとgotoで実現するものと思いましょう。そして、ループや分岐が絡むと途端にIL書く気が失せます。というのも、複雑怪奇になるので。例えばこんな単純なループですら……

image

なんかもう嫌な感じでいっぱいです。ああ、ああ……。といっても書かなきゃいけない局面もいっぱいあるんで、書きましょう。

まず、forはないものと思って、この手のイメージコードを作る場合は全部gotoに直します。それがILに近くなるので。近いほうがイメージもしやすい。

	var i = 0;
	goto FOR_CONDITION;

FOR_BODY:
	if (i == 50) goto FOR_END;
FOR_CONTINUE: // 今回は使いませんが
	i += 1;
FOR_CONDITION:
	if (i < 100)
	{
		goto FOR_BODY;
	}
FOR_END:
	Console.WriteLine("End");

なるほど古き良きgoto。既に帰りたい感じですが、更にこれをEmitに直します。まぁ基本はLINQPadのコピペなのですが、LabelのDefineが必要です!

const string ModuleName = "FooBar";
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(ModuleName), AssemblyBuilderAccess.RunAndSave);
var moduleBuilder = assemblyBuilder.DefineDynamicModule(ModuleName, ModuleName + ".dll");
var typeBuilder = moduleBuilder.DefineType("Foo", TypeAttributes.Public);

var methodBuilder = typeBuilder.DefineMethod("For", MethodAttributes.Public, null, Type.EmptyTypes);

// -- ここから --
ILGenerator il = methodBuilder.GetILGenerator();

// gotoの行き先をあらかじめDefineLabelで持つ
var forBodyLabel = il.DefineLabel();
var forContinueLabel = il.DefineLabel();
var forConditionLabel = il.DefineLabel();
var forEndLabel = il.DefineLabel();

// ローカル変数を宣言する
var iLocal = il.DeclareLocal(typeof(int));

il.Emit(OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Stloc, iLocal); // i = 0;
il.Emit(OpCodes.Br, forConditionLabel); // goto FOR_CONDITION;

// MarkLabelでラベルの位置を確定させる
il.MarkLabel(forBodyLabel); // FOR_BODY:
il.Emit(OpCodes.Ldloc, iLocal);
il.Emit(OpCodes.Ldc_I4, 50);
il.Emit(OpCodes.Beq, forEndLabel); // if(i == 50) goto FOR_END;

il.MarkLabel(forContinueLabel);
il.Emit(OpCodes.Ldloc, iLocal);
il.Emit(OpCodes.Ldc_I4_1);
il.Emit(OpCodes.Add);
il.Emit(OpCodes.Stloc, iLocal);  // i += 1;
            
il.MarkLabel(forConditionLabel); // FOR_CONDTION:
il.Emit(OpCodes.Ldloc, iLocal);
il.Emit(OpCodes.Ldc_I4, 100);
il.Emit(OpCodes.Blt, forBodyLabel); // if(i < 100) goto FOR_BODY;

il.MarkLabel(forEndLabel); // FOR_END:
il.EmitWriteLine("End"); // Stfld, Call WriteLine

il.Emit(OpCodes.Ret);
// -- ここまで --

var t = typeBuilder.CreateType();
dynamic instance = Activator.CreateInstance(t);

try
{
    instance.For(); // 実行確認
}
finally
{
    assemblyBuilder.Save(ModuleName + ".dll");
    Verify(assemblyBuilder);
}

DefineLabelで予め宣言する、MarkLabelでラベル位置を決める、分岐系OpCodeでLabelを指定する。ということになります。まぁ、全部gotoなんだって思えば別になんてことない話ではあるんですが、だいぶ見辛くなりました。ただの、ほぼ空のfor文ですら!また、分岐はBeq_SなどがLINQPadなどの解析結果に出ると思うのですが、これはジャンプ先が近ければ_Sが使えて、遠ければ実行時エラーになります。埋め込み量がわかっている場合は_Sでいいんですが、動的生成の都合上、長さわからない場合っていうのも少なくなかったりするので、安全側に倒すなら、とりあえず_Sナシでやるってのは手だと思っています。ちょっとね、怖いんですよね。

ちなみに私はこれを書き写すにあたって、二回ミスってPEVerifyのお世話になりました(笑)。ちょっと長くなったり分岐入ると、やっぱミスってしまうんですよねぇ。で、これ、PEVerifyなしで探れって言われると、たかだかfor文一つだけでしかなくても、めっちゃ辛いわけです。実際の生成コードだとこれの比じゃなく長くなりますから、いやはや、大変な話です……。

キャッシュの手法

生成したコードは再利用するためにどこかに保持する必要があります。ああ、Dictionaryの出番だね。その通りですが、その通りではありません。Dictionaryのルックアップコストはタダではない!GetHashCodeとEqualsを呼び出すわけですが、例えばStringがキーなら、GetHashCodeで一回全舐めして、Equalsでやはり全舐めするわけです。おお……(もちろん、文字列の長さが長ければ長いほどコストは嵩む)。とはいえ、通常はTypeをキーにすると思うので、ルックアップのコストはそこまで高くはないので、構わないっちゃあ構わないでしょう。

が、もしTypeなら、ジェネリクスを有効に使うと、より高速なルックアップが可能です。MessagePack for C#やUtf8JsonではResolverという形で、生成した型をキャッシュ/取得する機構を全面採用しています。

internal sealed class DynamicObjectResolverAllowPrivateFalseExcludeNullFalseNameMutateOriginal : IJsonFormatterResolver
{
    public static readonly IJsonFormatterResolver Instance = new DynamicObjectResolverAllowPrivateFalseExcludeNullFalseNameMutateOriginal();
    
    static readonly Func<string, string> nameMutator = StringMutator.Original;
    static readonly bool excludeNull = false;
    const string ModuleName = "Utf8Json.Resolvers.DynamicObjectResolverAllowPrivateFalseExcludeNullFalseNameMutateOriginal";

    static readonly DynamicAssembly assembly;

    static DynamicObjectResolverAllowPrivateFalseExcludeNullFalseNameMutateOriginal()
    {
        assembly = new DynamicAssembly(ModuleName);
    }

    DynamicObjectResolverAllowPrivateFalseExcludeNullFalseNameMutateOriginal()
    {
    }

    // DynamicObjectResolverAllowPrivateFalseExcludeNullFalseNameMutateOriginal.Instance.GetFormatter<T>で取得する
    public IJsonFormatter<T> GetFormatter<T>()
    {
        // 中身は型キャッシュのフィールドを取りに行くだけ
        return FormatterCache<T>.formatter;
    }

    // 型キャッシュ
    static class FormatterCache<T>
    {
        public static readonly IJsonFormatter<T> formatter;

        // 静的コンストラクタはスレッドセーフが保証される
        static FormatterCache()
        {
            // ここでILのEmitしてIJsonFormatter<T>を一度だけ生成している
            formatter = (IJsonFormatter<T>)DynamicObjectTypeBuilder.BuildFormatterToAssembly<T>(assembly, Instance, nameMutator, excludeNull);
        }
    }
}

難点はアンロードできないことと、動的に生成しづらい(できないわけではない, ただしそれで生成した型もアンロード不可能)になりますが、大抵この手のライブラリの生成データはアプリケーションの生存期間でずっと生き続けるので、あまり問題にはならないでしょう。

その他Tips

C#コンパイラがコード生成するもの(yield returnやawaitなど)をIL生成でやるのは、無理です。が、そういうのが必要なのだという場合は、ヘルパーメソッドを作ってあげて、それを呼ぶ形にしてあの手この手でIL手書き部分を減らしてあげましょう。

unsafeをIL手書きで書くのは地獄の一里塚です。しかし、やらなければならない時はあります(実際MessagePack for C#やUtf8Jsonはunsafeが含まれてる)。そして、何気にfixedのコードもまた、コンパイラ生成だったりします。LINQPadで見てみましょう。

image

fixed(byte* p = xs) のコードは生成量が多くてうげー、って感じなので、基本 fixed(byte* p = &xs[0]) のほうでいいでしょう(nullチェック?それは外側でしましょ)。若干ややこしいですが、こんな感じで。

// DeclareLocalの際にpinned: trueを指定する
var p = il.DeclareLocal(typeof(byte).MakePointerType(), pinned: true); // byte*

// begin fixed定型文
il.Emit(OpCodes.Ldarg_1); // staticメソッドじゃないので1で。
il.Emit(OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Ldelema, typeof(byte));
il.Emit(OpCodes.Stloc, p); // byte* p = &xs[0];

// -- ここに好きにBodyをどうぞ--

// end fixed定型文
il.Emit(OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Conv_U);
il.Emit(OpCodes.Stloc, p);

il.Emit(OpCodes.Ret);

このfixed含みのコードをPEVerifyにかけると

[IL]: エラー:[Foo::For][オフセット 0x00000007][address of Byte が見つかりました][unmanaged pointerS が必要です] スタックに予期しない型があります。
[IL]: エラー:[Foo::For][オフセット 0x0000000A][Native Int が見つかりました][unmanaged pointerS が必要です] スタックに予期しない型があります。

という2つのエラーメッセージが必ず出てしまいますが、これはもうそういうものだと思うことにしましょう、しょうがない……。

ニッチトピックスとしてはGeneric型の生成は、結構大変です。いや、大変でもないんですが、そのジェネリックとしてのTを使って、別の型で生成するのがむつかしいのです。IntelliSenseから出てこないし普通に書いてると辿りつけないんですが、TypeBuilder.GetMethod経由だとDefineGenericParametersとMakeGenericTypeからMethodInfoが取れる。って、何言ってるのか全く意味不明と思うんですが、いつか誰かがはまった時のヒントとして残しておきます。もしジェネリック型を生成して、なにかよくわからないけれど、どうにもならないことがあったら、思い出してください。はい。

まとめ

とにかくツールの使いこなしが全てです。徒手空拳でILGeneratorと戦うのは、そりゃあ大変な努力が必要ですが、きっちりとツールを使っていけば、超絶難易度の黒魔術、というほどではなく、まぁまぁ常識的な範囲に収まります。書くだけなら。読み解くのはやっぱ一苦労だし、人の書いたのを読めるかって言ったら、まぁ読めないんですが(自分の書いたのだって数日置いたら読めないぞ!)、その辺はアセンブラなんでしょうがないね。読みの難易度と書きの難易度は非対称だし、読みに比べると、書きのほうがずっと楽、ということです(なんせカンニングコピペというテクが使えますからね)。

というわけで、あまり恐れずに、自分の中のツールセットとして持っておくと、なんらかのフレームワーク的なレイヤーを作る際にやれることが大きく広がるんじゃないかと思います。

とはいえ、別に無闇に使うのはお薦めしません!必要ないところでは必要ないのままでいいし、場合によってはベタなリフレクションで構わない場合も多いでしょう。そこの辺の選択は冷静にやったほうがいいですね、麻疹にかかるのも大事ですが、IL書きは割と冗談じゃなく本人以外メンテ不能になるので。

さて、そんなわけで明日のAdvent Calendardは既に書いていただいているのですが@NumAniCloudさんのC#で実装!RPGのパッシブ効果の作り方を通じたオブジェクト指向のノウハウです。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive