C#筆記 – 匿名類型與匿名方法

匿名類型
  • 一種自動聲明不可變的元組類型的語法

    • 元組類型:含有一組屬性的類型

  • var o1 = new { Name = "Kelvin", Age = 24 };
    • 編譯器自動創建類型名稱,而開發者無法得知,只能使用var接收

      • var:

        • //編譯器獲取初始化表達式在編譯時的類型,使變量也具有那種類型
          var varName = new MyType();
        • 限制

          • 只有局部變量可用var,靜態字段和實例字段都不可以使用

          • 變量在聲明的同時被初始化

          • 初始化表達式不是方法組,也不是匿名方法/Lambda表達式(如果不進行強制轉換)

            • //Invalid
              var starter = delegate() { Console.WriteLine(); };
              //Valid
              var starter = (SomeDelegate)delegate() { Console.WriteLine(); }
          • 初始化表達式不是null

          • 語句中只聲明了一個變量

          • 不包含正在聲明的變量

          • 變量類型是初始化表達式的編譯時類型

            • var args = Environment.GetArgs();
          • 在using, for, foreach語句的開頭部分中聲明的局部變量使用var也是合法的

        • 優缺點

          • 可讀性

            • 優點:減少代碼量

            • 缺點:無法直觀判斷類型 

    • 編譯器首先推斷每個表達式的類型,創建推斷類型的私有字段

      • //Name
        .field private initonly !'<Name>j__TPar' '<Name>i__Field'
        .custom instance void [System.Diagnostics.Debug]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Diagnostics.Debug]System.Diagnostics.DebuggerBrowsableState) = ( 01 00 00 00 00 00 00 00 )

        //Age
        .field private initonly !'<Age>j__TPar' '<Age>i__Field'
        .custom instance void [System.Diagnostics.Debug]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Diagnostics.Debug]System.Diagnostics.DebuggerBrowsableState) = ( 01 00 00 00 00 00 00 00 )
    • 然後為每個字段創建對應的唯讀屬性

    • 再創建構造器來接受所有表達式

    • 最後重寫Equals、GetHashCode和ToString方法

  • 如果定義了多個相同結構的匿名類型,編譯器也只會創建一個匿名類型定義,然後創建該類型的多個實例

    • 這使得以下操作成為了可能

      • var o1 = new { Name = "Kelvin", Age = 24 };
        var o2 = new { Name = "Catherine", Age = 24 };

        o1 = o2;

        var people = new[]
        {
           o1,
           new { Name = "Virgil", Age = 18 },
           new { Name = "Nero", Age = 18 },
           new { Name = "Dante", Age = 18 },
        };
  • 匿名類型經常與LINQ配合使用,也是var的主要使用場景

    • 限制:

      • 匿名類型的實例不能泄露到方法外部

  • 方法不能接受匿名類型的參數;也不可以返回匿名類型的返回值

  • 雖然可以把匿名類型視為Object;但是Object不能轉為匿名類型

    • 匿名類型在編譯時的名稱是未知的

  • 匿名類型的屬性都是可讀不可寫的,其值和類型已在用於創建實例的匿名對象初始化程序中指定(確定)

    • //隱式類型數組
      var family = new[]
      {
         //匿名類型
         new { Name = "Holly", Age = 36 },
         new { Name = "Jon", Age = 36 },
         new { Name = "Tom", Age = 9 },
         new { Name = "Robin", Age = 6 },
         new { Name = "William", Age = 6 }
      };
    • family中所有人都必須有相同的類型,否則編譯器無法推斷數組的類型

    • 在任何一個給定的程序集中,如果兩個匿名對象初始化程序包含相同數量的屬性,且具有相同的名稱和類型,以相同的順序出現,就被認為是同一個類型。

  • 匿名類型被聲明時,IL會自動創建一個類並包含到最終的程序集中,CLR把它們看做普通的類型

    • //匿名類實例化IL
      IL_0009: ldstr "Holly"
      IL_000e: ldc.i4.s 36
      IL_0010: newobj instance void class '<>f__AnonymousType0`2'<string,int32>::.ctor(!0,
      !1)
      IL_0015: stelem.ref
      IL_0016: dup
      IL_0017: ldc.i4.1
      IL_0018: ldstr "Jon"
      IL_001d: ldc.i4.s 36
      IL_001f: newobj instance void class '<>f__AnonymousType0`2'<string,int32>::.ctor(!0,
      !1)
      IL_0024: stelem.ref
      IL_0025: dup
      IL_0026: ldc.i4.2
      IL_0027: ldstr "Tom"
      IL_002c: ldc.i4.s 9
      IL_002e: newobj instance void class '<>f__AnonymousType0`2'<string,int32>::.ctor(!0,
      !1)
      IL_0033: stelem.ref
      IL_0034: dup
      IL_0035: ldc.i4.3
      IL_0036: ldstr "Robin"
      IL_003b: ldc.i4.6
      IL_003c: newobj instance void class '<>f__AnonymousType0`2'<string,int32>::.ctor(!0,
      !1)
      IL_0041: stelem.ref
      IL_0042: dup
      IL_0043: ldc.i4.4
      IL_0044: ldstr "William"
      IL_0049: ldc.i4.6
      IL_004a: newobj instance void class '<>f__AnonymousType0`2'<string,int32>::.ctor(!0,
      !1)
    • //匿名類內部IL
      .method public hidebysig specialname rtspecialname
      instance void .ctor(!'j__TPar' Name,
      !'j__TPar' Age) cil managed
      {
      .custom instance void [System.Diagnostics.Debug]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = ( 01 00 00 00 )
      // 程式碼大小 21 (0x15)
      .maxstack 8
      IL_0000: ldarg.0
      IL_0001: call instance void [System.Runtime]System.Object::.ctor()
      IL_0006: ldarg.0
      IL_0007: ldarg.1
      IL_0008: stfld !0 class '<>f__AnonymousType0`2'<!'j__TPar',!'j__TPar'>::'i__Field'
      IL_000d: ldarg.0
      IL_000e: ldarg.2
      IL_000f: stfld !1 class '<>f__AnonymousType0`2'<!'j__TPar',!'j__TPar'>::'i__Field'
      IL_0014: ret
      } // end of method '<>f__AnonymousType0`2'::.ctor
    • 匿名類包含了
      • 一個獲取所有初始值的構造函數。參數順序和它們在匿名對象初始化程序中的順序一樣,名稱類型也一樣

      • 公有的只讀屬性

        • 匿名類型是「不易變」的

      • 屬性的私有只讀字段

      • 重寫的Equals、GetHashCode、ToString

        • 當同一個匿名類型的兩個實例判斷相等性時,用屬性類型的Equals方法依次比較每個屬性值。

        • 生成HashCode也是依次為每個屬性值調用GetHashCode,并組合結果,返回一個複合的散列

