JIT コンパイラの Dead Code Elimination を利用して,同一の CIL から複数の実装を生成する
値型かもしれない変数をnullと比較する
『プログラミングMicrosoft .NET Framework 第2版』で紹介されているように,以下のコードは合法.
ジェネリックの型引数をnullと比較する
ジェネリックの型が制約されていなくても、ジェネリックの型引数を、==演算子または!=演算子を使ってnullと比較することができます。
private static void ComparingAGenericTypeVariableWithNull<T>(T obj) { if (obj == null ) { /* 値型では絶対に実行されない */ } }Tには制約がないので、参照型にも値型にもなりえます。Tが値型であった場合、objはnullにはなりません。この場合、C#コンパイラがエラーを出力すると思うでしょう。しかし、C#コンパイラはエラーを出力しません。このコードは問題なくコンパイルされます。このメソッドが、型引数に値型を指定されて呼び出されると、JITコンパイラはifステートメントがtrueにならないことを検出し、ifのテストと中かっこ({})の中のコードに対応するネイティブコードを生成しません。!=を使っていた場合には、JITコンパイラはifのテストを出力せず(常にtrueなので)に、ifの中かっこの中のコードを生成します。
ちなみに、Tがstructに制約されていた場合、値が他の変数とnullを比較するコードを記述すべきでない(常に同じ結果になる)ので、C#コンパイラはエラーを出力します。
じゃあこれを使うと,同一の中間コード (CIL) から,複数の実装を実行時生成できるんじゃなかろうか,と思って実験してみたらうまく行ったという話が以下.
想定シナリオ
例えば次のような C# コードを眺めつつ,ソースコード上ではなるべく実装を共有したい,しかし処理効率の関係から分岐は使いたくない,という場合を考えてみる.
public static void FuncX() { InitX(); FooBar(); CleanUpX(); } public static void FuncY() { InitY(); FooBar(); CleanUpY(); }
これを書き換える.
public static void Func<T>() { if (default(T) == null) { InitX(); } else { InitY(); } FooBar(); if (default(T) == null) { CleanUpX(); } else { CleanUpY(); } }
このコードは,確かに単一の CIL にコンパイルされる.
T が値型か参照型かは呼び出し側が決める.それぞれのケースについて,JIT コンパイラはネイティブコードを生成する*1.
このとき JIT コンパイラの Dead Code Elimination も個別に働く.その結果,T が参照型の場合は InitX と CleanUpX の呼び出しのみが残り,T が値型のときは InitY と CleanUpY の呼び出しのみが残る.結果として 2 つの実装が生成された.というわけだ.
実験
別の例で実験してみよう.
public static void Foo<T>(int[] array) { foreach (var i in array) { if(default(T) == null ? array[i] > 3 : array[i] < 3) { Console.WriteLine("BANG!"); } } }
このコードを Release ビルドし,JIT コンパイラが生成した x86 コードを見てみる.T = object の場合と T = int の場合で実験した.環境は以下の通り.
- Windows Vista Ultimate Edition (x86)
- .NET Framework 3.5 SP1 beta
- Intel T2500 (Core Duo) @ 2.00GHz
================== Foo<object> の場合 ================== public static void Foo<T>(int[] array) { foreach (var i in array) 00000000 push ebp 00000001 mov ebp,esp 00000003 push edi 00000004 push esi 00000005 mov esi,ecx 00000007 xor edi,edi 00000009 cmp dword ptr [esi+4],0 0000000d jle 00000042 0000000f mov edx,dword ptr [esi+edi*4+8] { if(default(T) == null ? array[i] > 3 : array[i] < 3) 00000013 cmp edx,dword ptr [esi+4] 00000016 jae 00000046 00000018 cmp dword ptr [esi+edx*4+8],3 0000001d setg al 00000020 movzx eax,al 00000023 test eax,eax 00000025 je 0000003C { Console.WriteLine("BANG!"); 00000027 call 698F59D8 0000002c mov ecx,eax 0000002e mov edx,dword ptr ds:[02BE2030h] 00000034 mov eax,dword ptr [ecx] 00000036 call dword ptr [eax+000000D8h] 0000003c inc edi foreach (var i in array) 0000003d cmp dword ptr [esi+4],edi 00000040 jg 0000000F 00000042 pop esi } } } 00000043 pop edi 00000044 pop ebp 00000045 ret 00000046 call 6A89493A 0000004b int 3
================== Foo<int> の場合 ================== public static void Foo<T>(int[] array) { foreach (var i in array) 00000000 push ebp 00000001 mov ebp,esp 00000003 push edi 00000004 push esi 00000005 mov esi,ecx 00000007 xor edi,edi 00000009 cmp dword ptr [esi+4],0 0000000d jle 00000042 0000000f mov edx,dword ptr [esi+edi*4+8] { if(default(T) == null ? array[i] > 3 : array[i] < 3) 00000013 cmp edx,dword ptr [esi+4] 00000016 jae 00000046 00000018 cmp dword ptr [esi+edx*4+8],3 0000001d setl al 00000020 movzx eax,al 00000023 test eax,eax 00000025 je 0000003C { Console.WriteLine("BANG!"); 00000027 call 698F5978 0000002c mov ecx,eax 0000002e mov edx,dword ptr ds:[02BE2030h] 00000034 mov eax,dword ptr [ecx] 00000036 call dword ptr [eax+000000D8h] 0000003c inc edi foreach (var i in array) 0000003d cmp dword ptr [esi+4],edi 00000040 jg 0000000F 00000042 pop esi } } } 00000043 pop edi 00000044 pop ebp 00000045 ret 00000046 call 6A8948DA 0000004b int 3
ほとんど同じコードだが,よく見れば 0000001d 番地の命令が異なっている.
問題点
残念ながら,この方式は万全という訳でもない.例えば,読みやすさを改善しようとすると Dead Code Elimination ができなくなる,という問題に出くわした.
まず,コードを読みやすくするために,以下のような型とユーティリティメソッドを定義した.
public interface IMetaBool<T> { } public sealed class True : IMetaBool<True> { } public struct False : IMetaBool<False> { } public static class Util { public static bool ToBoolean<T>() where T:IMetaBool<T> { return default(T) == null; } }
これで,コードの読みやすさは改善できる.が……
public static void Foo<CheckHigher>(int[] array) where CheckHigher : IMetaBool<CheckHigher> { foreach (var i in array) { if (Util.ToBoolean<CheckHigher>() ? array[i] > 3 : array[i] < 3) { Console.WriteLine("BANG!"); } } } static void Main(string[] args) { int[] array = new[] { 0, 1, 2 }; Foo<True>(array); Foo<False>(array); }
試してみると Util.ToBoolean<CheckHigher>() のインライン展開が行われないことに気付く.Util.ToBoolean<CheckHigher>() の呼び出しが残るため,if 文も除去されない.これでは,引数でフラグを指定するのと変わらない.
Util.ToBoolean<CheckHigher>() のインライン展開が行われない理由はよく分からない.where 制約を外して実験してみたがダメだった.
もうちょっとがんばれば,Generative CLR Programming とかできそうなんだが.調査はケイゾク.
*1:参照型同士であれば異なる型でもコード共有される.