匿名方法中的捕獲變數

  • 2019 年 10 月 7 日
  • 筆記

  乍一接觸”匿名方法中的捕獲變數”這一術語可能會優點蒙,那什麼是”匿名方法中的捕獲變數”呢?在章節未開始之前,我們先定義一個委託:public delegate void MethodInvoke();

1、閉包和不同類型的變數:

  首先,大家應該都知道”閉包”,它的概念是:一個函數除了能通過提供給它的參數交互之外,還能同環境進行更大程度的互動。但這個定義過於抽象,還需要理解兩個術語:

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

  2)捕獲的外部變數(captured outer variable)通常簡稱捕獲變數(captured variable),它是在匿名方法內部使用的外部變數。

  這些定義看起來雲里霧裡的,那接下來以一個例子來說明: 

 1 public void EnClosingMethod()   2 {   3     int outerVariable = 5; // 外部變數   4     string captureVariable = "captured"; // 被匿名方法捕獲的外部變數   5     if (DateTime.Now.Hour == 23)   6     {   7         int normalLocalVariable = DateTime.Now.Minute; // 普通方法的局部變數   8         Console.WriteLine(normalLocalVariable);   9     }  10     MethodInvoke x = delegate ()  11     {  12         string anonLocal = "local to anonymous method"; // 匿名方法的局部變數  13         Console.WriteLine(captureVariable + anonLocal); // 捕獲外部變數captureVariable  14     };  15     Console.WriteLine(outerVariable);  16     x();  17 }

2、捕獲變數的行為:

  如果你運行了上述程式碼,你會發現匿名方法捕捉到的確實是變數,而不是創建委託實例時該變數的值。通俗的說就是只有在匿名方法被調用時才會被使用。 

 1 string captured = "before x is created";   2 MethodInvoke x = delegate   3 {   4     Console.WriteLine(captured);   5     captured = "change by x";   6 };   7 captured = "directly before x is invoked";   8 x();   9 Console.WriteLine(captured);  10 captured = "before second invocation";  11 x();

  上述程式碼的執行順序是這樣子的(可以debug):定義變數captured => 聲明匿名方法MethodInvoke x => 將captured的值修改為”directly before x is invoked” => 緊接著調用委託x(),這個時候會進入匿名方法 => 首先輸出captured的值”directly before x is invoked”,然後修改為”change by x” => 匿名方法調用結束,來到第9行,輸出captured的值”change by x” => 第10行重新給captured賦值”before second invocation” => 調用x()

3、捕獲變數到底有什麼用處:

  捕獲變數能簡化避免專門創建一些類來存儲一個委託需要處理的資訊。

1 List<People> FindAllYoungerThan(List<People> people, int limit)  2 {  3     return people.Where(person => person.Age < limit).ToList();  4 }

  我們在委託實例內部捕獲了limit參數——如果僅有匿名方法而沒有捕獲變數,就只能在匿名方法中使用一個”硬編碼”的限制年齡,而不能使用作為參數傳遞的limit。這樣的設計能夠準備描述我們的”目的”,而不是將大量的精力放在”過程”上。

4、捕獲變數的延長生存期:

  到目前為止,我么一直在創建委託實例的方法內部使用委託實例。在這種情況下,你對捕獲變數的生存期(lifetime)不會又太大的疑問。但是,假如委託實例”逃”到另一個黑暗的世界(big bad world),那會發生什麼?假如創建它的那個方法結束了,它將何以應對?

  在理解這種問題時,最簡單的辦法就是指定一個規則,給出一個例子,然後思考假如沒有那個規則,會發生什麼:對於一個捕獲變數,只要還有任何委託實例在引用它,它就會一直存在。

 1 private static void Main(string[] args)   2 {   3     MethodInvoke x = CreateDelegateInstance();   4     x();   5     x();   6 }   7   8 private static MethodInvoke CreateDelegateInstance()   9 {  10     int counter = 5;  11  12     MethodInvoke ret = delegate  13     {  14         Console.WriteLine(counter);  15         counter++;  16     };  17  18     ret();  19     return ret;  20 }

  輸出的結果:

  我們一般認為counter在棧上,所以只要與CreateDelegateInstance對應的棧幀被銷毀,counter隨之消失,但是從結果來看,顯然我們的認知是有問題的。事實上,編譯器創建了一個額外的類容納變數。CreateDelegateInstance方法擁有對該類的一個實例的引用,所以它能使用counter。另外,委託也對該實例的一個引用,這個實例和其他實例一樣都在堆上。除非委託準備好垃圾回收,否則那個實例是不會被回收的。

5、局部變數實例化:

  下面將展示一個例子。

1 int single;  2 for (int i = 0; i < 10; i++)  3 {  4     single = 5;  5     Console.WriteLine(single + i);  6 }

1 for (int i = 0; i < 10; i++)  2 {  3     int multiple = 5;  4     Console.WriteLine(multiple + i);  5 }

  上述兩段程式碼在語義和功能上是一樣的,但在記憶體開銷上顯然第一種寫法比第二種佔用較小的記憶體。single變數只實例化一次,而multiple變數將實例化10次。當一個變數被捕獲時,捕捉的是變數的”實例”。如果在循環內捕捉multiple,第一次循環迭代時捕獲的變數與第二次循環時捕獲的變數是不同的。

 1 List<MethodInvoke> list = new List<MethodInvoke>();   2 for (int index = 0; index < 5; index++)   3 {   4     int counter = index * 10;   5     list.Add(delegate   6     {   7         Console.WriteLine(counter);   8         counter++;   9     });  10 }  11 foreach (MethodInvoke t in list)  12 {  13     t();  14 }  15  16 list[0]();  17 list[0]();  18 list[0]();  19  20 list[1]();

  輸出結果:

  上述程式碼首先創建了5個不同的委託實例,調用委託時,會先列印counter值,再對它進行遞增。由於counter變數是在循環內部聲明的,所以每次循環迭代,它都會被實例化。這樣一來,每個委託捕捉到的都是不同的變數。

6、共享和非共享的變數混合使用:

 1 MethodInvoke[] delegates = new MethodInvoke[2];   2 int outside = 0;   3   4 for (int i = 0; i < 2; i++)   5 {   6     int inside = 0;   7     delegates[i] = delegate   8     {   9         Console.WriteLine($"{outside},{inside}");  10         outside++;  11         inside++;  12     };  13 }  14  15 MethodInvoke first = delegates[0];  16 MethodInvoke second = delegates[1];  17  18 first();  19 first();  20 first();  21  22 second();  23 second();

  輸出結果:

  首先outside變數只聲明了一次,但inside變數每次循環迭代,都會實例化一個新的inside變數。這意味著當我們創建委託實例時,outside變數將由委託實例共享,但每個委託實例都有它們自己的inside變數。

7、總結:

  如何合理使用捕獲變數?

    1)如果用或不用捕獲變數的程式碼同樣簡單,那就不要用。

    2)捕獲由for或foreach語句聲明的變數之前,思考你的委託是否需要再循環迭代結束之後延續,以及是否想讓它看到那個變數的後續值。如果不是,就在循環內另建一個變數,用來複制你想要的值。

    3)如果創建多個委託實例,而且捕獲了變數,思考下是否希望它們捕獲同一變數。

    4)如果捕獲的變數不會發生變化,就不需要擔心。

    5)如果你創建的委託實例永遠不會存儲別的地方,不會返回,也不會啟動執行緒。

    6)從垃圾回收的角度,思考任何捕獲變數被延長的生存期。這個問題一般都不大,但假如捕獲的對象會產生昂貴的記憶體開銷,問題就會凸顯出來。

參考:深入理解C#_第三版