首页 / 资讯中心 / 文章详情

浅谈c++11闭包的实现

浅谈c++11闭包的实现 ★ FEATURED ARTICLE
前言「闭包」这个词常被说得玄乎其实定义很朴素一个函数加上它捕获的外部变量环境。C11 之前要表达这种「带状态的可调用对象」只能手写一个类并重载operator()或者用std::bind拼凑。C11 引入的 lambda 表达式让编译器替你生成这个类语法上接近函数语义上仍然是一个对象。常见的误解有两个。第一以为 lambda 是「轻量函数指针」——只有不捕获任何变量的 lambda 才能转换成函数指针带捕获的 lambda 是一个有数据成员的类实例大小至少等于捕获内容。第二以为按值捕获[]是「拷贝整个作用域」——它只拷贝 lambda 体内实际用到的变量并且在 lambda创建时完成拷贝之后外部变量再变也不影响副本。本文从编译器如何把 lambda 翻译成一个类入手讲清捕获方式、mutable、闭包类型的身世、std::function的类型擦除以及引用捕获带来的生命周期风险。示例以 C17 为基准C14 的初始化捕获与 C20 的改动会单独标注。一、lambda 的语法与闭包类型一个 lambda 表达式由五部分组成[捕获列表](参数列表) mutable 异常说明 - 返回类型 { 函数体 }除捕获列表和函数体外其余都可省略。编译器为每个 lambda 表达式生成一个唯一的、未命名的类类型称为闭包类型closure type。这个类有一个operator()参数和返回类型对应 lambda 的签名每个被捕获的变量成为该类的一个非静态数据成员默认构造函数被删除拷贝赋值运算符被删除拷贝/移动构造函数按成员规则隐式生成没有捕获时提供一个转换到函数指针的转换函数。因此auto f [](int x) { return x 1; };里的f不是函数而是一个对象。decltype(f)是一个你写不出来的类型每个 lambda 表达式的类型都不同即使签名完全一样。二、编译器展开与捕获列表的细则理解闭包最好的方式是看它展开后的样子。下面这个捕获了两个变量并做累加的 lambda// 适用于 C17int base 10;int step 2;auto add [base, step](int x) {return base step * x;};编译器生成的类大致等价于// 适用于 C17 —— 编译器生成的闭包类型示意class __Lambda_1 {public:__Lambda_1(int base_, int step_) : base(base_), step(step_) {}int operator()(int x) const {return base step * x;}private:int base;int step;};// 使用处__Lambda_1 add{base, step};两个要点捕获的变量是数据成员在构造时按值拷贝operator()默认带const所以函数体内不能修改按值捕获的成员。加上mutable就是去掉这个const// 适用于 C17int counter 0;auto next [counter]() mutable {return counter; // 修改的是闭包自己的成员副本};调用next()会递增闭包内部的那份counter外部的counter始终是 0。这也说明闭包对象是可变的——同一个next多次调用会看到累积效果。按引用捕获则完全不同闭包内部存放的是引用实现上通常是指针操作的是外部对象本身// 适用于 C17int counter 0;auto next [counter]() {return counter; // 修改外部变量};写法含义[]不捕获任何变量[x]按值捕获x[x]按引用捕获x[]按值捕获体内用到的所有自动变量[]按引用捕获体内用到的所有自动变量[, x]默认按值x例外按引用[, x]默认按引用x例外按值[this]捕获当前对象的指针[*this]按值捕获当前对象C17[y x 1]初始化捕获创建新成员C14几个容易忽略的规则[]不会捕获this本身而是捕获this指针成员函数里访问成员变量仍然是通过指针。[]也不能捕获「只在if constexpr未选中分支里出现」的变量捕获是编译期按实际使用决定的。引用捕获的变量必须在闭包被调用时仍然存活否则是悬垂引用。捕获列表里不能出现static变量或全局变量它们本来就无需捕获。C20 起[]隐式捕获this的做法被弃用应显式写[this]或[, this]。初始化捕获init-capture是 C14 的特性它允许在捕获列表里创建一个新变量常用于移动捕获// 需要 C14#include memory#include utilityauto make_task() {auto data std::make_uniqueint(42);// 把 unique_ptr 移进闭包C11 无法做到return [value std::move(data)]() {return *value;};}C11 下unique_ptr不能被拷贝而[]只做拷贝所以移动捕获必须借助std::bind之类的迂回手段。C14 的初始化捕获直接解决了这个问题。三、从闭包到 std::function类型擦除闭包类型是匿名的两个签名相同的 lambda 类型也不同因此不能把它们的类型写进变量声明。C11 的解决办法是std::function// 适用于 C17#include functional#include iostreamint apply(const std::functionint(int) f, int x) {return f(x);}int main() {int base 100;auto add_base [base](int x) { return base x; };std::cout apply(add_base, 5) \n;return 0;}std::functionint(int)能装下任何「可用int调用、返回int」的可调用对象靠的是类型擦除内部持有一个基类指针或小对象缓冲区把具体闭包类型隐藏在派生类里调用时通过虚函数或函数指针转发。这意味着构造std::function可能发生动态分配小对象优化是否命中取决于实现每次调用多一层间接通常比直接调用 lambda 难内联拷贝std::function会拷贝其持有的闭包。如果函数模板能接受任意可调用类型优先用模板参数避免类型擦除// 适用于 C17templateclass Fint apply_template(F f, int x) {return f(x);}这样编译器能看到具体闭包类型内联和优化都有更大空间。std::function适合需要「存起来、跨接口传递、运行期替换」的场景。四、生命周期闭包最常见的错误来源闭包把环境保存在对象里于是环境的生命周期就成了问题。按值捕获是安全的副本独立按引用捕获则要求被引用对象活得更久。// 适用于 C17 —— 危险示例不要这样写#include functional#include iostreamstd::functionint() make_bad() {int local 42;return [local]() { return local; }; // 返回后 local 已销毁}int main() {auto f make_bad();std::cout f() \n; // 悬垂引用未定义行为return 0;}函数返回后local的生命周期结束闭包里的引用指向已销毁的对象调用operator()是未定义行为undefined behavior, UB。按值捕获[local]则没问题副本随闭包一起存活。同理捕获this的 lambda 如果在对象销毁后被调用也是悬垂指针。异步任务中把[this]扔进线程池是典型事故场景要么保证对象活得比任务久要么用std::weak_ptr配合lock()检查要么按值捕获需要的数据。捕获方式存储内容风险按值[x]副本拷贝成本对象切片副本与源不同步按引用[x]引用源对象销毁后悬垂[this]指针对象销毁后悬垂[*this]对象副本拷贝成本C17 起可用初始化捕获新成员需注意移动后的源对象状态常见坑点❌ 认为带捕获的 lambda 可以转换成函数指针。✅ 只有无捕获的 lambda 才有到函数指针的转换带捕获的闭包是对象。❌ 在返回闭包的函数里按引用捕获局部变量。✅ 改为按值捕获或把被捕获对象的生命周期延长到闭包之外。❌ 以为[]会在每次调用时重新读取外部变量。✅ 按值捕获在 lambda 创建时拷贝一次之后副本独立。❌ 在const上下文中修改按值捕获的变量忘记加mutable。✅ 默认operator()是const需要修改内部副本时加mutable。❌ 在 C11 项目里用[p std::move(ptr)]做移动捕获。✅ 初始化捕获需要 C14C11 下只能用std::bind或手写函数对象。❌ 把捕获this的 lambda 提交到异步任务对象却先销毁。✅ 保证对象生命周期或用weak_ptr检查或按值捕获所需数据。❌ 无脑用std::function包装每个 lambda包括热路径。✅ 模板参数可直接接受闭包类型避免类型擦除的间接调用与潜在分配。❌ 认为两个签名相同的 lambda 类型相同试图互相赋值。✅ 每个 lambda 表达式都有唯一闭包类型用auto或std::function。总结概念结论闭包的本质编译器生成的匿名类捕获变量成为数据成员operator()默认constmutable可去掉按值捕获创建时拷贝副本独立按引用捕获存引用源对象必须活得够久无捕获 lambda可转换为函数指针std::function类型擦除容器有间接调用成本初始化捕获C14 起支持移动捕获C11 的闭包不是语法糖那么简单它把「函数 环境」这一概念落成了真实的类对象。理解这层展开关系就能解释捕获的大小、mutable的必要性、std::function的开销以及为什么引用捕获总是生命周期的头号嫌疑犯。
阅读完成 · 觉得有帮助?
咨询建站