匿名方法
  • 匿名方法允許你指定一個內聯委托實例的操作,作為創建委托實例表達式的一部分。

  • 語法:

    • 使用delegate關鍵字

    • delegate(參數)
      {
         代碼塊
      };
    • 匿名方法的結果是一個委托實例

      • 不同於一般委托實例,逆變性不適用於匿名方法,必須指定和委托類型完全匹配的參數類型

    • 在IL中,編譯器會在已知類(匿名方法所在的類)的內部生成一個方法,並使用創建委托實例時的操作,就像一個普通方法一樣。

      • 但這些方法的名稱是不友好(unspeakable)的,因此,它們只在IL中有效,在C#中則無效,因此無法在C#代碼中直接引用它們。

      • //C# 層
        static void Main(string[] args)
        {
           Action<string> printReverse = delegate (string text)
          {
               char[] chars = text.ToCharArray();
               Array.Reverse(chars);
               Console.WriteLine(new string(chars));
          }
           
           printReverse("Let It Out");
        }
      • //IL層
        //入口
        //Main : void(string[])
        //臨時創建一個類
        IL_000a: ldsfld class CSharpInDepth.Program/'<>c' CSharpInDepth.Program/'<>c'::'<>9'
        //在臨時類裡生成一個方法
        IL_000f: ldftn instance void CSharpInDepth.Program/'<>c'::'<Main>b__6_0'(string)
        IL_0015: newobj instance void class [System.Runtime]System.Action`1<string>::.ctor(object,
        native int)
        IL_001a: dup
        IL_001b: stsfld class [System.Runtime]System.Action`1<string> CSharpInDepth.Program/'<>c'::'<>9__6_0'
      • //IL層
        //被構造的方法
        //<>c
        //<Main>b__6_0 : void(string)
        .method assembly hidebysig instance void
        '<Main>b__6_0'(string text) cil managed
        {
        // 程式碼大小 28 (0x1c)
        .maxstack 1
        .locals init (char[] V_0)
        IL_0000: nop
        IL_0001: ldarg.1
        IL_0002: callvirt instance char[] [System.Runtime]System.String::ToCharArray()
        IL_0007: stloc.0
        IL_0008: ldloc.0
        IL_0009: call void [System.Runtime]System.Array::Reverse<char>(!!0[])
        IL_000e: nop
        IL_000f: ldloc.0
        IL_0010: newobj instance void [System.Runtime]System.String::.ctor(char[])
        IL_0015: call void [System.Console]System.Console::WriteLine(string)
        IL_001a: nop
        IL_001b: ret
        } // end of method '<>c'::'<Main>b__6_0'
  • 返回值

    • 有的委托是可以有返回值的,比如Predicate和Func

    • 當需要匿名函數進行值的返回時,不需要聲明返回值類型,編譯器自己會檢查是否所有可能的返回值都兼容於委托類型(編譯器會嘗試將匿名方法轉換成該委托類型)

      • 當匿名方法返回一個值時,真的是從匿名方法返回,不是從創建的委托實例的方法返回

    • Predicate<int> isEven = delegate (int x) { return x % 2 == 0; }; //兼容的返回值類型:bool
      Func<int, string> test = delegate (int y) { return $"Answer:{y}"; }; //兼容的返回值類型:最後一個參數
  • 忽略委托參數

    • 如果不關心委托的參數,可以不用聲明它們

