程式語言 · 委派與 Lambda

# 委派與 Lambda：lambda 抓的是變數，不是值（C# closure，附 Go 對照）

Chance Lu

你在迴圈裡替三顆按鈕各掛一個點擊動作，按下去要印出自己的編號：0、1、2。寫法很直覺，`for` 跑三圈，每圈加一個 lambda。

一測，三顆按鈕**都印 3**。不是 0、1、2，連 2 都不是，是一個迴圈裡從沒處理過的 3。

這篇就拆這件事：lambda 用到外面的變數時，編譯器在背後做了什麼。弄懂它，你也會知道這種寫法**每次要花多少記憶體**。

> 一句話：lambda 抓的是**變數本身**，不是當下的值。編譯器把那個變數搬進一個**置物櫃物件**，用到它的人共用同一格。

整篇只用一個比喻：**置物櫃**。被 lambda 用到的變數會被搬進櫃子；方法和 lambda 各拿一把鑰匙，開的是**同一格**。

## 名詞白話

| 名詞 | 白話 |
| --- | --- |
| 委派（delegate） | 一個物件，記著「要跑哪段程式」和「要對誰跑」。`Func`、`Action` 都是委派 |
| 捕捉（capture） | lambda 用到了它外面的區域變數，就叫「捕捉」了那個變數 |
| closure（閉包） | 編譯器為了捕捉而自動產生的置物櫃（一個隱藏類別和它的物件） |
| static lambda | 前面加了 `static` 的 lambda，編譯器保證它什麼都不捕捉 |

## 第一步：委派，就是存進變數的一段程式

你早就把 lambda 傳來傳去，例如 `list.Sort((a, b) => a - b)`。可是一段**程式碼**怎麼放得進**變數**？這一步先認識裝它的容器：**委派**。

寫 `Func<int, int> square = x => x * x;` 時，編譯器會建一個**委派物件**放進 `square`。這個物件記兩件事：

- **方法**：要跑的那段程式，這裡是 `x * x`。
- **對象**（Target）：這段程式要用到哪個物件的資料。例如 `Action bark = dog.Bark;`，對象就是那隻 `dog`。`x * x` 只用到參數，用不到對象；下一步它是主角。

