你在迴圈裡替三顆按鈕各掛一個點擊動作,按下去要印出自己的編號: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。Action bark = dog.Bark;,對象就是那隻 dog。x * x 只用到參數,用不到對象;下一步它是主角。呼叫 square(5),就是拿對象去跑方法。Func 和 Action 能不能互換標籤,〈共變與逆變〉講過,這裡不重複。
圖 A:委派是一個物件,裡面記著方法和對象(點圖可放大)
一句話:委派=方法+對象,包成一個物件。lambda 是建委派最短的寫法。
區域變數平常跟著方法走:方法結束,變數就沒了。但 lambda 可以先存起來,晚點才呼叫,那時方法可能早就結束了。
lambda 用到外面的區域變數,叫做捕捉。被捕捉的變數得活得比方法久。這一步看編譯器怎麼辦到。
int count = 0;
Action add = () => count++; // 捕捉了 count
C# 規格描述的做法是把變數搬家:
count 變成它的欄位。這就是置物櫃,正式名稱叫 closure。count 所在的範圍(宣告它的那對大括號),就 new 一個櫃子。之後方法裡寫的 count,其實都是「櫃子.count」。翻成一般 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++;
}
圖 B:捕捉一個變數,編譯器做的四件事(動畫會循環,點圖可放大)
所以方法和 lambda 開的是同一格。方法把 count 改成 100,lambda 下次讀到的就是 100(練習 1)。櫃子會一直活著,直到委派沒人用、被 GC 回收。
一句話:lambda 捕捉的是變數本身,不是當下的值。變數被搬進 closure 物件,大家共用。
回到開頭的三顆按鈕。既然 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。
圖 C:for 的 i 只宣告一次,三個 lambda 共用一個櫃子(動畫會循環,點圖可放大)
修法:在迴圈裡面宣告一個新變數,lambda 改抓它。迴圈本體每跑一圈,copy 就被宣告一次,等於每圈開一個新櫃子。
for (int i = 0; i < 3; i++)
{
int copy = i; // 每圈一個新變數
actions.Add(() => Console.Write($"{copy} "));
} // 印出:0 1 2
圖 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 出來的物件,用完要等 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。圖 E:有捕捉每次都 new 兩個物件;沒捕捉只建一次(點圖可放大)
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 鎖住「零配置」。
if 裡,每次呼叫仍白花 24 bytes(實測)。把變數宣告搬進那個 if,就降到 0。Task.Run(() => Use(i))。跟按鈕同一個坑,但更難抓:工作什麼時候跑不一定,讀到的 i 每次可能不同。一樣用 int copy = i; 修。先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都把 Program.cs 整個換掉再執行。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。
建檔把 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)。
建檔把 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)。
建檔把 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)。
建檔把 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 的匿名函式也是 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 題,這篇就過了。
for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i));看答案收合int copy = i;,lambda 改抓 copy,印出 012。Array.Exists(ids, x => x == target) 每次呼叫配置了什麼?static lambda 幫得上什麼?看答案收合lambda 最常出現的地方是 LINQ;它在迴圈裡每次配置多少、什麼時候才真的執行,後面的筆記會接著拆。