程式語言 · 委派與 Lambda

委派與 Lambda:lambda 抓的是變數,不是值(C# closure,附 Go 對照)

Chance Lu · 學習文章 · 2026-11-04 23:32
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。這個物件記兩件事:

呼叫 square(5),就是拿對象去跑方法。Func 和 Action 能不能互換標籤,〈共變與逆變〉講過,這裡不重複。

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 = () => 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 < 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 直接抓 i13 3 3
for,迴圈裡先 int copy = i;30 1 2
foreach (var n in ...)30 1 2
一句話:先找 lambda 抓的變數宣告在哪。在迴圈外(包括 for 的 i)=大家共用一格;在迴圈裡(包括 foreach 的變數)=每圈一格。

第四步:每個櫃子都是一次 new,熱路徑會痛

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

static bool HasId(int[] ids, int target) =>
    Array.Exists(ids, x => x == target);   // 捕捉了參數 target
有捕捉:x => x == target 第 1 次呼叫 new 櫃子 new 委派 第 2 次呼叫 new 櫃子 new 委派 每次呼叫 88 bytes;一百萬次約 88 MB,全部等 GC 回收 沒捕捉:x => x > 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 鎖住「零配置」。

常見的坑

動手做

先開一個練習資料夾:

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# forC# foreachGo 1.21 以前Go 1.22 起
迴圈變數整個迴圈一個每圈一個整個迴圈一個每圈一個
上面的例子印出3 3 30 1 23 3 30 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;它在迴圈裡每次配置多少、什麼時候才真的執行,後面的筆記會接著拆。

參考來源