1. 引言
當你第一次看到 lambda 的 [] 時,很容易想:「方括號不過是語法的一種裝飾」。實際上這是非常實用的部分。預設情況下 lambda 只能「看到」它的參數和在其主體內宣告的東西。外部的一切——就像另一個宇宙,而捕獲就是在兩者之間搭建橋樑。
想像有一個由使用者設定的變數 minPriority,你想檢查任務是否「優先級不低於 minPriority」。沒有捕獲,你要麼把 minPriority 作為參數傳入每個檢查函式,要麼使用全域變數(而全域變數就像在會議時把聊天室一直開著:總有一天會很尷尬)。
捕獲解決了這個問題:我們可以把 minPriority 的值「綁定」到 lambda 裡面。
稍微正式一點但用通俗語言說:capture list(捕獲列表)是位於 [...] 的部分,它說明了 lambda 會使用哪些外部變數以及如何使用它們。
概念層面的簡單示意:
flowchart LR
A["外部變數 minPriority"] -->|捕獲| B["Lambda(可呼叫物件)"]
B --> C["呼叫: pred(task)"]
C --> D["結果: true/false"]
2. 背景上下文:用於示例的 TaskBoard
在討論捕獲之前,固定一下我們的「世界」會很有幫助。我們需要一個任務結構和一小組測試資料,這樣範例就不是抽象的「x 和 y」,而更像真實代碼。後面講解中我們會展示可以放入單個 main.cpp 的小片段。
#include <iostream>
#include <string>
#include <vector>
struct Task {
std::string title;
int priority = 0; // 1..5,5 表示「很緊急」
bool done = false;
};
創建一些測試任務:
int main() {
std::vector<Task> tasks = {
{"Buy milk", 2, false},
{"Fix bug #123", 5, false},
{"Read C++ book", 3, true}
};
std::cout << "Tasks: " << tasks.size() << '\n'; // Tasks: 3
}
目前這只是個簡單的模板。本講不會構建完整的 CLI、選單或命令解析,以免分散注意力。我們的目標是學會通過捕獲為小函數規則(lambdas)「掛載配置」。
3. 按值捕獲:[x] 與模式 [=]
按值捕獲通常是最先被理解的。它的意思是:「拿一個變數,將它的副本放到 lambda 裡面」。因此 lambda 變得自給自足:即使外部變數之後被修改,lambda 仍然記得創建時的舊值——就像一張照片:你可以換髮型,但照片不會變。
逐一捕獲 [x]
假設使用者設定了最小優先級:
#include <iostream>
#include <string>
#include <vector>
int main() {
int minPriority = 3;
auto isImportant = [minPriority](int p) {
return p >= minPriority;
};
std::cout << isImportant(2) << '\n'; // 0
std::cout << isImportant(5) << '\n'; // 1
}
這裡 [minPriority] 的意思是:「把 minPriority 的副本拷貝到 lambda 裡」。之後 lambda 有自己的內部值。
一個重要的效果是:如果在創建 lambda 之後你改變了外部的 minPriority,lambda 不會改變,因為它保存的是當時的副本。
#include <iostream>
int main() {
int minPriority = 3;
auto check = [minPriority](int p) { return p >= minPriority; };
minPriority = 5; // 改變外部變數
std::cout << check(4) << '\n'; // 1(閾值仍然是 3)
}
按值捕獲全部:[=]
有時 lambda 使用很多外部變數,一一列出很煩。這時有個「預設按值」的模式:[=]——把所有被使用的外部變數都按值捕獲。
#include <iostream>
int main() {
int minPriority = 3;
bool showDone = false;
auto allow = [=](int p, bool done) {
return (p >= minPriority) && (showDone || !done);
};
std::cout << allow(4, false) << '\n'; // 1
std::cout << allow(4, true) << '\n'; // 0
}
這個方法很便利,但也引出了第一個設計上的困擾。[=] 可能會「捕獲到多餘的變數」——導致閱讀程式碼時不易察覺。很多團隊因此偏好明確列出捕獲變數(例如 [minPriority, showDone]),雖然多寫幾個字,但意圖更清楚。
歷史上圍繞「隱式捕獲」及其行為在 C++ 標準中有不少討論和調整,目的是簡化或澄清行為,這點可以從相關採納的變更中看出。
4. 按引用捕獲:[&x] 與模式 [&]
現在介紹更「危險但強大」的版本。按引用捕獲表示:「不要複製值,而是讓 lambda 訪問原始變數」。也就是說 lambda 指向同一塊記憶體、同一個物件。如果外部變數變了,lambda 會看到變化;如果 lambda 改了變數,外部也會看到。
聽起來很理想。而且確實很好……直到你忘記考慮變數的生命週期。不過典型的陷阱稍後會講到。
逐一按引用捕獲 [&x]
我們要計算「重要」任務(優先級 >= 4)的數量,並在外部累加 count。
#include <iostream>
#include <vector>
#include <string>
struct Task { std::string title; int priority; bool done; };
int main() {
std::vector<Task> tasks = {{"A", 2, false}, {"B", 5, false}, {"C", 4, true}};
int count = 0;
auto consider = [&count](const Task& t) {
if (t.priority >= 4) ++count;
};
for (const auto& t : tasks) consider(t);
std::cout << count << '\n'; // 2
}
如果我們把 count 按值捕獲([count]),增加會發生在副本上,外部值不會改變。
按引用捕獲全部:[&]
與 [=] 類似,但對引用適用:[&] 表示「所有使用到的外部變數都按引用捕獲」。
#include <iostream>
int main() {
int a = 10;
int b = 20;
auto addToA = [&](int x) { a += x + b; };
addToA(5);
std::cout << a << '\n'; // 35
}
這很方便,但在可讀性上有時比 [=] 更糟。看到 [&] 時,你應該想到:「這個 lambda 可能會碰觸周圍的所有東西」。有時確實需要(例如很短的本地代碼),但通常還是明確列出實際使用到的變數更好:[&a, b]、[&count] 等。
另外,在標準討論裡有關於捕獲如何與名稱隱藏(hiding)互動的議題和 issue,這說明了這個主題並非表面上那麼簡單。
如何選擇:按值或按引用
學捕獲時,大腦先想記住「語法」,但真正的重點是選擇一個合適的「資料所有權模型」:是複製還是引用。下面是一個便於記憶的速查表,幫助在還沒形成直覺時做決策。
| 寫法 | 含義 | 適用情況 | 風險 |
|---|---|---|---|
|
在 lambda 內部放入 x 的副本 | x 是設定/閾值;不希望外部影響 | 可能不小心複製大型物件(字串/向量) |
|
在 lambda 內部引用外部的 x | 需要修改 x 或觀察其變化 | x 必須比 lambda 的使用期更長 |
|
所有被使用的外部變數都以副本捕獲 | 短小的 lambda,外部變數不多 | 可能無意中捕獲多餘東西,難以察覺 |
|
所有被使用的外部變數都以引用捕獲 | 非常本地化的 lambda,位於小區塊內 | 生命週期風險 + 「會碰觸周圍所有東西」的副作用 |
對初學者的一個實用規則:如果變數是「設定」(閾值、限制、模式),按值捕獲通常更安全且更易理解。如果變數是「計數器」、「結果」或「累加器」,通常需要按引用捕獲。
5. 混合捕獲:比 [=] 和 [&] 更精確
當你習慣後,下一步的自然需求是:「我想把一部分按值捕獲,另一部分按引用捕獲」。這是很好的做法,因為它讓意圖更明確:哪些是配置,哪些是可變狀態。
語法是選擇一個 capture‑default,然後對個別變數做例外。比如 [=, &count] 表示:「預設按值,但 count 按引用」。
在 TaskBoard 的例子中:把閾值 minPriority 當作設定,把計數器 matched 當作我們想改變的結果。
#include <iostream>
#include <string>
#include <vector>
struct Task { std::string title; int priority; bool done; };
int main() {
std::vector<Task> tasks = {{"A", 2, false}, {"B", 5, false}, {"C", 4, true}};
int minPriority = 4;
int matched = 0;
auto check = [=, &matched](const Task& t) {
if (t.priority >= minPriority) ++matched;
};
for (const auto& t : tasks) check(t);
std::cout << matched << '\n'; // 2
}
為什麼這樣寫好看:minPriority 表現為「凍結的設定」,而 matched 是可變的結果。通常這樣的程式比使用廣泛的 [&](過於籠統)更易讀,也比只用 [=](計數器不會對外改變)更可靠。
6. mutable:lambda 內的狀態
當按值捕獲時,lambda 保存的是副本。預設情況下 C++ 對這些副本視為不可改變的,這樣做並不是為了刁難,而是讓 lambda 預設表現得像「純函數」:接收參數並返回結果,不在內部修改狀態。
但有時你希望 lambda 擁有內部狀態。例如,一個簡單的「下一個 ID」生成器。這時可用 mutable:它允許修改那些被按值捕獲的副本。
做一個簡單的任務 id 生成器。注意:外部的 nextId 不會改變——只有 lambda 內的副本會變。
#include <iostream>
int main() {
int nextId = 0;
auto gen = [nextId]() mutable {
++nextId;
return nextId;
};
std::cout << gen() << ' ' << gen() << '\n'; // 1 2
std::cout << nextId << '\n'; // 0
}
這通常會讓初學者先驚訝再高興,但也可能促使人開始寫「有 200 行狀態的超級 lambda」。那種做法不推薦。但一個 3–5 行的小型 mutable 生成器是合理的工具。
7. 引用捕獲下的生命週期問題
接下來是引用捕獲成為臭名昭著 bug 根源(以及那些「為什麼它只在星期二工作」的笑話)的主要原因。
如果你按引用捕獲一個變數,lambda 並不擁有它。lambda 只保存了一個引用。如果變數已經死亡而你仍然調用 lambda,lambda 就會訪問一個「幽靈」。在 C++ 中這類情況通常不是編譯錯誤,而是未定義行為(程式可能做任何事)。
看一個會編譯但邏輯上危險的例子:我們在一個區塊內創建 lambda 並捕獲 x,然後把 lambda 儲存起來以後面再呼叫。
#include <iostream>
int main() {
auto f = [] { return 0; };
{
int x = 10;
f = [&x] { return x; }; // x 被按引用捕獲
} // x 在此被銷毀
std::cout << f() << '\n'; // 危險:x 已不存在
}
用時間線來「看」這個問題:
timeline
title 變數 x 的生命週期與 lambda 的使用
section 區塊 { ... }
x 存在 : a1, 1
lambda 捕獲 &x : a2, 1
區塊結束,x 被銷毀 : a3, 1
section 區塊之外
呼叫 lambda : b1, 1
lambda 讀取 x(但 x 已不存在) : b2, 1
對於本課程的建議總結:如果你立刻在同一處呼叫 lambda,按引用捕獲是安全的。如果打算把 lambda 存起來以後再用,按值捕獲通常更安全(或者需要其他設計手段,這些會在進階主題中討論)。
8. 可讀性:名稱隱藏與預設捕獲
到這個階段常見的問題不是程式不運行,而是程式難以閱讀。Lambda 很緊湊,捕獲預設([&]、[=])讓它們更緊湊,但也可能導致你寫出讀不懂的程式:不清楚某個變數是哪來的、誰在改它。
有關 lambda 捕獲與參數造成的「名稱隱藏(hiding)」的討論很多——就是參數或捕獲名字遮蔽其他名字,讓閱讀者混淆。這不是抽象哲學,而是影響程式可維護性的實際問題。
一個簡單的日常例子:你捕獲了 minPriority,同時在 lambda 內又用同名參數。結果會很讓人困惑。
#include <iostream>
int main() {
int minPriority = 3;
auto f = [minPriority](int minPriority) { // 參數遮蔽了捕獲
return minPriority; // 這裡是參數,不是捕獲
};
std::cout << f(10) << '\n'; // 10
}
編譯器不會「幫你改正糟糕的設計」。因此簡單規則:不要給參數或本地變數取與捕獲相同的名字。多幾個字母的名字能減少大量認知上的痛苦。
9. 捕獲中常見錯誤總結
錯誤 1:忘記捕獲,然後對編譯錯誤感到驚訝。
典型情況:lambda 內使用了外部變數 minPriority,編譯器報錯說它「未被捕獲」。這不是挑剔:依規則 lambda 是隔離的,除非你在 [...] 裡建立橋樑。解法簡單:顯式加上 [minPriority] 或 [&minPriority],並有意識地選擇模式(副本還是引用)。
錯誤 2:按值捕獲但期望修改外部變數。
初學者常寫 [count](){ ++count; },期待外部的 count 被增加。但實際上只有副本被改變,外部計數器不變。若需要修改外部狀態,應按引用捕獲:[&count]。如果目的是讓 lambda 自己在內部計數,則用 mutable,但要記住那是 lambda 的內部狀態,不是外部變數被修改。
錯誤 3:按引用捕獲導致生命週期問題。
最危險的一類:你按引用捕獲了 &x,但 lambda 的存在期超過了 x(例如把 lambda 存到變數裡晚些時候呼叫,或從函數回傳 lambda)。結果將是對不存在記憶體的訪問。這在實際中常表現為「有時候工作,有時候不工作」,遠比編譯錯誤難追查。若打算後用 lambda,通常選擇按值捕獲更安全。
錯誤 4:過度使用 [&] 導致意外副作用。
[&] 看起來「超方便」,但讀者(包括一週後的你)會不知道哪些變數實際被使用和修改。此外 lambda 可能意外改變周圍的許多東西,讓一行多餘的代碼把本地檢查變成副作用的源頭。通常更安全也更清晰的方式是精確列出捕獲,例如 [&matched, minPriority] 或 [&total]。
錯誤 5:過度使用 [=] 導致意外的大量拷貝。
[=] 可能會不經意地拷貝大型物件(例如 std::string, std::vector)。有時這無妨,但有時你會發現自己「突然」在為了一行檢查拷貝了半個應用。當你捕獲到大型物件且不想拷貝時,應使用引用([&big])——但那又帶來生命週期的要求。
GO TO FULL VERSION