d.sunnyone.org
sunnyone.org

ページ

2014-05-11

MSILでわかるC# のラムダ式

そういえばLINQ to Objectsでコードを書いたときに裏でどういう動きになっているのかなぁ、とILでイメージができなかったので読んでみたら、単にC#のラムダ式がどうなっているかという話だけだったのでまとめる。

長いのでサマリ:
  • ラムダ式を使うと、基本的に入れ子クラスが作られ、ラムダ式が使う変数をとっておかれる。
  • ラムダ式の中身に書いたものは、基本的に入れ子クラスのメソッドとして定義される。
  • 入れ子クラスは不要なときには作られない。

評価スタックでピンとこない方は「C#でHelloWorldプログラムを作成する」を読んでおくのをおすすめする。

今回の説明用のサンプルコードはこちら。「.Count(x => x == val)」がどうなっていくのか、というお話。
namespace ConsoleApplicationLinq
{
    class Program
    {
        static void Main(string[] args)
        {
            int val = 100;
            var format = "Count of {0}: {1}";
            
            var array = new int[0];
            var count = array.Count(x => x == val);

            System.Console.WriteLine(format, val, count);
        }
    }
}

サイズ0のint配列に100がいくつあるか調べてWriteするというコード(0に決まっている)。説明の都合、順番などが不自然な感じになっている。

このソースをILに逆アセンブルした結果がこちら。
https://gist.github.com/sunnyone/0d3ed8285b5d91495399
別ウィンドウで開きながら説明を見るといいかも。

さて、これからILを見ていく。書いた部分がコンパイルされているであろうMainメソッドの実装を見ようとすると、そのMainメソッドの前に見知らぬ「<>c__DisplayClass1」という入れ子クラスが作られていることがわかる。

そのクラスの内容はこうなっている。

.class auto ansi sealed nested private beforefieldinit '<>c__DisplayClass1'
         extends [mscorlib]System.Object
  {
    (CompilerGeneratedAttributeの部分は省略)
    
    .field public int32 val // valフィールド
    
    (ここにコンストラクタの定義があるが省略)
    
    // <Main>b__0メソッドの定義
    .method public hidebysig instance bool 
            '<Main>b__0'(int32 x) cil managed
    {
      .maxstack  8
      
      // xをロード(評価スタックにpush)
      IL_0000:  ldarg.1
      
      // valフィールドをロード
      IL_0001:  ldarg.0
      IL_0002:  ldfld      int32 ConsoleApplicationLinq.Program/'<>c__DisplayClass1'::val
      
      // 二つの値=xとvalの値を比較し、同じなら1・異なるときは0
      IL_0007:  ceq
      
      // return
      IL_0009:  ret
    } // end of method '<>c__DisplayClass1'::'<Main>b__0'

  } // end of class '<>c__DisplayClass1'

このクラスは要は「x => x == val」の部分を<Main>b__0というメソッドに実装し、加えてvalフィールドを持っている。

次に本体のMainメソッド。Mainメソッドはこの入れ子クラスを活用して動作する。

