从 C++ 闭包底层上看:你的[]里到底发生了什么?
目录
什么是闭包,为什么需要它?
手动实现
1. 仿函数:闭包的最原始形态
2. 引入捕获:模拟 lambda 的捕获列表
按值捕获
按引用捕获
多个捕获项
默认捕获 [=] 与 [&]
深入理解
1. 可变闭包:mutable 背后的秘密
2. 闭包的传递与存储
3. 内存与性能
值捕获闭包的内存布局
引用捕获的底层
闭包相比函数指针的优势
结尾
C++ 的 lambda,本是编译器代写的匿名类,零开销的狠角色,没成想,函数指针说它怪!std::function 拖它后腿!悬垂引用暗算它!没有 mutable 还不让随便改!
它发誓,重活一世,一定要完美复仇!
V我50,带你一起拆穿 C++ 闭包的复仇大计。
什么是闭包,为什么需要它?
假如我们想把一段操作丢给另一个函数,但这段操作非要用到当时身边的几个变量。这时候脑子里那个传函数指针的条件反射瞬间就卡壳了,函数指针它没脑子啊,带不上当时的上下文。
于是我们开始抱怨:凭什么一个简单的过滤逻辑,非得让我折腾出一堆全局变量、或者写个七八行的类?闭包就是来解决这个事的。
在 C++ 里面我们一般使用 lambda 进行闭包操作,不过在没有 lambda 的苦日子里是怎么搞定这个的?
全局变量:把 threshold 扔到全局或者文件作用域里,让比较函数直接去读。这招属于图省事一时爽,调试火葬场。
函数对象:写一个结构体,重载 operator(),把阈值作为成员变量,构造函数里传进去。
std::bind + 占位符:看起来好像聪明点,bind(greater<int>(), _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::vector<int> 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::function<void()> 之类的常规签名要求,还得额外用 std::function<void() const> 这类变态写法。
2. 闭包的传递与存储
我们写了两个 lambda,哪怕它们的函数签名一模一样,比如都是 int(int),它们的类型也绝对不同。
auto lam1 = [](int x) { return x * 2; }; auto lam2 = [](int x) { return x + 1; }; // lam1 的类型是 __lambda_123,lam2 的类型是 __lambda_456 // 它们没有任何继承关系,完全是两个不同的类每个 lambda 生成独立的类,编译器可以在模板展开时分别为它们内联优化,但代价就是我们没法把它们放进同一个 vector。
解决方案就是使用 std::function,它是一个类模板,模板参数是函数签名,比如 std::function<int(int)>。它可以容纳任何可调用对象,只要这个对象能用 int 参数调用并返回 int。包括普通函数指针、仿函数、lambda 及 std::bind 那些玩意。
它的实现原理:虚函数 + 模板子类。std::function 内部大致干了两件事:
定义一个抽象基类,里面有一个纯虚的 invoke 函数,和一个纯虚的 clone 函数。
对于每一个我们装进去的具体可调用对象,它内部生成一个继承自该基类的模板子类,这个子类存着我们的对象,并重写 invoke 和 clone。然后把基类指针存进 std::function 对象里。
调用的时候,通过基类指针虚函数派发到具体子类的 invoke。我们往 std::function 里塞的东西类型不同,它就在背后生成不同的子类,但最终暴露给我们的都是一个统一的 std::function<void(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的倍数,所以是 4+4(padding)+8+1+7(padding) = 24。
这就是一个扎扎实实的结构体,没有虚表指针,没有额外的堆分配,除非我们捕获的成员变量自己维护了堆,那就是另一回事。
引用捕获的底层
C++ 标准说引用捕获存储的是引用,但在实现层面编译器几乎无一例外地用指针来实现引用。因此在底层实现上,引用捕获几乎总是用指针来存储地址。那么一个引用捕获的闭包成员,就是一块存着外部对象地址的内存,大小就是一个指针的大小。
例如这段代码:
int x = 10; auto lambda = [&x] { return x; };编译器可能生成类似这样的闭包结构:
struct __lambda { int* __x; // 实际存的是指针 auto operator()() const { return *__x; } };即使在 C++11/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++ 这门语言就是这样的,每次都冷冰冰丢给我们一堆零件,然后说:“自己装,装错炸了活该。”闭包也是如此,它不仅仅是语法糖,它背后是编译器实打实生成的类。理解这一点之后,我们每次写 [] 的时候脑子里都是一个结构体。
