CodeGym /課程 /C# SELF /變數捕獲 ( Closures)

變數捕獲 ( Closures)

C# SELF
等級 49 , 課堂 4
開放

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 被長期保存(例如存在靜態欄位),那些被引用的變數也會被長期保留在記憶體中,這常造成隱蔽的記憶體洩漏與效能問題。

1
問卷/小測驗
Lambda-表示式,等級 49,課堂 4
未開放
Lambda-表示式
Lambda-表示式語法
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION