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 の場合で実験した.環境は以下の通り.

================== 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:参照型同士であれば異なる型でもコード共有される.