C#——委託、Lambda表示式、閉包和記憶體洩漏
使用委託的典型情況
首先看看委託的常見的使用情景:定義一個委託、使用剛定義的委託宣告一個委託變數、根據需要將方法和該變數繫結,最後在合適的地方使用它。程式碼形式如下:
//定義委託
public delegate void SomeDelegate();
class SomeClass
{
public void InstanceFunction()
{
//Do something
}
public static void StaticFunction()
{
//Do something
}
}
public class SomeUserClass
{
public void SomeAction()
{
//宣告委託變數
SomeDelegate del;
SomeClass someClass = new SomeClass();
//繫結到例項方法
del = someClass.InstanceFunction;
//使用它
del();
//繫結到靜態方法
del = SomeClass.StaticFunction;
//再次使用它
del ();
}
}
先不談委託的其他用途,通過上面的例子,可以將委託簡單理解為一個“方法型別”。可將委託宣告的變數和與委託簽名相符的方法繫結,之後就可以像使用方法一樣使用這個變數。
委託是安全封裝方法的型別,類似於 C 和 C++ 中的函式指標。 與 C 函式指標不同的是,委託是面向物件的、型別安全的和可靠的。 委託的型別由委託的名稱確定。——來自MSDN
上面的做法是將委託變數del
分別與一個例項方法和一個靜態方法繫結。這兩種方式都被稱作使用命名方法。
在 C# 1.0 中,通過使用在程式碼中其他位置定義的方法顯式初始化委託來建立委託的例項。 C# 2.0 引入了匿名方法的概念,作為一種編寫可在委託呼叫中執行的未命名內聯語句塊的方式。 C# 3.0 引入了 lambda 表示式
,這種表示式與匿名方法的概念類似,但更具表現力並且更簡練。 這兩個功能統稱為匿名函式。 通常,面向 .NET Framework 3.5 及更高版本的應用程式應使用 lambda 表示式。——來自MSDN
我個人是更加偏好於使用Lambda表示式,至於匿名方法,用法幾乎與Lambda表示式一樣。下文的示例程式碼中我都將用更加簡潔的Lambda表示式來書寫。Lambda表示式可以參考MSDN——Lambda表示式
使用Lambda表示式初始化委託
在這一節,先看看Func<TResult>
,可以參考MSDN——Func 委託得到更多資訊。
Func<TResult>
實際上是.net封裝好的一個委託,它不接受引數、返回一個TResult
型別的值。
比如我們可以通過如下程式碼來宣告一個Func<int>
的變數、併為其繫結一個方法、然後使用它:
public class AnotherClass{
//宣告委託變數
private Func<int> funcInt;
private int info;
//宣告符合Func<int>簽名的函式
private int FunctionReturnsInt()
{
return info;
}
private void SomeUserFunction()
{
//將方法繫結至委託變數
funcInt = FunctionReturnsInt;
//通過變數呼叫方法
int result = funcInt();
//Do something
}
}
對於上面的程式碼,如果改用Lambda表示式,就會簡潔很多,如下:
public class AnotherClass{
//宣告委託變數
private Func<int> funcInt;
private int info;
private void SomeUserFunction()
{
//將Lambda表示式繫結至委託變數
funcInt = () => { return info; };
//通過變數呼叫方法
int result = funcInt();
//Do something
}
}
使用Lambda表示式省掉了書寫命名方法的過程,程式碼看起來更加清新。然而,稍不注意,Lambda表示式就會“毀滅”你的程式碼。
閉包
在Lambda表示式“毀滅”你的程式碼前,先看看下面的程式碼會輸出什麼:
List<Func<int>> funcs = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
funcs.Add(() => { return i; });
}
foreach(var item in funcs)
{
Console.WriteLine(item().ToString());
}
對於不理解閉包的人,第一反應自然是輸出0、1、2
。但事實上,它輸出的是3、3、3
。造成這種“出人意料”的結果的原因,就是閉包。
關於閉包,這裡不作過多、過複雜的介紹,想要深入瞭解,可以查閱相關資料。
簡單地講,閉包是一個程式碼塊(在C#中,指的是匿名方法或者Lambda表示式,也就是匿名函式),並且這個程式碼塊使用到了程式碼塊以外的變數,於是這個程式碼塊和用到的程式碼塊以外的變數(上下文)被“封閉地包在一起”。當使用此程式碼塊時,該程式碼塊裡使用的外部變數的值,是使用該程式碼塊時的值,並不一定是建立該程式碼塊時的值。
一句話概括,閉包是一個包含了上下文環境的匿名函式。
有點拗口,不過暫且先根據這個解釋,我們回去看看上面的程式碼。
程式碼中的Lambda表示式(程式碼塊)() => { return i; }
,使用了for
迴圈中的迴圈變數i
。
在for
迴圈中,我們通過Lambda表示式(程式碼塊)建立了三個匿名函式、並新增進委託列表中;當for
迴圈結束後,我們逐個呼叫與委託列表繫結的三個匿名函式。
在呼叫這三個匿名函式時,雖然for
迴圈已經結束,其控制變數i
也“看起來不存在了”,但事實是,變數i已經被加入到上面每一個匿名函式各自的上下文中,也就是說,上面的三個匿名函式,都“閉包”著變數i
。
此時i
的值已經等於3
,於是這三個匿名函式都將返回3
並交給Console
去輸出。
為了看清楚後臺究竟發生了什麼,用Visual Studio自帶的IL Disassembler開啟編譯出的exe檔案,檢視結果。
對於閉包,編譯的結果是:編譯器為閉包生成了一個類,i
作為一個公共的欄位存在於其中。
也就是說,雖然for
迴圈已經結束,但是i
仍然以一種“看不見”的方式活躍在記憶體中。所以當我們呼叫這三個匿名函式時,使用的都將是同一個i
(指的是變數,而不是它具體的值)。
接下來修改程式碼如下:
List<Func<int>> funcs = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
int j = i;
funcs.Add(() => { return j; });
}
foreach(var item in funcs)
{
Console.WriteLine(item().ToString());
}
再次執行,輸出結果為0、1、2
。分析下原因。
在每一次迴圈時,我們都建立了一個新的變數j
。為了區分每一次迴圈中的j
,第一次迴圈時,我稱它為j0
,此時它從i
中獲得的值為0
,並且本次迴圈中,建立了一個匿名函式並使用了j0
,形成了一個閉包。在第二次迴圈時,將建立另一個變數j1
,此時它從i
中獲得的值為1
,此迴圈中的匿名函式將使用變數j1
,形成另一個閉包;第三次迴圈類似。
一下子豁然開朗了。在這次的程式碼中,三個匿名函式使用的j
並不是同一個變數,所以會有後面的結果。
關於foreach語句的閉包
還是先看一段程式碼:
List<int> values = new List<int>() {0,1,2 };
List<Func<int>> funcs = new List<Func<int>>();
foreach (var item in values)
{
funcs.Add(() => { return item; });
}
foreach(var item in funcs)
{
Console.WriteLine(item().ToString());
}
這段程式碼的輸出是0、1、2
。看起來似乎與前面所講的有矛盾。
在C# 5.0之前的版本,在foreach
的迴圈中,將會共用一個item
,這段程式碼的輸出就是2、2、2
;C# 5.0之後,foreach
的實現方式作了修改,在每一次迴圈時,都會產生一個新的item
用來存放列舉器當前值,所以此時的情形類似於上面for
迴圈的第二種情形。
閉包?記憶體洩漏?
再看一段程式碼:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Program start");
ShowMemory();
Console.WriteLine("Create object");
SomeClass someClass = new SomeClass();
ShowMemory();
Console.WriteLine("Call function");
someClass.SomeFunction();
ShowMemory();
Console.WriteLine("Release delegate");
someClass.func = null;
ShowMemory();
}
private static void ShowMemory()
{
GC.Collect();
Console.WriteLine("Memory used : " + GC.GetTotalMemory(true));
Console.WriteLine("--------------------------------------------------");
Console.ReadKey();
}
public class MemoryHolder
{
public byte[] data;
public int info;
public MemoryHolder()
{
data = new byte[10 * 1024 * 1024];
info = 100;
Console.WriteLine("MemoryHolder created");
}
~MemoryHolder()
{
Console.WriteLine("MemoryHolder released");
}
}
public class SomeClass
{
public Func<int> func;
public void SomeFunction()
{
MemoryHolder holder = new MemoryHolder();
func = () => { return holder.info; };
Console.WriteLine("Function exited");
}
}
}
看看執行結果:
可以看出,原本在SomeFunction
呼叫結束時就應該被釋放的MemoryHolder
物件,並沒有被釋放,而是在使用它的閉包被釋放時,才真正被釋放掉。也就是說,閉包會延長它使用的外部變數的生命週期,直到閉包本身被釋放。
那麼閉包會不會造成記憶體洩漏?
我認為只有不嚴謹的程式碼才會造成記憶體洩漏。正如上述程式碼中的someClass.func
或者someClass
物件、在不需要它(們)的時候沒有被正確釋放它(們),就會造成了本該被銷燬的holder
物件不會被正確地被銷燬、自然也就造成了記憶體洩漏。但是不應該讓閉包背這個鍋。
總結
1、匿名函式是個語法糖,很方便,但是也容易帶來問題。
2、如果一定要使用閉包,那麼切記做好記憶體的回收。
3、養成良好的程式碼習慣。