閉包

C# SELF
等級 51 , 課堂 3
開放

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)的區域變數,
  • 方法的參數,
  • 區塊裡的變數(像是 forforeach 等)。

重要:變數是按引用捕獲的,而不是按值!如果我們在閉包內改變變數,外部也會看到變化。其實 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

函數 squarecube 分別以不同的 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:可維護性下降。
過度使用閉包會讓追蹤資料來源及其變動變得困難,特別是當閉包的宣告位置遠離呼叫處時。把邏輯保持在接近的位置並避免濫用。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION