C#のDelegateをILから理解する
C#のSystem.Delegate
の挙動について気になることがあったので、Delegateを利用したプログラムのILを調べてみました。
ラムダ式で変数をキャプチャしたときの挙動など面白いところがあったのでポストとして残しておきます。
環境は.NET 8です。
※ ILの確認には有償のLinqpad 8を利用しています。
※ 手軽にILを参照したい方はildasm、ILSpy、SharpLab等の利用をお勧めします。
Delegateとは
Delegateとは関数を表す型です。
以下の様に記述ができる。
public partial class Program {
static void Main() {
// fnにラムダ式を代入
var fn = (int value) => value * 2;
// 200を出力
Console.WriteLine(fn(100));
}
}
上記のラムダ式はRoslynによってFunc<int, int>
デリゲートとして解釈されます。
FuncやAction等のデリゲートは抽象クラスSystem.Delegateを継承しており実行時はクラスとして実行されます。
つまり、ILではnewobj
命令を利用してFuncクラスのインスタンスを作成する命令が出力されるということですね。
1フレームで大量の処理を実行する必要があるゲームや、大量のデータをループで処理するジョブ等ではパフォーマンスに影響を与えそうです。
※ 後述しますがインスタンスをキャッシュをすることでアロケーション・GCのコストを削減しています。
※ 逆にインスタンスが大量に生成されるパターンもあります。これも後述します。
実際にILを見てみます。
Program.Main
のIL_0014
を見ると、Func<int, int>
のインスタンスを作成しているのが分かります。
そして、IL_0021
の位置でcallvirt
命令でInvoke
メソッドを呼び出し、その結果がスタックされConsole.WriteLine
に渡されています。
ここまでは予想の範疇ですが、<>c
というクラスがコンパイラによって生成されています。
これは何かというと、ラムダ式で記述した処理と、生成したデリゲートのインスタンスをキャッシュする静的フィールドを持つクラスです。
再度ILを見てみましょう。
IL_000E
のldftn
命令で、<>c
インスタンスの<Main>b__0_0
メソッドをスタックに積み、IL_0014
のnewobj
命令でFunc<int, int>..ctor
に渡されています。
こうすることでラムダ式で記述した部分の処理がFuncに渡されます。
ここからが面白いところで、IL0019~IL_001A
を見ると分かるが、<>c.<>9__0_0
にFuncのインスタンスをキャッシュしていることが分かる。
これによってアロケーション・GCのコストを削減しているのですね。IL_0000 ~ IL_0006
でキャッシュの有無を確認し、キャッシュが存在する場合はbrtrue.s
命令でインスタンス生成の手順をスキップしています。
変数をキャプチャしてみる
先ほどのプログラムを変更し、以下の様にしてみる。
public partial class Program {
static void Main() {
var mul = 100;
// 変数mulをキャプチャ
var fn = (int value) => value * mul;
Console.WriteLine(fn(100));
}
}
ILは以下の様になる。
コ_ンパイラによって生成されているのが、<>c__DisplayClass0_0
となっているのが分かる。IL_0006 ~ IL_0008
を見ると、変数mulの値が、<>c__DisplayClass0_0.mul
に代入されているのが分かる。
要するにクラスにキャプチャ対象を代入するためのフィールドを用意することで、変数のキャプチャを行っているのですね。
また、キャプチャを行った場合、デリゲートのインスタンスをキャッシュする処理が消えています。
当然と言えば当然ですが、注意が必要ですね。
メソッドを代入してみる
今度はデリゲートに静的メソッドを代入してみます。
以下の様にプログラムを変更します。
public partial class Program {
static void Main() {
var fn = Multiply;
Console.WriteLine(fn(100));
}
static int Multiply(int value) {
return value * 2;
}
}
ILは以下の様になる。
キャッシュロジックが生成されていることが分かります。
パフォーマンスを気にせず使えそうです。
次はSample
クラスを作成し、インスタンスメソッドを代入してみました。
public class Sample {
public void Main() {
var a = Method;
Console.WriteLine(a(100));
}
public int Method(int value) {
return value * 2;
}
}
ILを見てみると...
今度はキャッシュロジックが生成されていませんね。
インスタンスメソッドをキャッシュするわけにはいかないので当然ですね。
アロケーション・GCのコストを削減したい場合は静的メソッドを利用するのがいいかもしれません。
マルチキャストデリゲート
Func
やAction
、ユーザー定義のデリゲートはSystem.MulticastDelegate
を継承しています。
マルチキャストデリゲートは以下のソースの様に+=演算子を利用することで複数のメソッドを登録することができます。
public partial class Program {
static void Main() {
// System.Actionとして解釈される。
var a = () => Console.WriteLine(1);
// +=演算子で複数の関数を代入できる。
a += () => Console.WriteLine(2);
}
}
System.MulticastDelegateのソースを見るとわかるのですが+=演算子をオーバーロードしていません。
では代わりにどういった処理になっているかILを確認してみます。
IL_003E ~ IL_0043
を見ると、+=はDelegate.Combine
メソッドに変換されています。
このメソッドを呼び出し新しいデリゲートをスタックし、それをActionにキャストして呼び出していることが分かります。
まとめ
ILを見るとコンパイラがかなり頑張ってくれていることが分かります。
通常デリゲートを利用するうえでパフォーマンスをそこまで気にする必要は無いと思いますが、アロケーション・GCのコストは馬鹿にならない場合もあります。
自分の周りではILを確認しているエンジニアは見かけませんが、パフォーマンスを突き詰める場合、やはりこの辺の理解が必要になると感じました。