呼叫 `square(5)`，就是拿對象去跑方法。`Func` 和 `Action` 能不能互換標籤，[〈共變與逆變〉](https://docs.smartech.tw/n/generics-variance-in-out)講過，這裡不重複。

square一個變數
裝著

委派物件
① 方法：要跑哪段程式（x \* x）
② 對象：要對誰跑（下一步就會用到）
呼叫 square(5)：拿 ② 去跑 ①

圖 A：委派是一個物件，裡面記著方法和對象（點圖可放大）

> 一句話：**委派＝方法＋對象**，包成一個物件。lambda 是建委派最短的寫法。

## 第二步：用到外面的變數，編譯器就開一個置物櫃

區域變數平常跟著方法走：方法結束，變數就沒了。但 lambda 可以先存起來，**晚點才呼叫**，那時方法可能早就結束了。

lambda 用到外面的區域變數，叫做**捕捉**。被捕捉的變數得活得比方法久。這一步看編譯器怎麼辦到。

```
int count = 0;
Action add = () => count++;   // 捕捉了 count
```

C# 規格描述的做法是**把變數搬家**：

1. 產生一個隱藏類別，`count` 變成它的**欄位**。這就是置物櫃，正式名稱叫 **closure**。
2. 一進入 `count` 所在的範圍（宣告它的那對大括號），就 `new` 一個櫃子。之後方法裡寫的 `count`，其實都是「櫃子.count」。
3. lambda 的內容變成櫃子的方法；委派的**對象**就是這個櫃子。

翻成一般 C#，大概長這樣（類別名稱是示意）：

```
var locker = new Locker();    // 真實名字類似 <>c__DisplayClass0_0
locker.count = 0;
Action add = locker.Body;     // 對象＝locker，方法＝Body

class Locker
{
    public int count;
    public void Body() => count++;
}
```

你寫的
編譯器實際做的

int count = 0;
Action add = () =&gt; count++;
count = 100;
置物櫃（closure 物件）欄位：count
委派 add方法：count++　對象：那個櫃子

① 編譯器發現：lambda 用到了外面的 countcount 得活得比這個方法久
② 一進入範圍就 new 一個置物櫃，count 搬進去當欄位之後的 count 都是「櫃子.count」
③ 建委派：方法＝lambda 的內容，對象＝這個櫃子委派手上拿著櫃子的鑰匙
④ count = 100 改的是櫃子裡那一格add() 讀到的也是這一格，所以看得到 100

圖 B：捕捉一個變數，編譯器做的四件事（動畫會循環，點圖可放大）

所以方法和 lambda 開的是**同一格**。方法把 `count` 改成 100，lambda 下次讀到的就是 100（練習 1）。櫃子會一直活著，直到委派沒人用、被 GC 回收。

> 一句話：lambda 捕捉的是**變數本身**，不是當下的值。變數被搬進 closure 物件，大家共用。

## 第三步：for 迴圈裡，三個 lambda 開同一個櫃子

回到開頭的三顆按鈕。既然 lambda 抓的是變數，問題就變成：**迴圈裡到底有幾個 `i`？**這一步學會從「變數宣告在哪」數出櫃子有幾個。

```
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.Write($"{i} "));
}
foreach (var a in actions) a();   // 印出：3 3 3
```

規則只有一條：**變數每被宣告一次，就開一個新櫃子。**

C# 規格寫明，`for` 括號裡的 `i` 算是宣告在**迴圈外面**，整個迴圈只宣告一次。所以只有一個櫃子，三個 lambda 都開它。

最後一次 `i++` 把 `i` 變成 3，`3 < 3` 不成立才離開迴圈。等你呼叫 lambda 時，櫃子裡就是 3。

整個 for 迴圈只有一個 i
置物櫃：i
0
1
2
3

lambda #1
lambda #2
lambda #3

① 第一圈，i = 0：建 lambda #1，鑰匙開這個櫃子lambda 只拿到鑰匙，沒有抄下 0
② 第二圈，i = 1：建 lambda #2，開的還是同一個櫃子i 沒有重新宣告，所以沒有新櫃子
③ 第三圈，i = 2：建 lambda #3，還是同一個櫃子三把鑰匙，一個櫃子
④ i++ 變成 3，3 &lt; 3 不成立，離開迴圈櫃子裡最後留下的是 3
⑤ 這時才呼叫三個 lambda：都去開同一格印出 3 3 3

圖 C：for 的 i 只宣告一次，三個 lambda 共用一個櫃子（動畫會循環，點圖可放大）

**修法**：在迴圈**裡面**宣告一個新變數，lambda 改抓它。迴圈本體每跑一圈，`copy` 就被宣告一次，等於每圈開一個新櫃子。

```
for (int i = 0; i < 3; i++)
{
    int copy = i;                          // 每圈一個新變數
    actions.Add(() => Console.Write($"{copy} "));
}                                          // 印出：0 1 2
```

每圈宣告一次 copy＝每圈一個新櫃子
櫃子：copy = 0
櫃子：copy = 1
櫃子：copy = 2

lambda #1
lambda #2
lambda #3
呼叫時各開各的櫃子 → 0 1 2（foreach 的變數也是每圈一個新的）

圖 D：修法，每圈一個櫃子（點圖可放大）

`foreach` 不必修：規格把它的變數放在**每一圈裡面**宣告，每圈本來就是新的。規格也註明早期版本的 C# 放在外面，所以舊文章會說 foreach 也有這個坑。

| 寫法 | 櫃子幾個 | 印出 |
| --- | --- | --- |
| `for`，lambda 直接抓 `i` | 1 | 3 3 3 |
| `for`，迴圈裡先 `int copy = i;` | 3 | 0 1 2 |
| `foreach (var n in ...)` | 3 | 0 1 2 |

> 一句話：先找 lambda 抓的變數**宣告在哪**。在迴圈外（包括 for 的 i）＝大家共用一格；在迴圈裡（包括 foreach 的變數）＝每圈一格。

## 第四步：每個櫃子都是一次 new，熱路徑會痛

櫃子是 `new` 出來的物件，用完要等 GC 回收。偶爾一次無所謂；但放在**熱路徑**上，也就是每秒被呼叫上萬次的程式呢？這一步量出代價，再學一個護欄。

```
static bool HasId(int[] ids, int target) =>
    Array.Exists(ids, x => x == target);   // 捕捉了參數 target
```

- **有捕捉**：每次呼叫都要 `new` 一個櫃子放 `target`，再 `new` 一個委派指向這次的櫃子。實測**每次 88 bytes**，呼叫一百萬次就是約 88 MB 的垃圾。
- **沒捕捉**：像 `x => x > 0` 不需要櫃子，每次建出來的委派都一模一樣。所以編譯器把它**存在一個 static 欄位**，第一次建好就一直重用。實測**每次 0 bytes**。

有捕捉：x =&gt; x == target
第 1 次呼叫
new 櫃子
new 委派
第 2 次呼叫
new 櫃子
new 委派
每次呼叫 88 bytes；一百萬次約 88 MB，全部等 GC 回收
沒捕捉：x =&gt; x &gt; 0
第 1 次呼叫
第 2 次呼叫
第 N 次呼叫

同一個委派第一次建好，存在 static 欄位
之後每次呼叫 0 bytes

圖 E：有捕捉每次都 new 兩個物件；沒捕捉只建一次（點圖可放大）

### static lambda：保證不捕捉的護欄

0 bytes 很容易被改壞：有人在 lambda 裡多用一個外面的變數，編譯照過，配置悄悄回來。

在 lambda 前加 `static`，就是**跟編譯器保證：這個 lambda 什麼都不捕捉**。誰用了外面的變數，直接編譯失敗（CS8820，練習 4）。

它**不會讓有捕捉的 lambda 變快**，拿掉 `static` 程式的意思也完全不變，它只是一道檢查。真的需要外面的值，就找有 **state 參數**的 API（多一個參數，專門把值轉交給 lambda），把值當參數傳進去，例如 `ConcurrentDictionary` 的 `GetOrAdd(key, static (id, p) => p + id, prefix)`。

> 一句話：有捕捉＝每次執行都 new 櫃子和委派；沒捕捉＝委派快取重用。熱路徑上用 **static lambda** 鎖住「零配置」。

## 常見的坑

- **櫃子在「進入範圍」時就 new，不是在建 lambda 那行。**變數宣告在方法開頭、lambda 只在很少走到的 `if` 裡，每次呼叫仍白花 24 bytes（實測）。把變數宣告**搬進那個 if**，就降到 0。
- **在 for 迴圈裡寫 `Task.Run(() => Use(i))`。**跟按鈕同一個坑，但更難抓：工作什麼時候跑不一定，讀到的 `i` 每次可能不同。一樣用 `int copy = i;` 修。
- **以為 lambda 存下了「建立當下的值」。**它存的是變數，之後誰改了它都看得到。要定住當下的值，先複製到一個新變數。

## 動手做

先開一個練習資料夾：

```
mkdir lab && cd lab && dotnet new console
```

每個練習都**把 `Program.cs` 整個換掉**再執行。**先猜、再跑、再對答案。**輸出是在 .NET 10 實際跑出來的。

### 練習 1：存的是變數，還是值？

建檔把 `Program.cs` 換成：

```
int count = 0;
Action add = () => count++;

add();
add();
count = 100;
add();

Console.WriteLine($"count = {count}");
Console.WriteLine($"委派的對象是：{add.Target!.GetType().Name}");
```

先猜`count` 最後是 3 還是 101？委派的對象會是什麼型別？

執行`dotnet run`：

```
count = 101
委派的對象是：<>c__DisplayClass0_0
```

這證明方法和 lambda 改的是同一格；委派的對象就是編譯器產生的置物櫃（圖 B）。

### 練習 2：開頭那三顆按鈕

建檔把 `Program.cs` 換成：

```
var actions = new List<Action>();

for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.Write($"{i} "));
}

foreach (var a in actions) a();
Console.WriteLine();
```

先猜印出什麼？lambda 抓到的是哪個變數？

執行`dotnet run`：

```
3 3 3 
```

再試修法換成下面這段，對照 for 加 `copy` 和 foreach：

```
var fromFor = new List<Action>();
for (int i = 0; i < 3; i++)
{
    int copy = i;                        // 每圈宣告一個新變數
    fromFor.Add(() => Console.Write($"{copy} "));
}

var fromForeach = new List<Action>();
foreach (var n in new[] { 0, 1, 2 })     // foreach 每圈本來就是新的 n
{
    fromForeach.Add(() => Console.Write($"{n} "));
}

Console.Write("for + copy : "); foreach (var a in fromFor) a();
Console.WriteLine();
Console.Write("foreach    : "); foreach (var a in fromForeach) a();
Console.WriteLine();
```

```
for + copy : 0 1 2
foreach    : 0 1 2 
```

這證明for 的 `i` 只有一個櫃子（圖 C）；每圈宣告的變數各有一個（圖 D）。

### 練習 3：每次呼叫花多少記憶體？

建檔把 `Program.cs` 換成：

```
int[] ids = { 3, 8, 42 };
const int N = 1_000_000;

HasId(ids, 42); HasPositive(ids);   // 先各跑一次，排除第一次的初始化

long before = GC.GetAllocatedBytesForCurrentThread();
for (int k = 0; k < N; k++) HasId(ids, 42);
long capture = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
for (int k = 0; k < N; k++) HasPositive(ids);
long noCapture = GC.GetAllocatedBytesForCurrentThread() - before;

Console.WriteLine($"有捕捉 target：每次呼叫配置 {capture / N} bytes");
Console.WriteLine($"什麼都沒捕捉：每次呼叫配置 {noCapture / N} bytes");

static bool HasId(int[] ids, int target) =>
    Array.Exists(ids, x => x == target);   // 用到外面的 target

static bool HasPositive(int[] ids) =>
    Array.Exists(ids, x => x > 0);         // 只用自己的參數 x
```

先猜兩個方法都只是查陣列，配置會一樣嗎？

執行`dotnet run -c Release`：

```
有捕捉 target：每次呼叫配置 88 bytes
什麼都沒捕捉：每次呼叫配置 0 bytes
```

這證明有捕捉，每次都 new 櫃子和委派；沒捕捉，委派被快取重用（圖 E）。

### 練習 4：static 護欄

建檔把 `Program.cs` 換成：

```
int[] ids = { 3, 8, 42 };
int target = 42;

bool found = Array.Exists(ids, static x => x == target);
Console.WriteLine(found);
```

先猜編譯得過嗎？

執行`dotnet build`：

```
Program.cs(4,49): error CS8820: A static anonymous function cannot contain a reference to 'target'.
```

再試改用有 state 參數的 API，值當參數傳進去，`static` 就合法：

```
using System.Collections.Concurrent;

var cache = new ConcurrentDictionary<int, string>();
string prefix = "user-";

string name = cache.GetOrAdd(42, static (id, p) => p + id, prefix);
Console.WriteLine(name);
```

```
user-42
```

這證明`static` 是會被檢查的承諾：想偷偷捕捉，編譯器直接擋。

## Go 對照

Go 的匿名函式也是 closure，也是**抓變數**。Go 規格說，被用到的變數由外層函式和匿名函式**共用**，只要還有人用得到就一直活著。

差在 **for 迴圈**。Go 以前跟 C# 的 for 一樣，整個迴圈共用一個 `i`。**Go 1.22** 起改成**每一圈都有自己的變數**，但只在 `go.mod` 寫 `go 1.22` 以上時生效，舊專案意思不變。

```
package main

import "fmt"

func main() {var prints []func()for i := 0; i < 3; i++ {	prints = append(prints, func() { fmt.Print(i, " ") })}for _, p := range prints {	p()}fmt.Println()
}
```

用 Go 1.25 實際跑，只改 `go.mod` 的 `go` 那一行：

```
go 1.21:  3 3 3
go 1.22:  0 1 2
```

|  | C# for | C# foreach | Go 1.21 以前 | Go 1.22 起 |
| --- | --- | --- | --- | --- |
| 迴圈變數 | 整個迴圈一個 | 每圈一個 | 整個迴圈一個 | 每圈一個 |
| 上面的例子印出 | 3 3 3 | 0 1 2 | 3 3 3 | 0 1 2 |

C# 的 for **從來沒改**，.NET 10 還是印 3 3 3。從 Go 轉過來的人最容易在這裡踩到。

## ✔ 過關標準

能不看筆記回答下面 5 題，這篇就過了。

1. 下面這段印出什麼？lambda 抓到的是哪個變數？怎麼修？
`for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i));`
**印出 333。三個 lambda 抓的都是 for 括號裡那唯一一個 i。**
    - for 的 i 只宣告一次，只有一個櫃子；離開迴圈時它是 3。
    - 修法：迴圈裡先 `int copy = i;`，lambda 改抓 `copy`，印出 012。
2. lambda 捕捉區域變數時，編譯器產生了什麼？為什麼外面改 count，lambda 看得到？
**產生一個隱藏類別（closure），把變數搬進去當欄位，並 new 一個它的物件。**
    - 兩邊的 count 都變成「櫃子.count」，委派的對象就是這個櫃子，所以讀寫同一格。
3. 同樣建三個 lambda，為什麼 foreach 版印 0 1 2？
**foreach 的變數在每一圈裡面宣告，每圈都是新變數，也就是新櫃子。**
4. 熱路徑上的 `Array.Exists(ids, x => x == target)` 每次呼叫配置了什麼？static lambda 幫得上什麼？
**每次 new 一個櫃子放 target，再 new 一個委派，實測 88 bytes。**
    - static lambda 保證不捕捉，誰捕捉就編譯失敗（CS8820），守住零配置；它不會讓有捕捉的版本變快。
5. Go 1.22 改了什麼？C# 的 for 有跟著改嗎？
**Go 1.22 起 for 迴圈每圈都有自己的變數（go.mod 寫 1.22 以上才生效）；C# 的 for 沒改。**

lambda 最常出現的地方是 LINQ；它在迴圈裡每次配置多少、什麼時候才真的執行，後面的筆記會接著拆。

## 參考來源

- [Microsoft Learn — Work with delegate types in C#](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/)（委派是什麼）
- [Microsoft Learn — Lambda expressions](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions)（捕捉外部變數、static lambda）
- [C# 標準 §12.21.6 Outer variables、§12.21.8 Implementation example](https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md)（捕捉延長壽命、for 的 i 宣告在迴圈外、編譯器產生類別的做法）
- [C# 標準 §10.7.2 Evaluation of anonymous function conversions to delegate types](https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/conversions.md)（委派的方法與對象、可重用同一個委派）
- [C# 標準 §13.9.5 The foreach statement](https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md)（foreach 變數每圈一個、早期版本在外面）
- [C# 9 提案 — Static anonymous functions](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-9.0/static-anonymous-functions.md)（避免意外捕捉造成的配置）
- [.NET Blog — Know Thine Implicit Allocations（Stephen Toub）](https://devblogs.microsoft.com/pfxteam/know-thine-implicit-allocations/)（closure 物件＋委派兩次配置、沒捕捉的委派快取在 static 欄位）
- [The Go Programming Language Specification](https://go.dev/ref/spec#Function_literals)（function literal 是 closure、for 每圈獨立變數）
- [Go Blog — Fixing For Loops in Go 1.22](https://go.dev/blog/loopvar-preview)（迴圈變數改成每圈一個、go.mod 控制）