まずメソッドとローカル変数の定義。format, array, countのほかに、先ほどの<>c__DisplayClass1が「CS$<>8__locals2」として用意されているのがわかる。valはないことに注意。
.method private hidebysig static void  Main(string[] args) cil managed
  {
    .entrypoint
    .maxstack  3

    .locals init ([0] string format,
             [1] int32[] 'array',
             [2] int32 count,
             [3] class ConsoleApplicationLinq.Program/'<>c__DisplayClass1' 'CS$<>8__locals2')

ここから処理開始だが、記述したC#コードに対応する部分に先立って、<>c__DisplayClass1がnewされ、ローカル変数に入る。
// <>c__DisplayClass1クラスのインスタンスを生成してローカル変数3番目にセット
    IL_0000:  newobj     instance void ConsoleApplicationLinq.Program/'<>c__DisplayClass1'::.ctor()
    IL_0005:  stloc.3

次に「int val = 100;」の部分。ここがポイント。
C#コード上ではローカル変数に見えているが、実際には内部の<>c__DisplayClass1クラスのフィールドになっている。
入れ子になったクラスにあるラムダ式の実体がこの変数を使うためにフィールドに入れている。
// CS$<>8__locals2と「100」をロードして、CS$<>8__locals2のvalにセット
    IL_0006:  ldloc.3
    IL_0007:  ldc.i4.s   100 
    IL_0009:  stfld      int32 ConsoleApplicationLinq.Program/'<>c__DisplayClass1'::val

次に「var format = "Count of {0}: {1}";」の部分。このように、ラムダ式と関係ない部分はふつうのローカル変数になる。
// 説明省略
    IL_000e:  ldstr      "Count of {0}: {1}"
    IL_0013:  stloc.0

次に「var array = new int[0];」だが、ここもローカル変数に入れるだけ。
// 説明省略
    IL_0014:  ldc.i4.0
    IL_0015:  newarr     [mscorlib]System.Int32
    IL_001a:  stloc.1

次に実際のラムダ式が登場する「var count = array.Count(x => x == val);」の部分。
先ほどvalのために生成したDisplayClassのメソッドを使ってFuncオブジェクトを生成し、Countメソッドに渡している。

// ローカル変数1番目:arrayをロード
    IL_001b:  ldloc.1
    
    // <Main>b__0メソッドのポインタをロード
    IL_001c:  ldloc.3
    IL_001d:  ldftn      instance bool ConsoleApplicationLinq.Program/'<>c__DisplayClass1'::'<Main>b__0'(int32)
    
    // <Main>b__0メソッドのポインタを使って、Funcオブジェクトを生成
    IL_0023:  newobj     instance void class [mscorlib]System.Func`2::.ctor(object,
                                                                                        native int)
                                                                                        
    // System.Linq.Enumerable::CountメソッドにarrayとFuncオブジェクトを渡す
    IL_0028:  call       int32 [System.Core]System.Linq.Enumerable::Count(class [mscorlib]System.Collections.Generic.IEnumerable`1,
                                                                                 class [mscorlib]System.Func`2)

    // ローカル変数2番目にセット
    IL_002d:  stloc.2

最後に「System.Console.WriteLine(format, val, count);」の部分。
ポイントはvalを使うのにラムダ式用の<>c__DisplayClass1を使っているところ。
// ローカル変数0番目: formatをロード
    IL_002e:  ldloc.0
    
    // ローカル変数3番目: CS$<>8__locals2のvalフィールドをロード、intなのでboxingする
    IL_002f:  ldloc.3
    IL_0030:  ldfld      int32 ConsoleApplicationLinq.Program/'<>c__DisplayClass1'::val
    IL_0035:  box        [mscorlib]System.Int32
    
    // ローカル変数2番目: countをロード, boxing
    IL_003a:  ldloc.2
    IL_003b:  box        [mscorlib]System.Int32
    
    // WriteLine
    IL_0040:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                  object,
                                                                  object)
    IL_0045:  ret
  } // end of method Program::Main

このように、ラムダ式は、必要な変数を入れ子クラスのインスタンスにとっておいて、ラムダ式に書いた内容のメソッドが実行される、という形で実装されている。
しかし、ラムダ式があれば必ず入れ子クラスが作られるかというとそうではなく、変数をとっておく必要がない場合、違う形にコンパイルされる。

たとえば、先のコードのint val = 100;にconstをつけてconst int val = 100;にするだけで、ILはこうなってしまう。

.class private auto ansi beforefieldinit ConsoleApplicationLinq.Program
       extends [mscorlib]System.Object
{
  // ProgramクラスそのものにFuncのフィールドが用意される
  .field private static class [mscorlib]System.Func`2<int32,bool> 'CS$<>9__CachedAnonymousMethodDelegate1'
  
  (CompilerGeneratedAttributeの部分は省略)
  
  .method private hidebysig static void  Main(string[] args) cil managed
  {
    // ローカル変数の定義などなど
    .entrypoint
    .maxstack  3
    .locals init ([0] string format,
             [1] int32[] 'array',
             [2] int32 count)
    IL_0000:  ldstr      "Count of {0}: {1}"
    IL_0005:  stloc.0
    IL_0006:  ldc.i4.0
    IL_0007:  newarr     [mscorlib]System.Int32
    IL_000c:  stloc.1
    IL_000d:  ldloc.1
    
    // CS$<>9__CachedAnonymousMethodDelegate1をロードして、存在すればこの先の処理まで飛ばす
    IL_000e:  ldsfld     class [mscorlib]System.Func`2 ConsoleApplicationLinq.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
    IL_0013:  brtrue.s   IL_0026

    // なければProgramクラスに定義された<Main>b__0メソッド(「x => x == val」の実装)を使ってFuncオブジェクトを作る
    IL_0015:  ldnull
    IL_0016:  ldftn      bool ConsoleApplicationLinq.Program::'<Main>b__0'(int32)
    IL_001c:  newobj     instance void class [mscorlib]System.Func`2::.ctor(object,
                                                                                        native int)
    // 作ったらフィールドに格納して、ロードしておく
    IL_0021:  stsfld     class [mscorlib]System.Func`2 ConsoleApplicationLinq.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
    IL_0026:  ldsfld     class [mscorlib]System.Func`2 ConsoleApplicationLinq.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
    
    // 以下同じ
    IL_002b:  call       int32 [System.Core]System.Linq.Enumerable::Count(class [mscorlib]System.Collections.Generic.IEnumerable`1,
                                                                                 class [mscorlib]System.Func`2)
    IL_0030:  stloc.2
    IL_0031:  ldloc.0
    IL_0032:  ldc.i4.s   100
    IL_0034:  box        [mscorlib]System.Int32
    IL_0039:  ldloc.2
    IL_003a:  box        [mscorlib]System.Int32
    IL_003f:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                  object,
                                                                  object)
    IL_0044:  ret
  } // end of method Program::Main

  (コンストラクタの定義は省略)
  
  .method private hidebysig static bool  '<Main>b__0'(int32 x) cil managed
  {
    (CompilerGeneratedAttributeの部分は省略)
    
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  ldc.i4.s   100 // 「100」はここに埋め込まれるので、フィールドから取る必要がない
    IL_0003:  ceq
    IL_0005:  ret
  } // end of method Program::'<Main>b__0'

} // end of class ConsoleApplicationLinq.Program

こっちのコードだとご丁寧にもFuncオブジェクトをstaticフィールドにキャッシュしている。

ちなみに、valをローカル変数ではなくフィールドに持つようなクラスを作った場合も上述のconstの形に近くなり、入れ子クラスは作られない(Funcオブジェクトのキャッシュはしなくなる)。

まぁ、この差異はプログラム全体からしたらたいしたことはないと思うので、書くときに意識することはないと思うが、知っておいても悪くない…かな?
(一応、array.Count(x => x == val)を10000000回実行したら、valがconst/フィールド/ローカル変数それぞれの場合でStopwatchクラス読みで240ms/240ms/300msだった)

こうしてILコードを見ていると、ラムダ式がどういうものなのかしっくりきたのだけど、みなさまはどうだろうか?