捕獲變量
  • 閉包

    • 一個函數除了能通過提供給它的參數交互之外,還能同環境進行更大程度的互動

    • 外部變量(outer variable)

      • 在包括匿名方法的作用域(scope)中的局部變量或參數(不包括ref和out參數)。在類的實例成員內部的匿名方法中,this引用也被認為是一個外部變量

    • 捕獲(的外部)變量(captured (outer) variable)

      • 在匿名方法內部使用的外部變量

      • 在閉包定義中,「函數」指的是匿名方法,「與之交互的環境」指的是匿名方法捕獲到的變量集

  • 簡言之,匿名方法能使用在聲明該匿名方法的方法內部定義的局部變量

  • void EnclosingMethod()
    {
       int outerVar = 5; //外部變量
       string capturedVar = "captured"; //捕獲變量
       
       if(DateTime.Now.Hour == 23)
      {
           int normalLocalVar = DateTime.Now.Minute; //普通方法的局部變量
           Console.WriteLine(normalLocalVar);
      }
       
       MethodInvoker x = delegate()
      {
           string anonLocal = "local to anonymous method"; //匿名方法的局部變量
           Console.WriteLine(capturedVar + anonLocal); //捕獲變量的使用
      };
       
       x();
    }
  • 行為

    • 被匿名方法捕獲的是「變量」,而不是創建委托實例時「該變量的值」

    • string captured = "before x is created";

      TEST t = delegate
      {
         Console.WriteLine(captured);
         captured = "changed by t";
      };

      captured = "directly before x is invoked";
      t(); //Expected: directly before x is invoked

      Console.WriteLine(captured); //Expected: changed by t

      captured = "before second invocation";
      t(); //Expected before second invocation
    • 創建委托實例時,不會執行匿名函數內的代碼

    • 在整個方法中,使用的始終是「同一個變量」

  • 用途

    • 捕獲變量能簡化避免專門創建一些類來存儲一個委托需要處理的信息

    • List<Person> FindAllYoungerThan(List<Person> people, int limit)
      {
         return people.FindAll(delegate(Person person)
                              {
                                   return person.Age < limit;
                              });
      }
    • 以上面的代碼為例,如果只有匿名方法而沒有捕獲變量,就只能在匿名方法中使用一個「硬編碼」的限制年齡,而不能使用作為參數傳遞的limit

  • 捕獲變量的生存期

    • 對於一個捕獲變量,只要還有任何委托實例在引用它,它就會一直存在

    • void Main()
      {
         TEST t = CreateDelegateInstance(); //5
         //counter只存活在CreateDelegateInstance中
         //而CreateDelegateInstance在上面已經完成了調用,生命周期完結
         //但由於t裡面還捕捉著counter,因此counter生存期被延長
         t(); //6
         t(); //7
      }

      static TEST CreateDelegateInstance()
      {
         int counter = 5;
         TEST t = delegate
        {
             Console.WriteLine(counter);
             counter++;
        };

         t();
         return t;
      }
    • 在調用CreateDelegateInstance時,counter的值理所當應是可用的

    • 在方法返回後,雖然counter已隨著CreateDelegateInstance的棧幀被銷毀時而消失

    • 儘管如此,編譯器創建了一個額外的類來容納變量。CreateDelegateInstance方法擁有對該類的一個實例的引用,所以它能使用counter

    • 另外,委托也有對該實例的一個引用,這個實例和其他實例一樣都在堆上。除非委托準備好被垃圾回收,否則那個實例是不會被回收的。

  • 局部變量實例化

    • 局部變量每被聲明一次,它就會被實例化一次。

    • 假如不涉及任何匿名方法,局部變量所需的空間都在方法開始時在棧上分配,所以不會產生每次循環迭代都「重新聲明」變量的開銷

    • 但是以下面代碼為例:

      • 如果把變量捕獲,那在每一次循環裡聲明的局部變量,都會再實例化一個新的同名實例

      • 如果聲明在循環外面,那捕獲的只有那「一個」變量實例,也不會重新實例化

    • List<TEST> list = new List<TEST>();
      //int counter = 10;
      for (int i = 0; i < 5; i++)
      {
         int counter = i * 10;

         list.Add(delegate
                  {
                      Console.WriteLine(counter);
                      counter++;
                  });
      }
    • 當一個變量被捕獲時,捕捉的是變量的實例。如果在循環裡捕獲counter,第一次循環時捕獲的變量與第二次循環時捕獲的變量是不同的

    • 循環的初始部分聲明的變量也是只要被實例化一次(int i)

      • 因此,如果想捕獲循環變量在一次特定循環迭代中的值,必須在循環內部引入另一個變量,并將循環變量的值賦給它,再捕捉這個新的變量

  • 同時使用共享與非共享變量

    • TEST[] delegates = new TEST[2];

      int outside = 0;
      for (int i = 0; i < 2; i++)
      {
         int inside = 0;
         delegates[i] = delegate
        {
             Console.WriteLine($"{outside}, {inside}");
             outside++;
             inside++;
        };
      }

      TEST first = delegates[0];
      TEST second = delegates[1];

      //Outside, Inside
      first(); //0, 0
      first(); //1, 1
      first(); //2, 2

      second(); //3, 0
      second(); //4, 1
  • 當同時捕獲循環外和循環內的變量時,分別會生成兩個類去存儲捕獲變量
    • 一個包含了outside的變量的類

    • 一個包含inside變量以及對outside變量類的引用的類

    • 從根本上來說,包含了一個捕獲變量的每個作用域都有它自己的類型

  • 捕獲變量的使用規則

    • 如果用或不用時的代碼同樣簡單,那就不用

    • 捕獲由for/foreach語句聲明的變量前,思考委托是否需要在循環結束後延續,是否想讓它看到那個變量的後續值。如果不是,就在循環內另建一個變量,用來複製你想要的值。

    • 如果創建多個委托實例,而且捕獲了變量,思考清楚捕獲的對象是否同一個

    • 如果捕獲的變量不變,就沒那麼多事

    • 如果委托實例永遠不會存儲到別的地方,事情會簡單

    • 捕獲對象過多/內存開銷過高,基於被捕獲對象的生存期延長,從垃圾回收的角度來看,問題就會浮現出來

  • 捕獲變量的小結

    • 捕獲的是變量,而不是創建委托實例時它的值

    • 捕獲的變量生存期被延長了,至少和捕捉它的委托一樣長

    • 多個委托可捕獲同一變量

      • 但在循環內部,同一個變量的聲明實際上會引用不同的變量「實例」

    • 在for循環的聲明中創建的變量(int i)僅在循環持續期間有效,不會在每次循環迭代時都實例化

    • 必要時創建額外的類型來保存捕獲變量

參考書目

  • 《CLR via C#》(第4版) Jeffrey Richter
  • 《深入理解C#》(第3版) Jon Skeet