我們知道,在匿名方法或者lambda中,可以訪問或者修改該匿的定義範圍內的變數。例如:
int num = 1; Func<int> incNum = () => ++num;
其中lambda運算式使用了在其外部定義的變數num。我們可以認為該段lambda語句塊構成了一個閉包,而這個閉包捕獲了外部變數num。
好了,不說那麼多讓人看著難受的定義套話了。我們進入正題,看看在C#中變數是如何被捕獲的。來看一個例子:
public Func<String> CreateFunction() { String str = "我的幸運數字是"; int num = 17; Func<String> func = () => str + num; return func; }
在這個例子中,定義了一個返回一個函數的方法CreateFunction。返回的函數構成了一個閉包,該閉包捕獲了兩個變數:String類型的str和int類型的num。
好了,我們現在可以這樣使用這個函數了:
Func<String> myFunc = CreateFunction(); String result = myFunc();
我們來分析一下這兩行代碼實際都幹了什麼。第一行很容易理解,我們把方法CreateFunction產生的匿名函數賦值給了委託myFunc。第二行更好理解,我們執行了myFunc,並將返回結果賦值給了變數result。我們再深入思考一下:在執行myFunc的時候,會訪問到在CreateFunction中定義兩個變數str與num。雖然這時CreateFunction的棧幀早就被銷毀了,其內部定義的變數至今也“生死不明”了,但是因為我們知道這兩個變數已經被閉包所捕獲了,所以我們堅信這兩個變數截至目前為止還是可以訪問的!
對於str對象,鑒於它是一個參考型別,所以只要有存在某個“東西”一直儲存著對它的引用,它就不會被銷毀。這樣我們完全不用擔心在我們需要它時,編譯器或運行時會告訴我們它被弄丟了。然而對於num,情況就有些不同了。num是一個實值型別。我們知道實值型別是存活在棧上的,我們也知道它所存在的那個棧幀(也就是CreateFunction的幀)在CreateFunction執行完畢後就會被銷毀,然後其上存在的任何實值型別也會被一併的銷毀,這其中當然包括我們所關注的變數num了。
那麼,我們為什麼還能安全的訪問num呢?C#中的變數捕獲機制究竟有什麼神奇之處,可以讓實值型別擁有違反常規的生存周期呢?裝箱!你可能會立刻想到,把每個實值型別都裝到一個對象裡,我們就可以讓這個實值型別擁有和那個包裹它的對象相同的壽命了。不過,這並不是C#實現者所選擇的方式!C#並不會對每個需要捕獲的實值型別變數進行裝箱操作,而是把所有捕獲的變數統統放到同一個大“箱子”裡——當編譯器遇到需要變數捕獲的情況時,它會默默地在後台構造一個類型,這個類型包含了每一個閉包所捕獲的變數(包括實值型別變數和參考型別變數)作為它的一個公有欄位。這樣,編譯器就可以
輕鬆愉快地
維護那些在匿名函數或lambda運算式中出現的外部變數了。
更進一步,如果我們使用ILDASM工具查看CreateFunction方法的IL代碼,我們會發現編譯器壓根就沒有聲明num和str變數。取而代之的是聲明了一個類型名和執行個體名都及其難看的封裝對象。這個玩意兒就是我們上面所說的那個被編譯器默默產生,儲存了所有捕獲變數的引用的對象。我們還可以看到,在
CreateFunction方法
C#原始碼內所有對str和num的操作,在IL中都被轉換成了對封裝對象的同名公有成員的操作。順便說一句,就連我們構造的那個lambda運算式“() => str + num”現在都被編譯器轉換成了這個封裝對象的一個方法!