Chapter 34: Lambda Expressions
Lambda 表达式把一段可调用逻辑、捕获状态和调用接口压缩成一个表达式。读完本章后,读者应能把一个 lambda 定位成编译器生成的闭包对象,判断它捕获了什么对象,追踪它进入 STL 算法后的复制、调用和生命周期边界,并据此选择值捕获、引用捕获、mutable、generic lambda 或显式函数对象。
本章贯穿下面这个算法场景。代码里有三个 lambda:一个谓词保存筛选参数,一个比较器决定排序顺序,一个输出逻辑读取筛选结果。表面看它们只是短函数,工程上它们都是有类型、有状态、有生命周期的对象。
#include <algorithm>
#include <cstdint>
#include <string>
#include <tuple>
#include <vector>
struct Job {
std::string owner;
int priority;
std::uint64_t deadline;
};
void schedule(std::vector<Job>& jobs, std::uint64_t now) {
std::string current_owner = "build";
int priority_floor = 5;
auto urgent_for_owner = [current_owner, priority_floor, &now](const Job& job) {
return job.owner == current_owner &&
job.priority >= priority_floor &&
job.deadline <= now + 60;
};
std::stable_sort(jobs.begin(), jobs.end(), [](const Job& lhs, const Job& rhs) {
return std::tie(lhs.deadline, rhs.priority) <
std::tie(rhs.deadline, lhs.priority);
});
auto urgent_count = std::count_if(jobs.begin(), jobs.end(), urgent_for_owner);
(void)urgent_count;
}
这段代码的主问题是:lambda 进入 STL 算法以后,算法看到的到底是函数、对象,还是模板实例。答案来自 C++ 语言规则。C++ 工作草案在 lambda expressions 中把 lambda 描述为创建 function object 的表达式;lambda 表达式的结果对象叫 closure object,它的类型叫 closure type。后续所有判断都围绕这个对象展开。
本章不把 lambda 当作语法糖手册处理。每一节都回到同一个判断链:先看闭包类型,再看捕获成员,再看 operator() 的 const 或模板化形态,最后看算法如何保存和调用这个对象。
34.1 closure type 与 lambda 对象模型
Lambda 表达式求值会产生一个闭包对象(closure object)。闭包对象的类型是一个唯一的、未命名的类类型。唯一性意味着即使两个 lambda 的文本完全相同,它们的类型也不同;未命名意味着用户通常通过 auto、模板参数或类型擦除包装器保存它。
在贯穿示例里,urgent_for_owner 的类型不是函数指针,也不是 std::function。它是某个编译器生成的闭包类型。这个闭包类型大致可以理解成下面的教学形状。代码只表达对象关系,不声称匹配某个标准库或编译器的真实内部布局。
struct UrgentForOwnerClosure {
std::string current_owner;
int priority_floor;
std::uint64_t& now;
bool operator()(const Job& job) const {
return job.owner == current_owner &&
job.priority >= priority_floor &&
job.deadline <= now + 60;
}
};
这个形状解释了三个接口现象。第一,lambda 可以传给 std::count_if,因为它的对象能被 operator() 调用。第二,值捕获的 current_owner 和 priority_floor 变成闭包对象自己的状态。第三,引用捕获的 now 让闭包对象在调用时观察外部变量的当前值。
闭包类型直接决定 lambda 的存储成本。空捕获 lambda 通常是空对象,值捕获字符串、数组、智能指针或大型策略对象会把这些状态带进闭包对象。标准允许实现改变闭包类型的大小、对齐和若干类属性,只要不改变程序可观察行为。工程判断时应把闭包大小视为实现相关,把捕获对象的所有权和生命周期视为代码自己的责任。
空捕获 lambda 有一个额外能力:在满足条件时可以转换成函数指针。这个能力来自闭包对象不依赖外部状态。带捕获 lambda 需要闭包对象保存环境,函数指针只能保存代码入口,无法保存每个闭包对象各自的成员状态。
auto plain = [](int x) { return x + 1; };
int (*fp)(int) = plain;
int bias = 1;
auto with_state = [bias](int x) { return x + bias; };
// int (*bad)(int) = with_state; // 带捕获闭包需要对象状态,无法按普通函数指针保存。
把 lambda 当成对象以后,std::stable_sort 的比较器也更清楚。排序算法持有一个比较器对象,并在比较两个元素时调用它。空捕获比较器的对象状态为空,判断结果只来自两个参数;这类比较器最接近普通函数,但仍然有独立闭包类型。
这张图的边界是语言层对象模型。STL 算法不会识别“lambda 语法”,它只接收一个可调用对象。语法在编译期生成闭包类型,运行时算法只保存和调用闭包对象。
34.2 value capture、reference capture 与生命周期
捕获列表决定闭包对象和外部对象之间的关系。值捕获把创建 lambda 时的值保存到闭包对象内部;引用捕获让闭包对象在调用时通过引用访问外部对象。二者的差异落在所有权、可见状态和生命周期三件事上。
贯穿示例里的 current_owner 和 priority_floor 使用值捕获。urgent_for_owner 创建以后,即使外层变量随后被修改,闭包对象内部保存的 owner 和优先级下限仍保持创建时的状态。这适合表达筛选条件快照:算法执行时使用一组稳定参数。
std::string current_owner = "build";
int priority_floor = 5;
const auto urgent_for_owner = [current_owner, priority_floor](const Job& job) {
return job.owner == current_owner && job.priority >= priority_floor;
};
current_owner = "test";
priority_floor = 9;
// urgent_for_owner 仍按 "build" 和 5 判断。
值捕获的对象是闭包对象成员。闭包对象被复制时,捕获成员也按对应类型的复制或移动规则进入新对象。这个结论影响 STL 算法:算法接收谓词或比较器时通常按值接收,闭包对象会被复制到算法内部。捕获状态越大,复制成本越高;捕获对象的复制语义越复杂,算法调用边界越需要检查异常和所有权。
引用捕获表达的是“闭包调用期内访问同一个外部对象”。贯穿示例里的 &now 让筛选逻辑使用调用期间的当前时间。只要 std::count_if 在 schedule 返回前完成,now 的生命周期覆盖闭包调用期,这个引用关系成立。
auto near_deadline = [&now](const Job& job) {
return job.deadline <= now + 60;
};
now += 30;
// near_deadline 下一次调用会看到更新后的 now。
引用捕获的风险来自使用期延长。把引用捕获的 lambda 返回、存入容器、交给异步任务或保存到回调系统时,闭包对象可能在外层局部变量销毁之后继续存在。此时闭包内部保留的引用已经悬垂,调用结果进入未定义行为。
#include <functional>
std::function<bool(const Job&)> make_filter() {
std::string owner = "build";
return [&owner](const Job& job) {
return job.owner == owner;
};
}
这段代码的错误点在返回值。owner 是 make_filter 的局部对象,函数返回后生命周期结束;返回的闭包仍保存对它的引用。稳定写法是值捕获,或者用初始化捕获显式转移所有权。
std::function<bool(const Job&)> make_filter(std::string owner) {
return [owner = std::move(owner)](const Job& job) {
return job.owner == owner;
};
}
初始化捕获从 C++14 起可用,它允许在捕获列表里声明闭包成员并用表达式初始化。这个能力常用于 move-only 对象,例如 std::unique_ptr、大字符串、预编译策略对象或连接句柄。它把“外部对象名字”扩展成“闭包对象成员初始化”。
#include <memory>
std::unique_ptr<int> threshold = std::make_unique<int>(5);
auto above_threshold = [limit = std::move(threshold)](int value) {
return value > *limit;
};
this 捕获需要单独判断。[this] 捕获的是当前对象指针,闭包调用时通过指针访问成员;它要求当前对象覆盖闭包调用期。C++17 的 [*this] 捕获当前对象副本,闭包持有的是对象快照。二者在回调和异步任务里差异很大:指针捕获依赖原对象仍存活,副本捕获依赖对象可复制并接受快照语义。
struct Scheduler {
int limit = 5;
auto pointer_filter() {
return [this](const Job& job) { return job.priority >= limit; };
}
auto snapshot_filter() const {
return [*this](const Job& job) { return job.priority >= limit; };
}
};
捕获方式的判断顺序可以固定下来:闭包是否会离开当前语句或当前函数;被捕获对象是否需要反映后续修改;对象复制成本和所有权是否可接受;算法是否可能复制谓词或比较器;调用路径是否跨线程、延迟执行或长期保存。先回答这几个问题,再选择值捕获、引用捕获、初始化捕获或显式对象参数。
34.3 mutable lambda 与状态修改
默认情况下,非静态 lambda 的调用运算符是 const 成员函数。这个规则让值捕获成员在 lambda 体内按只读状态使用。mutable 改变的是闭包类型的 operator() 声明:它让调用运算符成为非 const 成员函数,从而允许修改闭包对象内部的值捕获成员。
下面的计数器展示了 mutable 修改的是闭包自己的状态。start 被值捕获后形成闭包成员,调用时递增的是这个成员,外部变量保持原值。
int start = 0;
auto next_id = [start]() mutable {
return ++start;
};
int a = next_id(); // 1
int b = next_id(); // 2
// 外部 start 仍为 0。
这个现象和普通对象成员函数一致。可以把它想成下面的教学代码:默认 lambda 的 operator() 类似 const 成员函数,mutable lambda 的 operator() 类似非 const 成员函数。
struct NextIdClosure {
int start;
int operator()() {
return ++start;
}
};
mutable 不会把值捕获变成引用捕获。它只允许闭包对象修改自己的副本。外部对象是否被修改,仍由捕获方式决定。引用捕获访问的是外部对象,即使默认 operator() 是 const,通过引用修改外部非 const 对象依然成立,因为 const 限制落在闭包对象自身,不把被引用对象自动变成 const。
int external = 0;
auto bump_external = [&external] {
return ++external;
};
int x = bump_external(); // external 变为 1
mutable 与 STL 算法结合时,最容易误判的是状态归属。算法接收 lambda 时通常复制闭包对象,算法内部调用的是自己的副本。外部那个 lambda 对象是否被改变,取决于算法接口和实现路径,不能把外部对象的状态变化当成算法结果的一部分。
std::vector<int> values{1, 2, 3, 4};
auto first_two = [seen = 0](int) mutable {
return ++seen <= 2;
};
auto n = std::count_if(values.begin(), values.end(), first_two);
(void)n;
这个谓词把“第几次调用”混入筛选逻辑。顺序算法在一次调用中会推进内部副本的 seen,但算法有权复制谓词对象;并行算法还会引入更强的顺序和共享状态限制。工程上,谓词和比较器应优先表达参数到布尔值的稳定映射。需要计数、采样或统计时,把状态管理放在算法外层,或者使用明确的循环更容易审查。
mutable 更适合构造局部生成器、一次性消费对象、缓存型函数对象和测试桩。它不适合让排序比较器或查找谓词依赖调用次数。排序比较器必须在整个排序过程中提供稳定的严格弱序;如果比较结果随调用次数变化,排序算法的前提被破坏,结果没有工程意义。
34.4 generic lambda
Generic lambda 是带泛型调用运算符的闭包对象。C++14 起,lambda 形参可以使用 auto;C++20 起,可以在 lambda 上显式写模板参数列表。两种写法都会让闭包类型拥有函数调用运算符模板。
auto less_by_deadline = [](const auto& lhs, const auto& rhs) {
return lhs.deadline < rhs.deadline;
};
这段代码可以理解成一个闭包类型里有模板化的 operator()。每组实参类型会实例化对应的调用路径。捕获状态属于闭包对象本身,模板实例只决定参数和返回类型的编译期形状。
struct LessByDeadlineClosure {
template <class L, class R>
bool operator()(const L& lhs, const R& rhs) const {
return lhs.deadline < rhs.deadline;
}
};
Generic lambda 的工程价值在于把小策略写成类型可推导的对象。它适合算法适配器、投影函数、测试辅助逻辑和跨多个相似结构的局部策略。代价是错误会在模板实例化时暴露;当实参类型没有 deadline 成员时,错误位置会落在 lambda 体内。
C++20 显式模板参数和 requires 约束可以把错误边界提前到接口位置。下面的写法把“对象必须有可比较的 deadline 成员”写进 lambda 的调用接口。它仍是 lambda,但可读性更接近受约束的函数模板。
auto less_by_deadline_checked = []<class T>(const T& lhs, const T& rhs)
requires requires { lhs.deadline < rhs.deadline; }
{
return lhs.deadline < rhs.deadline;
};
auto&& 形参让 generic lambda 进入转发引用路径。它适合写包装器和转发器,但读者需要同时检查 value category、生命周期和转发目标。局部算法谓词通常使用 const auto& 更稳,因为谓词一般只读元素,且能接收不同值类型的元素引用。
auto has_owner = [](const auto& item) {
return !item.owner.empty();
};
Generic lambda 进入 STL 算法以后,算法仍然只接收一个闭包对象。差异在调用时出现:非泛型 lambda 调用一个普通 operator(),generic lambda 调用某个 operator()<...> 实例。算法不需要知道这个差异,编译器会在算法模板实例化期间完成可调用性检查。
这个边界解释了为什么 generic lambda 常和算法错误信息绑定在一起。std::sort 用比较器调用两个元素,如果比较器 lambda 的 auto 参数无法接受元素引用,或者返回值无法转成 bool,错误会在 std::sort 实例化路径上展开。排查顺序是先确认算法需要的可调用签名,再确认 lambda 参数和返回值,最后检查 lambda 体内访问的成员或表达式。
34.5 lambda 与 STL 算法
STL 算法把 lambda 当作可调用对象处理。算法的输入通常是迭代器范围和一个策略对象:谓词、比较器、变换函数、投影函数或输出动作。Lambda 只是构造这些策略对象的最短写法,真正的约束来自算法契约。
贯穿示例中,std::count_if 需要一元谓词:输入一个 const Job&,返回可转换为 bool 的结果。urgent_for_owner 满足这个形状,并且捕获的筛选参数在算法调用期间有效。此时 lambda 的检查重点是捕获状态和生命周期。
auto urgent_count = std::count_if(jobs.begin(), jobs.end(), urgent_for_owner);
std::stable_sort 需要比较器:输入两个元素引用,返回 lhs 是否应排在 rhs 前面。比较器必须形成严格弱序。空捕获 lambda 很适合表达纯比较规则,因为它没有外部可变状态,比较结果只依赖两个元素。
std::stable_sort(jobs.begin(), jobs.end(), [](const Job& lhs, const Job& rhs) {
return std::tie(lhs.deadline, lhs.priority) <
std::tie(rhs.deadline, rhs.priority);
});
比较器捕获配置值时,推荐捕获不可变快照。例如排序方向、阈值、区域配置和字段选择规则都可以值捕获。比较器引用捕获外部可变对象时,需要确认外部对象在排序过程中保持稳定。排序算法会多次调用比较器;如果外部状态在排序中途改变,同一对元素可能得到不同结果。
bool earlier_first = true;
auto by_deadline = [earlier_first](const Job& lhs, const Job& rhs) {
if (earlier_first) {
return lhs.deadline < rhs.deadline;
}
return rhs.deadline < lhs.deadline;
};
Lambda 捕获容器元素引用时,需要额外检查元素移动和迭代器失效。排序、重排、erase-remove、vector 扩容都可能移动元素或改变对象位置。闭包里保存对某个元素的引用,再把同一容器交给修改算法,引用目标可能被移动、销毁或语义变化。
Job& pivot = jobs.front();
auto near_pivot = [&pivot](const Job& job) {
return job.deadline < pivot.deadline + 60;
};
// 如果后续对 jobs 排序或扩容,pivot 引用和它表达的语义都需要重新审查。
算法中的 lambda 判断顺序可以固化成六步。第一,确认算法需要一元谓词、二元比较器、变换函数还是输出动作。第二,确认 lambda 参数能绑定算法传入的元素引用,返回值满足算法要求。第三,确认捕获对象是快照、共享状态还是外部引用。第四,确认闭包对象是否会被复制、保存、延迟调用或跨线程调用。第五,确认谓词和比较器的结果不依赖调用次数、可变全局状态或正在被算法修改的对象位置。第六,确认捕获对象的复制、移动、析构和异常边界符合算法调用成本。
这个顺序能覆盖大部分 STL 场景。find_if、count_if、remove_if 重点看谓词是否稳定以及捕获对象生命周期;sort、stable_sort、binary_search 重点看比较器是否与排序前提一致;transform 重点看输出区间和变换函数是否匹配;for_each 允许把副作用集中在一个可调用对象里,但副作用对象的所有权仍要明确。
Lambda 和显式函数对象的选择不取决于“写法长短”。局部、一次性、捕获少的策略适合 lambda;需要复用、命名语义、复杂状态、不变量维护或单独测试的策略适合显式函数对象。std::function 适合运行时保存异构可调用对象,但它引入类型擦除和潜在分配,下一章会单独展开。
最小自检任务
阅读下面代码,判断每个 lambda 的闭包状态、捕获边界和算法结果。重点说明 value_pred、ref_pred、next_id 三个对象分别保存了什么,以及外部变量修改后会影响哪一个调用路径。
#include <algorithm>
#include <vector>
int main() {
std::vector<int> values{4, 5, 6};
int limit = 3;
auto value_pred = [limit](int value) {
return value > limit;
};
auto ref_pred = [&limit](int value) {
return value > limit;
};
limit = 5;
auto value_count = std::count_if(values.begin(), values.end(), value_pred);
auto ref_count = std::count_if(values.begin(), values.end(), ref_pred);
auto next_id = [id = 0]() mutable {
return ++id;
};
int a = next_id();
int b = next_id();
auto copied = next_id;
int c = copied();
int d = next_id();
(void)value_count;
(void)ref_count;
(void)a;
(void)b;
(void)c;
(void)d;
}
答案要点
value_pred 使用值捕获,闭包对象里保存的是创建 lambda 时的 limit 值,也就是 3。后续外部 limit = 5 不改变这个闭包成员,所以 values{4, 5, 6} 中三个元素都满足 value > 3,value_count 为 3。
ref_pred 使用引用捕获,闭包调用时读取外部 limit 当前值。执行 std::count_if 时外部 limit 已经是 5,所以只有 6 满足 value > 5,ref_count 为 1。这个判断成立的前提是 limit 的生命周期覆盖 ref_pred 的调用期。
next_id 使用初始化值捕获创建闭包成员 id,并通过 mutable 修改闭包自己的成员。第一次和第二次调用同一个闭包对象,a 为 1,b 为 2。copied 是在 next_id 的 id 已经为 2 时复制出来的新闭包对象,所以 c 为 3。d 调用原来的 next_id,原对象的 id 也从 2 递增到 3,因此 d 为 3。
这段代码的迁移判断是:值捕获看创建时快照,引用捕获看调用时外部对象,mutable 看闭包对象自身副本。进入 STL 算法时,先检查算法需要的可调用签名,再检查捕获对象生命周期和闭包复制边界。
本章知识点总结
- 闭包对象:lambda 表达式求值得到闭包对象,算法接收和调用的是这个对象。
- 闭包类型:每个 lambda 表达式都有唯一未命名闭包类型,即使文本相同也属于不同类型。
- 调用运算符:非泛型 lambda 生成普通
operator(),generic lambda 生成调用运算符模板。 - 空捕获:空捕获 lambda 不依赖外部状态,在满足条件时可以转换为函数指针。
- 值捕获:值捕获把创建时的对象状态保存进闭包成员,后续外部修改不影响该成员。
- 引用捕获:引用捕获让闭包调用时访问外部对象,外部对象生命周期必须覆盖闭包调用期。
- 初始化捕获:初始化捕获在捕获列表里创建闭包成员,适合转移所有权和保存 move-only 对象。
- this 捕获:
[this]保存对象指针,[*this]保存对象快照,二者的生命周期和语义边界不同。 - mutable:
mutable让调用运算符成为非const成员函数,从而允许修改值捕获成员。 - 状态归属:算法通常复制谓词或比较器,
mutablelambda 的状态变化属于被调用的闭包对象副本。 - 泛型路径:generic lambda 的
auto参数按模板推导实例化,错误常在算法模板实例化时暴露。 - 算法契约:lambda 进入 STL 算法后必须满足对应谓词、比较器或变换函数的签名和语义要求。
- 比较器稳定:排序比较器应提供稳定严格弱序,结果不应依赖调用次数或排序过程中变化的状态。
- 判断顺序:先看算法契约,再看闭包类型、捕获对象、生命周期、复制边界和副作用范围。