目录什么是闭包为什么需要它手动实现1. 仿函数闭包的最原始形态2. 引入捕获模拟 lambda 的捕获列表按值捕获按引用捕获多个捕获项默认捕获 [] 与 []深入理解1. 可变闭包mutable 背后的秘密2. 闭包的传递与存储3. 内存与性能值捕获闭包的内存布局引用捕获的底层闭包相比函数指针的优势结尾C 的 lambda本是编译器代写的匿名类零开销的狠角色没成想函数指针说它怪std::function 拖它后腿悬垂引用暗算它没有 mutable 还不让随便改它发誓重活一世一定要完美复仇V我50带你一起拆穿 C 闭包的复仇大计。什么是闭包为什么需要它假如我们想把一段操作丢给另一个函数但这段操作非要用到当时身边的几个变量。这时候脑子里那个传函数指针的条件反射瞬间就卡壳了函数指针它没脑子啊带不上当时的上下文。于是我们开始抱怨凭什么一个简单的过滤逻辑非得让我折腾出一堆全局变量、或者写个七八行的类闭包就是来解决这个事的。在 C 里面我们一般使用 lambda 进行闭包操作不过在没有 lambda 的苦日子里是怎么搞定这个的全局变量把 threshold 扔到全局或者文件作用域里让比较函数直接去读。这招属于图省事一时爽调试火葬场。函数对象写一个结构体重载 operator()把阈值作为成员变量构造函数里传进去。std::bind 占位符看起来好像聪明点bind(greaterint(), _1, threshold)。但我得说一下bind 生成的对象类型是个黑盒子不透明想传递它还得用 std::function又引入一次间接调用开销。所以闭包就是函数与其创建时所在的词法环境的绑定体。用 C 来说就是我们写了一个 lambda 表达式它所在的代码块里有哪些可见的自动变量由我们决定捕获它们编译器就为我们生成一个匿名类对象。这个对象里面存了那些变量的副本或引用并且有一个 operator() 成员函数函数体就是我们写的 lambda 体。调用这个对象时函数体执行同时能无障碍访问它存储的那些变量——这就是闭包。那个 lambda 表达式本身其实就是这个匿名类的一个临时实例。C 中的闭包是一个彻底静态、类型确定的编译期产物。我们每一个 lambda 表达式都有一个独一无二的编译器生成类型只是我们经常用 auto 而已这导致它既能做到零开销不强制类型擦除又能被模板轻松内联展开。手动实现1. 仿函数闭包的最原始形态C 里有一个有趣的设定我们可以让一个对象像函数一样被调用只要给它写一个 operator() 就行。而且这东西的类型是实实在在的它就是一个类的实例该有几字节就几字节该内联就内联编译器优化起来得心应手。从抽象层面来说它已经是一个完整的闭包了因为它符合“函数 创建时的词法环境”这个定义只不过环境是我们手动塞进成员变量的。那么我们直接写代码假设我们想做一个乘法操作但这个乘法的因子不是固定的而是在运行时决定的并且要把这个操作传给另一个函数用。比如一个 std::transform需要把容器里每个元素都乘以这个因子。class Multiply { int factor_; // 这是它随身携带的环境 public: // 构造函数接收环境并存起来 explicit Multiply(int factor) : factor_(factor) {} // 用存好的因子去做乘法 int operator()(int x) const { return x * factor_; } };没几行但我们会发现这就是把操作和当时的环境打包在了一起。factor_ 是那个记下来的系数、条件……随便怎么叫它就是我们的词法环境里那个变量的化身。用起来也直接std::vectorint v { 1, 2, 3, 4, 5 }; int factor 10; Multiply multiply(factor); // 创建对象把环境注入进去 std::transform(v.begin(), v.end(), v.begin(), multiply); // v 现在变成了 { 10, 20, 30, 40, 50 }transform 的第四个参数要求一个可调用对象而我们传进去的 multiply 是一个地地道道的对象实例但它能被调用因为它有 operator()。而且它在调用时会带上自己存的那个 factor_神不知鬼不觉地把环境带进去了。这就是闭包。这种像函数又像对象的玩意初看可能有点分裂但细想其实非常合理。调用的时候我们写 obj(args)语法就是函数调用不违和而它内部有一个 this 指针能安安稳稳地访问自己的成员变量又是纯正的对象做派。这就让它成为了一个完美的可定制回调。我们可以像创建任何普通对象一样用不同的构造函数参数生成携带不同状态的实例Multiply times_two(2); Multiply times_hundred(100); int a times_two(5); // 10 int b times_hundred(5); // 500甚至还可以把它做成模板让因子类型泛化或者让 operator() 本身是模板这都是常规 C 操作类型安全零虚函数开销编译器看得清清楚楚。构造函数接收环境调用运算符使用环境这就是仿函数实现闭包的全部秘密。我们回头看一眼上面那个 Multiply 类它的构造函数就是用来捕获外部变量的入口成员变量就是捕获列表的物理存储而 operator() 就是 lambda 的函数体。这种对应关系如此直接以至于后来标准化委员会那帮人一拍大腿既然每次都手写这么个一次性的小类又麻烦又容易出错起名还费脑子干脆让编译器自动生成得了。于是 lambda 表达式诞生了它其实就是让编译器在背后帮我们生成一个跟 Multiply 一模一样的匿名类构造和调用全部自动匹配。所以 lambda 本质上是编译器自动生成的仿函数是手写仿函数的语法糖。2. 引入捕获模拟 lambda 的捕获列表按值捕获先看个最简单的按值捕获我们在 lambda 的方括号里写个 [a]意思就是“把外部变量 a 的副本存到闭包对象里去”。这跟咱们之前的 Multiply 类如出一辙只不过 a 可以是任何类型。假设外部有个 int a 10我们想写一个 lambda 把它按值捕获然后做点操作比如返回 a x。对应的仿函数长这样class LamEquivalent { int a_; // 用于存储捕获的副本 public: explicit LamEquivalent(int a) : a_(a) {} // 按值传入拷贝进成员 int operator()(int x) const { return a_ x; // 使用捕获的副本 } };使用对比int a 10; // Lambda 写法 auto lam [a](int x) { return a x; }; // 手写仿函数写法 LamEquivalent fun(a); std::cout lam(5) fun(5) std::endl; // 都是 15[a] 完全等于在匿名类里加了个 int a_ 成员并用外部 a 的值来初始化它。因为闭包自己持有副本和外部变量彻底断绝联系外面的 a 爱怎么变怎么变甚至出了作用域销毁了闭包里的 a_ 安然无恙。唯一要注意的是拷贝开销如果我们捕获的是一个 10MB 的 std::vector它会原封不动拷贝一份这时候我们可能需要把值捕获变成引用捕获。按引用捕获引用捕获就刺激多了也更危险。[b] 的意思是“别给我拷贝我就拿外面那个 b 本身用着”。在生成的匿名类里它存的其实是一个引用类型的成员变量或者严谨点说是存储了外部对象的引用。class LamRefEqu { int b_ref_; // 存储引用 public: explicit LamRefEqu(int b) : b_ref_(b) {} // 绑定到外部 b int operator()(int x) const { return b_ref_ x; // 通过引用访问外部 b } };使用对照int b 10; auto lam_ref [b](int x) { return b x; }; LamRefEqu fun_ref(b); b 20; std::cout lam_ref(5) fun_ref(5) std::endl; // 都是 25这里能看出来引用捕获意味着闭包和外部作用域共享同一个对象。外面改了闭包内的访问结果也跟着变闭包改了外面也会变。然而 C 不会像一些带垃圾回收的语言那样帮我们照顾生命周期。引用捕获的本质就是存了个指针或者 C 引用它完全依赖外部对象存活得比闭包久。一旦外部对象先于闭包析构了闭包里的引用就成了悬垂引用访问它就是未定义行为程序死得花样百出。因此只要我们的 lambda 要离开当前作用域如返回、传到其他线程、存到全局容器绝对不要按引用捕获局部变量除非能百分百确定生命周期得到保证否则用 shared_ptr 之类的智能指针把生命周期管理起来。多个捕获项实际代码里我们几乎不可能只捕获一个变量。混合按值和按引用捕获是常态lambda 允许我们在 [] 里逐个列出来int a 1; double b 2.0; std::string s hello; auto mixed_lam [a, b, s](int x) { // a 按值b 按引用s 按值 return a b s.size() x; };编译器生成的匿名类翻译过来大致就是class MixedLambda { int a_; // 对应按值捕获 a double b_ref_; // 对应按引用捕获 b std::string s_; // 对应按值捕获 s public: MixedLambda(int a, double b, std::string s) : a_(a), b_ref_(b), s_(std::move(s)) {} auto operator()(int x) const - decltype(auto) { return a_ b_ref_ s_.size() x; } };这个类清晰地展示了捕获列表里每个项都一对一地转化为一个成员变量。按值的存副本、按引用的存储引用。构造函数的参数列表和捕获列表严格对齐并且会包含所有按值和引用捕获的外部变量。这就是为什么我们捕获的变量越多生成的闭包对象就越大它真的就是把这些变量的副本或引用塞进一个结构体。默认捕获 [] 与 []这在手写仿函数时怎么模拟很简单就是把我们 lambda 函数体里用到的每一个外部变量都加到成员列表里按值或按引用就不演示了。不过这里还是得提一嘴隐式捕获虽然省事但很容易不小心捕获了不该捕获的东西尤其是 []。我们在函数体里随手加了个局部变量的使用如果它正好在外部作用域我们的 lambda 就自动多绑定了一个引用可能引入难以察觉的生命周期问题或者副作用。所以我的建议是能少用这俩玩意最好不过了。深入理解1. 可变闭包mutable 背后的秘密我们先来看个摔键盘的场景int counter 0; auto increment [counter]() { return counter; }; // 编译错误你看我明明按值捕获了 counter现在它是我闭包自己的副本了我想怎么改就怎么改关外面什么事凭什么报错编译器这什么臭脾气。抱怨归抱怨但 C 在这件事上其实很统一它把所有 lambda 的 operator() 都默认标记成了 const 成员函数。就像我们写了个类顺手给 operator() 后面加了个 const 一样。const 成员函数里不允许修改成员变量所以当我们试图 counter 修改闭包内的那个副本就直接被毙了。那怎么解封C 给我们提供了 mutable 关键字。在 lambda 的参数列表后面加个 mutable像这样auto increment [counter]() mutable { return counter; };这个 mutable 的作用就是告诉编译器“你生成 operator() 的时候别加那个 const我要一个非 const 的版本。” 对应的仿函数就变成了class __LambdaMutable { int counter_; public: explicit __LambdaMutable(int c) : counter_(c) {} // mutable 去掉了 const auto operator()() - int { return counter_; // 允许修改的是副本 } };完美通过编译现在我们每次调用 increment()它内部的 counter_ 就会自增但外面的那个原始 counter 纹丝不动。副本就是副本我们爱怎么折腾就怎么折腾只是默认情况下 C 不让我们折腾得主动说“我要 mutable”。按引用捕获的变量在 const 成员函数中依然可以修改因为修改的是被引用的对象而不是引用本身那么问题来了为什么标准委员会要默认加 const非得让我们多写个 mutable这不是脱裤子放屁吗其实这是 C 一贯的哲学默认朝安全、无副作用的方向倾斜把有状态的、可能出人意料的行为留给程序员显式申请。考虑这些点函数式编程的期待lambda 大量用于标准库算法这些算法大多期望我们传入的谓词或操作是一个纯函数即相同的输入永远产生相同的输出没有可观察的副作用。默认 const 的 operator() 恰好强制了这种无副作用语义。可调用对象的通用约定很多库设施要求可调用对象的 operator() 能在 const 上下文中被调用。如果 lambda 默认生成非 const 的 operator()那它就直接不符合 std::functionvoid() 之类的常规签名要求还得额外用 std::functionvoid() const 这类变态写法。2. 闭包的传递与存储我们写了两个 lambda哪怕它们的函数签名一模一样比如都是 int(int)它们的类型也绝对不同。auto lam1 [](int x) { return x * 2; }; auto lam2 [](int x) { return x 1; }; // lam1 的类型是 __lambda_123lam2 的类型是 __lambda_456 // 它们没有任何继承关系完全是两个不同的类每个 lambda 生成独立的类编译器可以在模板展开时分别为它们内联优化但代价就是我们没法把它们放进同一个 vector。解决方案就是使用 std::function它是一个类模板模板参数是函数签名比如 std::functionint(int)。它可以容纳任何可调用对象只要这个对象能用 int 参数调用并返回 int。包括普通函数指针、仿函数、lambda 及 std::bind 那些玩意。它的实现原理虚函数 模板子类。std::function 内部大致干了两件事定义一个抽象基类里面有一个纯虚的 invoke 函数和一个纯虚的 clone 函数。对于每一个我们装进去的具体可调用对象它内部生成一个继承自该基类的模板子类这个子类存着我们的对象并重写 invoke 和 clone。然后把基类指针存进 std::function 对象里。调用的时候通过基类指针虚函数派发到具体子类的 invoke。我们往 std::function 里塞的东西类型不同它就在背后生成不同的子类但最终暴露给我们的都是一个统一的 std::functionvoid(int) 接口。3. 内存与性能值捕获闭包的内存布局首先一个结论一个按值捕获的 lambda 对象在内存里就是一个紧凑的结构体成员顺序和我们捕获列表里的顺序一致。我们可以直接用 sizeof 量它int a 10; double b 3.14; char c x; auto lam [a, b, c]() { return a b c; }; std::cout sizeof(lam) std::endl;在我的机器上x64按8字节对齐这段代码输出 24。为什么int a 占4字节后面为了对齐 double会有4字节填充double b 8字节char c 1字节最后整体对齐到8的倍数所以是 44(padding)817(padding) 24。这就是一个扎扎实实的结构体没有虚表指针没有额外的堆分配除非我们捕获的成员变量自己维护了堆那就是另一回事。引用捕获的底层C 标准说引用捕获存储的是引用但在实现层面编译器几乎无一例外地用指针来实现引用。因此在底层实现上引用捕获几乎总是用指针来存储地址。那么一个引用捕获的闭包成员就是一块存着外部对象地址的内存大小就是一个指针的大小。例如这段代码int x 10; auto lambda [x] { return x; };编译器可能生成类似这样的闭包结构struct __lambda { int* __x; // 实际存的是指针 auto operator()() const { return *__x; } };即使在 C11/14 标准中说成员是 int它在内存里依然被实现为一个指针。既然引用捕获存的是指针那悬垂引用的痛就更加直观了闭包里存的就是个指向某块栈内存或已释放内存的指针外面对象一销毁这个指针就变成了野指针。所以引用捕获根本不会帮我们延长外部对象的生命周期。闭包相比函数指针的优势函数指针哪怕我们传的是 int (*)(int)编译器在调用它的时候通常只能看到一个指针它不知道这个指针到底指向哪个函数。除非它能在上下文里做指针分析证明这个指针是常量否则它就不得不生成一次间接调用而且没法内联函数体。间接调用不仅阻断了内联还让 CPU 的分支预测器头疼流水线可能断流性能损耗在频繁调用的场景下非常可观。而 lambda 是什么它是一个匿名类每一个 lambda 表达式都有独一无二的类型。当我们把它传给一个模板函数时比如 std::sort(v.begin(), v.end(), [](int a, int b) { return a b; })编译器完全知道这个类型的具体 operator()因为它就是一个普通的成员函数。因此编译器可以愉快地把比较操作直接内联到排序算法的循环里生成连续紧凑的指令完全没有 call 指令。这就是为什么用 lambda 做 std::sort 的比较器和直接写一个比较函数再传函数指针相比性能可以差出好几倍。结尾C 这门语言就是这样的每次都冷冰冰丢给我们一堆零件然后说“自己装装错炸了活该。”闭包也是如此它不仅仅是语法糖它背后是编译器实打实生成的类。理解这一点之后我们每次写 [] 的时候脑子里都是一个结构体。