1. 什麼是閉包?
在程式設計中,閉包(closure)是一個會捕捉外部上下文變數的函式。簡單說,如果一個 lambda 表達式或匿名方法使用了在自身之外宣告的變數,這個函式就變成了閉包。它「記得」那些變數在建立時的狀態。
生活類比:
想像你把一個祕方寫在紙上並放進信封。即便那張紙之後不見了或無法直接取得(變數不再可存取),拿著那個信封的人(lambda)仍然可以存取這個祕方。
簡單範例:
int x = 42;
Func<int> getX = () => x;
Console.WriteLine(getX()); // 42
這裡 getX 是個閉包,因為它使用了在外部宣告的變數 x。
2. 為什麼變數捕獲很重要?
在 C# 裡,閉包隨處可見:
- 在集合和 LINQ 查詢中
- 用來把參數傳給事件或非同步方法
- 在迴圈內建立事件處理器時
- 在不同呼叫之間儲存「上下文」
沒有閉包,很多 C# 的常見做法要麼做不到,要麼非常不方便。
實際情境舉例
假設我們做一個提醒程式:使用者設定一系列提醒,未來某個時間(1 分鐘後、1 小時後、1 週後...)要顯示對應訊息。把一個記得要提醒什麼的 lambda 傳給處理器非常簡單,這就是變數捕獲的經典用法。
3. C# 如何實作變數捕獲
在背後 C# 做了一個小技巧:當你有一個使用外部變數的 lambda 表達式時,編譯器會自動產生一個輔助類別 — display class。所有被「捕獲」的變數都變成這個類別的欄位。
示意圖大概長這樣:
外部變數 ──► DisplayClass
▲
│
閉包 (lambda)
代碼示意
底下是「在底層發生的事」:
int x = 5;
Func<int> f = () => x;
// 這裡編譯器大致會做成這樣:
class DisplayClass
{
public int x;
public int Lambda() => x;
}
DisplayClass display = new DisplayClass();
display.x = 5;
Func<int> f = display.Lambda;
這解釋了為什麼閉包在外部作用域結束後仍然能看到變數的最新值。
4. 變數的值是「凍結」還是會變?
在 C# 中變數是按引用被捕獲,而不是按值。這表示如果 lambda 使用的變數在別處被修改,lambda 會看到新的值。
範例:
int x = 10;
Func<int> getX = () => x;
x = 20;
Console.WriteLine(getX()); // 20, 不是 10!
學生常常以為 getX() 會一直回傳 10,因為變數「被捕獲」。但實際上 lambda 讀取的是仍然存在、且可以被變更的那個變數。
什麼時候值會被固定?
如果變數是在每次迭代都建立新作用域(例如某些情況下的 foreach),那麼每次迭代會有新的變數實例 — lambda 就會「記住」當時的值。
5. 範例:迴圈裡的捕獲 — 常見陷阱
常見錯誤
想建立一組 delegate,每個都輸出自己在迴圈中的編號:
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var action in actions)
action();
程式會輸出什麼?
5
5
5
5
5
哇!為什麼不是 0,1,2,3,4?
原因:
lambda 捕獲了同一個變數 i,這個變數在迴圈中持續變化。當你稍後呼叫那些 delegate 時,i 已經等於 5。
正確做法?
在迴圈內建立一個新的變數:
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
int index = i; // 每次迭代的新變數!
actions[i] = () => Console.WriteLine(index);
}
foreach (var action in actions)
action();
現在程式會輸出:
0
1
2
3
4
這和 display class 有什麼關係?
在第一個版本,所有 delegate 都「綁到」同一個欄位 — 因此結果相同。在第二個版本,每次迭代建立新的本地變數,因而每個 delegate 對應到一個不同的 DisplayClass,各自有自己的值。
6. 捕獲變數的實務應用場景
範例 1:帶有「上下文」的事件處理
假設在小程式中有一個任務列表,每個任務有一個「執行」按鈕。我們希望按鈕的 handler 的 lambda 記得要處理哪個任務:
foreach (var task in tasks)
{
button.Click += (sender, e) => CompleteTask(task);
}
這裡變數 task 在每次迭代時都會被捕獲。要注意把它正確地宣告在迴圈內,避免掉上面提到的陷阱。
範例 2:非同步操作
常用閉包把參數傳給非同步邏輯 — 例如在啟動非同步任務時把變數存到本地「槽」裡:
for (int i = 0; i < 3; i++)
{
int index = i; // 必要!
Task.Run(() => Console.WriteLine($"Task #{index}"));
}
沒有本地變數,所有任務會印出相同的編號,通常不是我們想要的行為。
範例 3:LINQ 查詢
LINQ 對集合的操作經常使用閉包,根據外部變數過濾或轉換元素。例如:
string prefix = "Task";
var filtered = tasks.Where(t => t.Name.StartsWith(prefix));
這裡 Where 裡的 lambda 記住了 prefix 的值,並呼叫 StartsWith。
7. 與閉包相關的特性、限制和常見錯誤
錯誤一:在迴圈中讓所有 delegate 使用同一個變數。
如果迴圈中所有 delegate 都參照同一個變數,結果會很意外。要在迴圈內為每個 delegate 建立新的本地變數,以避免共用引用。
錯誤二:捕獲方法外的變數。
如果閉包捕獲了類別欄位或在當前方法外宣告的變數,它會保有對該變數的引用。這可能導致記憶體無法回收,因為 GC 不能釋放被閉包引用的物件。
錯誤三:長期存在的 delegate 帶來的閉包。
如果帶閉包的 delegate 被長期保存(例如存在靜態欄位),那些被引用的變數也會被長期保留在記憶體中,這常造成隱蔽的記憶體洩漏與效能問題。
GO TO FULL VERSION