1. 介紹
在程式設計中,閉包(closure)不是關上 JavaScript 的門,而是一種機制:lambda 表達式或匿名方法會捕獲來自外部上下文的變數,並在那個宣告區塊結束後仍然「記住」它們。 簡單來說,閉包是一個函數,它記住了它誕生時的環境,把這些值當成一個小行李箱(變數)帶著走。
閉包 是函數與在建立它時存在的環境(scope)一起構成的東西。
最簡單的閉包範例
我們用實作來說明:
Func<int> MakeCounter()
{
int count = 0;
return () =>
{
count++;
return count;
};
}
這樣呼叫:
var counter = MakeCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
這是怎麼運作的?
- 變數 count 在方法 MakeCounter 內宣告。
- lambda () => { ... } 被回傳到外面,現在活在方法之外。
- 但!它記住了變數 count,即便方法 MakeCounter 已經結束執行。
這就是閉包: lambda 「封閉」(捕獲) 了外部上下文的變數 count。
lambda 到底捕獲什麼?
- 來自外部方法(scope)的區域變數,
- 方法的參數,
- 區塊裡的變數(像是 for、foreach 等)。
重要:變數是按引用捕獲的,而不是按值!如果我們在閉包內改變變數,外部也會看到變化。其實 C# 的編譯器會為這些變數建立一個特殊的輔助類別,但概念上只要記得這點就能放心使用閉包。
2. 閉包與詞法作用域
把「函數工廠」的例子再修改一下:
Func<int, int> PowerFactory(int power)
{
return x =>
{
int result = 1;
for (int i = 0; i < power; i++)
result *= x;
return result;
};
}
使用方式:
var square = PowerFactory(2); // x^2
var cube = PowerFactory(3); // x^3
Console.WriteLine(square(5)); // 25
Console.WriteLine(cube(2)); // 8
函數 square 和 cube 分別以不同的 power 值建立,且各自記住自己的值。每次呼叫 PowerFactory 都會創建一個「自己的背包」來保存被捕獲的值。
3. 捕獲變數的突變
有時會問:在迴圈裡建立多個捕獲迴圈變數的 lambda,會發生什麼?這裡很容易踩雷。
範例:迴圈裡的閉包
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
action(); // ???
你期待看到什麼? 0, 1, 2?但實際上會是:
3
3
3
為什麼?所有 lambda 都「參考」相同的變數 i。到迴圈結束時 i 已經是 3,所以所有的 Action 都會看到這個值。
修正版本
要讓每個 lambda 捕獲「自己的」值,必須在迴圈體內建立新的變數:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i;
actions.Add(() => Console.WriteLine(copy));
}
foreach (var action in actions)
action(); // 0 1 2
現在 copy 在每次迭代都是新的變數,閉包就會捕獲各自的那個。
4. 閉包在真實任務中的應用
資料處理與回呼
當你做非同步或延遲執行(事件處理器、篩選、排程任務)時,閉包可以把邏輯和參數一起「打包」。例如:
void ProcessList(List<int> list, int threshold)
{
var filtered = list.Where(x => x > threshold);
foreach (var item in filtered)
Console.WriteLine(item);
}
這裡 Where 裡的 lambda 捕獲了變數 threshold。
建立「函數工廠」
傳入參數,回傳一個把該參數「內建」進去的函數。這種技巧在設定過濾器、排序比較器、UI 反應等地方很有用。
狀態管理
有時候你只想保存少量狀態,而不想為它建立一個完整的類別:
Func<string, string> CreateGreeting()
{
string prefix = "Hello";
return name =>
{
return $"{prefix}, {name}!";
};
}
5. 有用的細節
底層:C# 裡閉包如何工作
所有被 lambda 捕獲的東西,編譯器會變成一個輔助類別:變數變成欄位,lambda 變成該類別的方法。因此變數的狀態會在呼叫之間保留。
每個「工廠」會生成一個小物件。這是正常的 —— .NET 對這類物件管理得很好,並且只在必要時配置它們。
備註:不要無需捕獲「龐大」的物件
如果你閉包捕獲了一個大型物件(例如 UI form),在 lambda 存活期間那個物件不會被回收。記憶體洩漏的典型情況是透過捕獲了「沉重」上下文的 lambda 訂閱事件,卻沒有取消訂閱(+=/-=)。
閉包與集合的生命週期 — LINQ 範例
閉包讓 LINQ 更靈活:篩選器會記住它們的參數。
List<string> colors = new List<string> { "Red", "Green", "Blue", "Yellow" };
string startsWith = "B";
var filtered = colors.Where(c => c.StartsWith(startsWith));
foreach (var color in filtered)
Console.WriteLine(color); // "Blue"
如果之後改變 startsWith,結果也會改變:
startsWith = "R";
foreach (var color in filtered)
Console.WriteLine(color); // "Red"
這發生的原因是閉包參考的是同一個變數 startsWith,而 StartsWith 每次都檢查當前的值。
6. 與閉包相關的典型錯誤
錯誤 #1:捕獲在使用前就被改變的變數。
迴圈中的經典情況:lambda 在閉包內看到同一個迴圈變數,到真正呼叫時該變數已經改變,函數就不是處理預期的資料。解法是在迴圈內引入新的變數。
錯誤 #2:捕獲過多的上下文。
閉包會把整個物件牽出來,而不只是某個欄位或值。這會製造不必要的依賴並讓程式更難懂。只捕獲需要的東西。
錯誤 #3:持有重資源造成記憶體洩漏。
用 lambda 訂閱事件且閉包捕獲了「重」物件,卻沒取消訂閱,會導致物件無法被釋放。注意訂閱生命週期並使用明確的取消訂閱(-=)。
錯誤 #4:可維護性下降。
過度使用閉包會讓追蹤資料來源及其變動變得困難,特別是當閉包的宣告位置遠離呼叫處時。把邏輯保持在接近的位置並避免濫用。
GO TO FULL VERSION