
遇到的问题在写代码时经常要给std::sort传比较器。有次我需要按与某个阈值距离的远近排序intx10;// 阈值boolcmp(inta,intb){returnstd::abs(a-x)std::abs(b-x);// 发现编译错误cmp 接受不到 x}x 传不进函数体这是 C 的常识。但换 lambda 写std::sort(v.begin(),v.end(),[x](inta,intb){// 捕获 xreturnstd::abs(a-x)std::abs(b-x);});假设 x、cmp 与 lambda 均处于 main 函数的作用域之内但 C 不允许普通函数嵌套定义于函数体内所以 cmp 只能声明于文件作用域其函数体在编译期即与 main 的局部变量隔离。而 lambda 则作为表达式可在 main 内就地创建并将 x 捕获为自身成员。这一特性体现了 lambda 的强大本文将从此思考点出发逐步剖析该表达式。一、lambda 的完整语法格式与编译期本质lambda 的完整形态C17[capture_list](params)mutablenoexcept(expr)-return_type{body}各组成部分的含义如下组成含义说明capture_list捕获列表声明要捕获哪些外部变量以及捕获方式params参数表与普通函数一致可以用 auto 泛型参数泛型 lambdamutable可选关键字解除 operator() 的 const 属性noexcept可选异常说明声明是否抛出异常return_type可选返回值类型C11 起支持自动推导body函数体lambda 被调用时执行的代码去掉全部可选项lambda 的最小形态是[capture](params){ body }。以最简形式为例autoadd[](inta,intb){returnab;};编译器将其展开为一个匿名结构体不同编译器生成的内部名称不同这里以__lambda_1示意struct__lambda_1{intoperator()(inta,intb)const{returnab;}};autoadd__lambda_1{};由此得到一个关键结论lambda 不是函数而是对象。函数体对应其operator()捕获的变量对应其成员变量编译器将 lambda 表达式翻译为一个匿名类并在表达式出现的位置构造该类的对象——add 即为该构造所得的对象实例。二、捕获机制闭包的标准定义是函数与环境的组合。C 的实现方式是把环境可见的局部变量在创建时刻抓取为闭包对象的成员。三种捕获方式的对比捕获方式值捕获 [x]引用捕获 [x]this 捕获创建时拷贝快照默认只读 mutable 解锁成员是引用实时映射外部成员是指针C17 可用 [*this] 取副本风险大对象拷贝开销风险悬垂引用风险this 悬垂2.1 值捕获intx10;autol[x]{returnx;};展开后struct__lambda_2{intx;// 成员变量构造时从外部 x 拷贝intoperator()()const{returnx;}};值捕获在 lambda 对象构造时完成一次拷贝之后与外部变量再无关联——外界修改 x闭包内的副本不受影响闭包内在 mutable 允许时修改副本外部也看不到。2.2 引用捕获intx10;autol[x]{returnx;};展开后成员类型为int。引用捕获不拷贝数据闭包内对 x 的访问实时映射到外部变量。代价是生命周期依赖若 x 先于闭包对象销毁闭包内的引用即悬垂访问是未定义行为UB。2.3 初始化捕获捕获一个表达式autol[ystd::make_uniqueint(42)]{return*y;};此时成员类型由初始化表达式推导这里为std::unique_ptrint为移动语义提供了入口。2.4 this 捕获的两种形态在成员函数内捕获 this成员类型是指针而不是对象本身classWidget{intvalue_;public:autoget_lambda(){return[this]{returnvalue_;};// 捕获 this 指针}};这带来一个经典隐患若 Widget 对象先于 lambda 销毁lambda 内的 this 悬垂。C17 提供[*this]捕获对象副本autoget_lambda(){return[*this]{returnvalue_;};// 捕获对象副本成员类型为 Widget};代价是对象拷贝的开销换来生命周期上的安全。2.5 默认捕获[]与[][]对全部可见自动变量按值捕获[]按引用捕获。两者是语法糖等价于逐一列出捕获项。需要注意的是this 指针在任何成员函数中都是被按值捕获的自动变量因此成员函数内写[]实际会隐式捕获 this这是[]悬垂风险的高发场景代码审查时应格外留意。三、operator() 的 const 语义与 mutable默认情况下匿名类的operator()是 const 成员函数。这意味着值捕获的成员在闭包内是只读的intx10;autol[x]{x;};// 编译错误不能修改 const 成员这一设计符合闭包的函数式语义值捕获产生的是环境快照快照应不可变lambda 表现为纯函数。若确实需要修改副本加mutableintx10;autol[x]()mutable{x;};// 合法修改的是副本// 外部 x 仍为 10需要强调的是mutable 修改的是闭包内的副本与外部变量无关。四、无捕获 lambda 到函数指针的退化函数指针是一种变量其值为某段函数代码的入口地址。调用函数指针时程序跳转至该地址执行函数所需的参数由调用方在调用点传入函数指针本身不携带任何与数据相关的信息。lambda 的 operator() 是成员函数。对于捕获了变量的 lambda其 operator() 在执行时需要访问闭包对象的成员如值捕获变量的副本而成员访问依赖对象地址this。函数指针的调用不提供对象地址——调用方仅跳转至代码地址不携带任何对象信息。因此捕获了变量的 lambda 无法转换为函数指针转换后成员无从访问代码无法正确执行。无捕获 lambda 的 operator() 不访问任何成员变量其执行不依赖对象地址即使不存在闭包对象代码亦可独立运行。此时 lambda 与普通函数在行为上无差别标准因此允许其向同签名的函数指针隐式转换void(*fp)()[]{};// 合法intx10;void(*fp2)()[x]{};// 非法无捕获 lambda 的匿名类不含成员变量属于空类C 规定任何对象至少占用一字节的存储保证不同对象具有不同地址因此 lambda 对象本身占 1 字节。转换为函数指针后对象不再存在调用直接使用代码地址无状态特性由此体现。五、结合std::functionlambda 类型唯一且各不相同而回调容器、事件系统、任务队列需要存放任意可调用对象。std::function通过类型擦除实现这一点容器只保留签名如int(int,int)抹掉具体类型内部通过指向存储的调用目标间接调用。std::functionint(int,int)f;f[](inta,intb){returnab;};f(3,4);// 返回 7类型擦除的代价是显著的其一调用从编译期直接调用退化为运行时间接跳转出现无法内联性能损失其二std::function对小对象有小型缓冲优化SBO缓冲容纳不下的可调用对象需要堆分配拷贝即产生分配开销其三空std::function被调用时抛出std::bad_function_call引入运行时分支。选择原则由此明确类型在编译期可知时用auto保存 lambda零开销、可内联只有确实需要多态地存放可调用对象时才使用std::function。六、生命周期陷阱闭包机制的全部风险集中在一点闭包对象存活期间被捕获对象必须同样存活。常见高发场景一引用捕获悬垂。函数返回 lambda 时lambda 内引用的局部变量已经销毁std::functionint()make(){intx42;return[x]{returnx;};// x 已销毁调用即未定义行为}二this 捕获悬垂。对象先于闭包析构如闭包被异步执行访问即崩溃或读到垃圾数据。应对方案是让捕获对象与闭包共享生命周期在并发场景中最常用的手段是捕获std::shared_ptr——闭包持有引用计数捕获对象存活期被闭包延长直到最后一个闭包销毁。七、并发场景中的应用lambda 的闭包能力在并发编程中承担了核心角色。以线程池为例任务被拍扁为无参无返回值的动作存储于队列std::queuestd::functionvoid()tasks;提交端任意签名的调用被 enqueue 收口templateclassF,class...Argsautoenqueue(Ff,Args...args)-std::futurestd::invoke_result_tF,Args...{usingreturn_typestd::invoke_result_tF,Args...;autotaskstd::make_sharedstd::packaged_taskreturn_type()(std::bind(std::forwardF(f),std::forwardArgs(args)...));std::futurereturn_typerestask-get_future();{std::lock_guardstd::mutexlock(queue_mutex);tasks.emplace([task]{(*task)();});}cv.notify_one();returnres;}任务从提交到获取结果的完整生命周期futureworker线程任务队列提交者futureworker线程任务队列提交者时间解耦可隔任意久enqueue 打包任务入队拿到 future继续干别的notify_one 唤醒锁外执行 (*task)()结果自动写入 shared statefuture.get() 解除阻塞闭包机制在此有三处典型应用参数绑定std::bind将函数与实参绑定为无参可调用对象任务得以统一为void()形态进入队列队列仅需依赖单一接口。捕获共享生命周期packaged_task不可拷贝故以std::shared_ptr包裹lambda 捕获 shared_ptr 副本——任务本体仅存一份lambda 在队列与 worker 之间的拷贝与移动均无风险且 shared_ptr 保证任务在执行前不会被销毁。完美转发std::forwardF(f), std::forwardArgs(args)...保持实参的左右值属性move-only 类型如 unique_ptr的实参得以原样进入任务。值得注意的细节是lambda 捕获 shared_ptr 属于值捕获捕获的是指针本身任务真正的执行期worker 取出并调用可能远晚于提交时刻但 shared_ptr 的生命周期延长机制保证不会引入悬垂。这正是闭包机制在并发程序设计中的价值所在——把一段代码与其需要的环境打包成可传递、可延迟执行的对象。总结lambda 之所以能带走普通函数带不走的局部变量根源在于它的实现形态lambda 不是函数而是编译器生成的匿名类对象函数体对应 operator()捕获的变量对应成员变量创建时刻将外部变量抓为成员之后随对象携带传递。回到开头的问题std::sort 的比较器之所以能捕获阈值 x正是因为 lambda 在创建时把 x 拷贝成了自己的成员环境随对象走调用只传变化的参数。本人能力有限文章如有错误或遗漏之处欢迎指正。参考资料cppreference — Lambda expressionscppreference — std::function《Effective Modern C》《深入理解计算机系统》