「ILのキホン」と言う題目で、Center CLR年末会で登壇してきました。
Center CLRも第五回で、毎回参加の方と新しく参加の方で程よく盛り上がってうれしい感じです。ChalkTalkもそうですが、遠方からわざわざ参加して頂いた方もありがとうございます。来年も継続して行きたいと思います。
さて、今回のネタは結構前から決めていたものの、年末の忙しさの為に十分な時間が取れず、直前に方針を決めてバタバタしてしまいました。猛省…
と言う事だけではないんですが、ちょっとセッションの進行を変えてみました。
Intermediate Languageのキホン
ILの事については、第一回のChalkTalk CLRで「ChalkTalk CLR – 動的コード生成技術(式木・IL等)」と題してディスカッションを行ったのですが、今回は本会でその展開をしようと思ったのです。
しかし、ただILを説明したのでは、実際にILを使う具体的なシチュエーションでも提起できない限り、右から左へ流れていってしまうだけだと考えて、ハンズオンのような形式で進行してみました。
Emitが可能なコードを一から書いていると、なかなか本題にたどり着けないため、GitHubに元ネタとなるコードを用意しておき、Emitコードから書き始めれられるようにしました。
/// <summary>
/// コードをILで動的に生成するヘルパークラスです。
/// </summary>
internal sealed class Emitter : IDisposable
{
private readonly AssemblyBuilder assemblyBuilder_;
private readonly ModuleBuilder moduleBuilder_;
/// <summary>
/// コンストラクタです。
/// </summary>
/// <param name="name">アセンブリ名</param>
public Emitter(string name)
{
var assemblyName = new AssemblyName(name);
#if NET45
assemblyBuilder_ = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,
AssemblyBuilderAccess.RunAndSave);
#else
assemblyBuilder_ = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,
AssemblyBuilderAccess.Run);
#endif
moduleBuilder_ = assemblyBuilder_.DefineDynamicModule(name + ".dll");
}
/// <summary>
/// Disposeメソッドです。
/// </summary>
public void Dispose()
{
#if NET45
// デバッグ用に出力
assemblyBuilder_.Save(moduleBuilder_.ScopeName);
#endif
}
/// <summary>
/// 引数と戻り値を指定可能なメソッドを定義します。
/// </summary>
/// <typeparam name="TArgument">引数の型</typeparam>
/// <typeparam name="TReturn">戻り値の型</typeparam>
/// <param name="typeName">クラス名</param>
/// <param name="methodName">メソッド名</param>
/// <param name="emitter">Emitを実行するデリゲート</param>
/// <returns>デリゲート</returns>
public Func<TArgument, TReturn> EmitMethod<TArgument, TReturn>(
string typeName,
string methodName,
Action<ILGenerator> emitter)
{
// クラス定義
var typeAttribute = TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Abstract;
var typeBuilder = moduleBuilder_.DefineType(
typeName,
typeAttribute,
typeof(object));
// メソッド定義
var methodAttribute = MethodAttributes.Public | MethodAttributes.Static;
var methodBuilder = typeBuilder.DefineMethod(
methodName,
methodAttribute,
typeof(TReturn),
new [] { typeof(TArgument) });
// ILGenerator
var ilGenerator = methodBuilder.GetILGenerator();
emitter(ilGenerator);
// 定義を完了して型を得る
var typeInfo = typeBuilder.CreateTypeInfo();
var type = typeInfo.AsType();
// メソッドを取得する
var bindingFlags = BindingFlags.Public | BindingFlags.Static;
var method = type.GetMethod(methodName, bindingFlags);
// デリゲートを生成する
return (Func<TArgument, TReturn>)method.CreateDelegate(
typeof(Func<TArgument, TReturn>));
}
}
public static class Program
{
public static void Main(string[] args)
{
// 動的アセンブリを生成
using (var emitter = new Emitter("TestAssembly"))
{
// メソッドを動的に生成
var func = emitter.EmitMethod<int, string>(
"TestNamespace.TestType",
"TestMethod",
ilGenerator =>
{
ilGenerator.Emit(OpCodes.Ldstr, "Hello IL coder!");
ilGenerator.Emit(OpCodes.Ret);
});
// 実行
var result = func(123);
// 結果
Console.WriteLine(result);
}
}
}
Emitterクラスは、System.Reflection.Emitの煩雑なコードを隠ぺいするために定義しています。課題の多くはMainメソッド内のラムダブロックのEmitメソッドをいじります。
# なお、このコードはnetcore5向けでも実行可能にしてあります。
課題はPart1~Part10まで用意し、はじめに少しだけ「スタックマシン」についての解説を行いました。
Part 1: 引数の値をToStringして返すようにする
GitHubのコードを軽く説明して、Emit本体のコードを課題に従って書き換えてもらいました。
テンプレートのコードは、”Hello IL coder!”と言う文字列を返すだけのものですが、引数で与えられたintの値を文字列に変換して返すのが課題です。
最初なので、こんなものだろうと思っていたのですが… 実はこれ、大変な罠が…
やるべきことは、スライド通り大きく3つあります。
引数の値はどうやって入手するか
ヒントに書いておいたのですが、「OpCodesクラス」を見ると、IL命令の一覧が確認できます。
そして、スタックに値を積む(プッシュ)するのは、「Ld~」で始まる命令であることも説明しました。OpCodesの一覧を見ていると、「Ldarg_0」命令が見つかります。これを使うと、引数の最初の値をスタックに積みます。「Ldarg」や「Ldarg_S」命令でもOKです。その場合は、Emitメソッドの引数にインデックス(0ですが)を指定します。
Int32.ToStringのMethodInfoはどうやって入手するか / staticメソッドの呼び出し
メソッドを呼び出すには、「Call」命令を使います。その時、Emitする引数に、呼び出すメソッドの「メソッド情報(MethodInfo)」が必要です。これには、リフレクションを使います。
// Int32のTypeクラスのインスタンスを得る
Type int32Type = typeof(System.Int32);
// Int32.ToStringメソッドのメソッド情報を得る
MethodInfo toStringMethod = int32Type.GetMethod("ToString", Type.EmptyTypes);
ToStringのオーバーロードは複数あるので、正しいメソッド情報を選択するために、Type.EmptyTypesフィールドを使って、「引数0」のオーバーロードを選択させています(このフィールドを使わなくても、0個の配列でもOKです)。
typeofを使うことに対して、一部の方がモヤモヤしていた(チートっぽい)ようです。厳密に0からInt32のTypeを取得したことにならないのではないかとの事で、確かにその通り。typeofを使うと、C#コンパイラがコンパイル時にInt32を解決しようとします。実行時に0から取得する事も不可能ではないのですが、その話をするとまた話がずれていくので、今は諦めてもらう事に。
# Call命令を使うかどうかの更なる議論がありますが、それは後のPartで…
「動かない!」
複数のチームから「動かない!」とか、「NullReferenceExceptionがスローされる!」とか騒ぎが…
ilGenerator.Emit(OpCodes.Ldarg_0);
ilGenerator.Emit(OpCodes.Call, toStringMethod);
ilGenerator.Emit(OpCodes.Ret);
どう見ても間違っていないように見える… そこで、「ILSpy」を使って、C#で書いた等価コードを逆アセンブルした所、引数をスタックに積む際に、「Ldarga」命令を使っていました。
やられた orz
Ldarga命令は、対象の値そのものではなく、その値を示す参照(ポインタ)をスタックに積みます。これは、対象の値が「値型(ValueType)」であり、Callでそのメソッドに遷移した先ではthis参照として扱われなければならないため、Ldarg命令ではなく、Ldarga命令を使う必要があったのです。
Int32の場合は値型と言っても、なぜ参照化しなければならないのか、ピンと来ないかもしれません。以下に構造体(構造体も値型)を使った例を示します:
// 構造体
public struct Hoge
{
public int A;
public int B;
public int C;
// 計算し、結果をCに代入する
public void Compute()
{
// 計算結果を代入するには、this(自分自身への参照)が必要
this.C = this.A + this.B;
}
}
// メソッドを呼び出して計算させる
Hoge hoge = new Hoge();
hoge.A = 1;
hoge.B = 2;
hoge.C = 123;
hoge.Compute();
// hoge.Cは、123か3か?
Console.WriteLine(hoge.C);
Computeメソッドはインスタンスメソッドなので、内部でthis参照が使えます。this参照を使った場合、そのインスタンスは、呼び出し元のhogeとなるはずです。Computeメソッドから呼び出し元が保持するインスタンスにアクセスさせるには、文字通り「参照」が必要なのです。従って、ここではLdarga命令を使って、インスタンスへの参照を渡す必要があります。勿論、対象が参照型(クラス)であれば、Ldarg命令で問題ありません。
# NullReferenceExceptionがスローされたのは、本当に偶然のようで、真の原因は分かりません。
全然足りない!!
そんなわけで、このPart1を30分ぐらいでやるつもりだったのが、これだけで時間使い切ってしまいました。値型と参照型の違いは、実際にはPart6ぐらいでやってもらう予定だったのが、こんな所でやる事になったのが敗因…
時間を延長して続けるかどうかという話もあったのですが、他セッションもあったため、次回に持ち越しとなりました。次回に同じ題目で再チャレンジします。ごめんなさい。スライドはその時まで正式公開はしません。次回も同様に参加する場合は、Part1の内容の復習をしておいてくだしあ。
詰め込みすぎは良くない
年末は… ダメですね。この記事も31日に書いてるし (;´Д`)
でも楽しかったです。来年もよろしくお願いします!