资讯中心

lambda 与闭包:一个隐藏的 struct

📅 2026/9/29 3:30:48
lambda 与闭包:一个隐藏的 struct
① 钩子lambda 是一个隐藏的 struct一个 lambda 其实是一个隐藏的 struct编译器为每个 lambda 表达式生成一个独一无二的闭包类型捕获的变量就是它的成员。所以无捕获的闭包是 1 字节空对象按值捕获 int是 4 字节按引用捕获是 8 字节一个指针。本机实测l0 1 无捕获 → 空闭包 lv 4 按值捕获 int lr 8 按引用捕获 → 一个指针这一集我们把 lambda 的编译真相剖开它怎么变成一个 struct、直接调用为什么零成本、std::function的类型擦除又为什么贵。② 源码 vs 实测/汇编对照__attribute__((noinline))intno_capture(intx){autol[](intv){returnv*2;};returnl(x);}__attribute__((noinline))intcapture_value(intx){intbase10;autol[base](intv){returnvbase;};returnl(x);}// std::function 作参数类型已擦除无法去虚化 → 必须间接调用__attribute__((noinline))intcall_it(conststd::functionint(int)f,intx){returnf(x);}直接调用 lambda —— 零成本被完全内联no_capture(int): leal (%rcx,%rcx), %eax ; 就是 return x*2 ret capture_value(int): leal 10(%rcx), %eax ; base 是编译期已知的 10直接折叠 retstd::function调用 —— 类型擦除的代价call_it(std::functionint(int) const, int): movl %edx, 44(%rsp) ; 把参数 x 存栈准备传给擦除后的调用 cmpq $0, 16(%rcx) ; 空函数检查可能抛异常 je .L16 leaq 44(%rsp), %rdx call *24(%rcx) ; ← 间接调用函数指针存在 std::function 内部 ret .L16: call __throw_bad_function_call环境备注本集在 x86-64 Windows MinGW g 15.2.0libstdc实测。SSO/布局数字因标准库实现不同可能略有差异机制一致。③ 为什么这么设计lambda 的本质每个 lambda 表达式对应一个匿名的struct闭包类型operator()就是它的调用操作符捕获列表 它的成员。无捕获闭包是空类能转成普通函数指针按值捕获 拷贝一个成员按引用捕获 存一个指针。为什么直接调用零成本编译器知道闭包的确切类型能内联operator()——捕获的值若是编译期已知甚至直接常量折叠成leal 10(...)。std::function是类型擦除把任意可调用对象塞进同一个统一类型里。代价有三① 多一次空函数检查可能抛bad_function_call② 通过内部函数指针间接调用call *24(%rcx)编译器无法内联③ 闭包超过标准库小缓冲SBO这里 16 字节时要堆分配。本例捕获 4 字节走 SBO无堆分配。一个反直觉点本 demo 里use_function一整条std::function被 GCC 优化没了类型已知时连std::function也可能内联去虚化。但一旦跨边界——作参数传入、放进容器、经虚调用——擦除成本就躲不掉。所以判断std::function贵不贵要看它是否逃逸了优化器的视野。④ 深入一闭包类型的内存长什么样编译器为每个 lambda 生成一个匿名结构体你的捕获列表决定它的成员intbase10;autol[base](intv){returnvbase;};// 等价于编译器生成的struct__closure1{intbase;// 捕获的成员intoperator()(intv)const{returnvbase;}};无捕获空结构体sizeof1E06 讲过空类占 1 字节按值捕获int一个int成员sizeof4按引用捕获int一个指针成员sizeof8捕获多个/大对象按声明顺序排成员遵循 E06 的对齐规则。捕获的本质就是构造闭包时复制成员[base]是在闭包构造时把base拷进去[base]是存一个指向base的指针。这直接呼应 E05 的传值/传引用讨论——闭包只是一个带状态的函数对象。⑤ 深入二mutable 与 const operator()默认情况下lambda 的operator()是const——所以按值捕获的成员在调用时不能修改它们相当于 const 成员。想修改就用mutableautocounter[n0]()mutable{returnn;};mutable让operator()去掉 const闭包内部状态可改反汇编看mutable闭包的operator()不再有 const 限定E23 的 mangling 里K消失且自增会对成员做真实的读-改-写incl。这是闭包是有状态对象的直接体现——lambda 不是纯函数它可能有内部状态。⑥ 深入三泛型 lambda 与模板的本质auto参数的 lambda 是泛型 lambda本质是一个带template的operator()autog[](autox){returnx*2;};// 等价于struct { templateclass T auto operator()(T x) const { return x*2; } };每用一种参数类型实例化一次生成独立的operator()int、operator()double……和模板函数一样E19 会看到模板实例化。因此泛型 lambda 是编译期多态不同调用各自内联没有虚调用/擦除成本。这也是模板/泛型 编译期多态零运行时成本的又一例E08 的 CRTP 讲过同一思想。⑦ 常见误区误区 1“lambda 和函数指针一样”只有无捕获lambda 能隐式转函数指针编译器生成一个转发到 operator()的静态函数。有捕获的 lambda 有状态不能转。误区 2“std::function一定堆分配”不。小闭包≤SBO 缓冲放在std::function对象内部大闭包才堆分配。本集捕获 int 走 SBO无堆分配。误区 3“lambda 比手写函数对象快/慢”lambda 和手写函数对象等价都是 struct operator()编译器一视同仁。lambda 只是少打字的语法糖。误区 4“按值捕获总是拷贝一份很贵”[base]拷贝 int 确实是一条 mov但捕获引用/指针才 8 字节。真正贵的是捕获大对象整体拷贝进闭包。误区 5“std::function内联不了”类型已知且没逃逸时GCC 可能把整个std::function拆开内联本集的反直觉点。但作参数跨边界后擦除成本真实存在。误区 6“捕获引用就不用担心拷贝了”捕获引用不拷贝值只存 8 字节指针但悬垂风险随之而来——被引用的变量在闭包存活期间必须还活着。这也是为什么把捕获引用的 lambda 存起来/跨作用域传递要格外小心。误区 7“[]默认捕获很省心”它捕获了所有被用到的局部变量的引用一旦闭包逃逸返回、存容器任何被引用局部变量都可能悬垂。显式列出捕获项才能对生命周期负责。误区 8“lambda 不能用于运行期分派”可以——std::function存 lambda 就是运行期分派类型擦除 间接调用。区别只是编译期多态模板内联 vs 运行期多态std::function间接的成本取舍。误区 9“lambda 的operator()是全局函数”它是成员函数带隐藏 this 参数指向闭包对象无捕获时能等价成静态函数但本质仍是成员调用。反汇编里可以看到operator()的 this 参数。⑧ 实战启示捕获大对象 整体拷贝进闭包[big]捕获一个 100 字节的 struct闭包就至少 100 字节。能捕获引用/指针就捕获引用。热点路径传回调优先模板 /auto编译期多态可内联别用std::function。std::function用在要存起来、类型各异的场景回调表、事件分发、策略注入——接受它的间接调用 可能的堆分配。生命周期按引用捕获时要保证闭包活得比被捕获的变量长否则悬垂引用。[]别滥用按值捕获全部会把大对象整份拷进闭包显式捕获清单更清楚也更快。⑨ 扩展专题一SBO小缓冲优化的布局细节std::function内部通常是这样一张布局libstdc 实测偏移 0 函数指针指向真正调用的 trampoline 偏移 8 存储类型信息/清器指针 偏移 16 小缓冲SBO 区域若干字节或指向堆对象的指针小闭包直接放进偏移 16 的 SBO 区本例捕获 int 放这里大闭包在堆上分配偏移 16 存堆指针间接调用走偏移 0 的函数指针call *24(%rcx)是先取 SBO 里的闭包地址再调 operator()的形态之一。对比std::variantE12 预告也是就地存值的布局思想能就地放就不堆分配。这是现代 C 容器/工具类的通用优化方向。⑩ 扩展专题二什么时候用std::function才对给用不用std::function一个决策框架要存进容器、回调表且类型各异→std::function或std::variant是正解接受间接调用成本只是传一次、立即调用→ 模板参数 /auto零成本可内联跨 ABI 边界插件、共享库接口→ 裸函数指针或 C 兼容回调std::function跨库不如裸指针稳E23 讲 ABI确定只有几种类型→std::variantvisit比std::function少一次间接还能存非可调用数据。一句话std::function是为类型擦除 延迟调用设计的即时调用场景它属于杀鸡用牛刀。⑪ 扩展 FAQQ无捕获 lambda 怎么转函数指针Aint(*fp)(int) [](int v){ return v*2; };合法。编译器生成一个静态的桥接函数operator()的转发把函数指针指向它。反汇编能看到这个桥接函数。Q[]和[]混用可以吗A可以[, x]其余按值、x 按引用、[, x]其余按引用、x 按值。但默认捕获容易不知不觉捕获一堆显式清单更利于审查生命周期。Qlambda 能递归吗A不能直接this引用自己匿名类型没有名字。用std::function 捕获自身或 C23 的递归 lambda技巧。本质闭包类型不可命名递归需要绕道。Qnoexceptlambda 怎么写A[]() noexcept { ... }。影响告诉编译器该调用不会抛能走更快的调用路径E10 讲过 noexcept 联动。Q为什么sizeof无捕获闭包是 1AE06 讲过空类占 1 字节为了地址唯一。无捕获闭包是空 struct同样占 1 字节它转成函数指针后这个对象本身通常不真正被用到。Q按值捕获的成员在调用时会拷贝吗A不会额外拷贝。闭包构造时拷贝一次[base]一条 mov之后operator()直接读成员。真正要注意的是闭包本身被拷贝/移动时放进容器、按值传参成员才再被拷一次——所以移动捕获/引用捕获能省这层拷贝E12 展开。⑫ 扩展实验量闭包大小跑 demo 的sizeof输出再捕获double、捕获引用、捕获两个 int看闭包大小如何随捕获变化。看桥接函数无捕获 lambda 转函数指针后-O2 -S在汇编里找那个转发到 operator()的静态函数。SBO vs 堆捕获一个 100 字节的结构体放进std::function反汇编看是否有call _Znwy堆分配E18 预告。mutable 对比mutable闭包 vs 普通闭包反汇编看 operator() 是否带 const 限定mangling 里的 K。泛型 lambda 实例化用 int/double 各调一次泛型 lambdanm看生成了几个不同签名。⑭ 扩展专题三闭包捕获的三种状态与汇编形态按捕获方式闭包成员有三种汇编形态按值捕获标量[base]int闭包成员是一个int调用时直接movl读到寄存器——成本就是一条 mov。按引用捕获[base]成员是一个指针调用时先movq (%rcx), %rax取指针再解引用movl (%rax)——多一次间接。捕获右值/移动[x std::move(big)]C14 起成员用移动构造——大对象只搬指针而非深拷贝E12 展开。一条实用经验标量用[]按值、大对象用[]按引用或移动捕获是拷贝成本 vs 悬垂风险的平衡。本集 ④ 的 4 字节 vs 8 字节只是起点真正的大头在捕获大对象时整体拷贝。⑮ 扩展专题四lambda 在标准库算法里的地位algorithm全家桶std::sort、std::transform、std::for_each……是 lambda 最大的舞台。为什么算法是模板回调参数是编译期类型——lambda 能被内联本集 ② 已见leal折叠对比 C 风格的回调函数指针函数指针是间接调用不能内联还要求全局状态才能带数据lambda 把算法 局部状态绑在一起且零额外运行时成本——这是现代 C 用它替换函数指针/手写函数对象的原因。std::sort(v.begin(),v.end(),[](inta,intb){returnab;});// 内联的比较器反汇编std::sort的 lambda 版本比较器被内联进排序循环没有一次间接调用。这就是模板算法 lambda能打败 C 风格 qsort函数指针的机器原因。⑯ 扩展 FAQ第二轮Qstd::function和函数指针谁更省A函数指针更省8 字节无 SBO/堆分配/间接层。std::function强在能存有状态闭包 类型擦除。能用函数指针无捕获 lambda就别用std::function。Q捕获this和捕获*this区别A[this]捕获 this 指针成员通过指针访问[ *this ]捕获对象的拷贝。前者快但可能悬垂后者安全但整份拷贝。Qlambda 在 C20 加了什么A模板 lambda显式template、[, this]默认捕获修正等。核心不变仍是隐藏 struct。Q为什么operator()默认 constA让 lambda像函数不改外部状态。要内部可变状态用mutable——它让闭包成为真正的有状态对象。Q把 lambda 存进std::vectorstd::function...会怎样A每个元素都是std::function可含 SBO 或堆指针调用走间接。容器本身均匀分布但每次调用都有擦除成本——这是回调表的典型形态接受成本即可。Q为什么无捕获 lambda 转函数指针在跨平台调用里很香A函数指针是 C 兼容的回调形态能跨 ABI/共享库边界传递E23 讲 ABI且无 SBO/擦除开销。所以给 C 库传回调时无捕获 lambda 是首选。⑰ 扩展实验第二轮算法内联对照std::sort用 lambda vs C 函数指针qsort-O2 -S对比比较器是否被内联、有无间接调用。*捕获 this vsthis成员函数里[this]和[*this]各写一个-O0 -S看闭包成员是指针还是对象副本。移动捕获反汇编[x std::move(big)]闭包构造的汇编确认走移动构造E12 会正式展开移动的 6 条 mov。SBO 阈值测试依次捕获 8/16/17/32/64 字节对象进std::function看哪个开始出现堆分配反汇编找_Znwy。泛型 vs 具体 lambda同样逻辑用auto参数和具体类型参数对比模板实例化的数量。⑲ 扩展专题五std::function 内部是怎么擦除类型的——一个迷你实现理解类型擦除的最好办法是自己实现一个简化版std::functiontemplateclassSigclassmy_function;templateclassR,class...Argsclassmy_functionR(Args...){// 每个实例化的调用器都是一个不同的静态函数但被擦除成统一签名R(*call)(void*,Args...);// 擦除后的调用入口void*obj;// 擦除后的对象指针可能指向 SBO 或堆public:templateclassFmy_function(F f){...}// 把 F 的 operator() 包装进一个静态函数存指针Roperator()(Args...a)const{returncall(obj,a...);}};核心思想“类型 F” 的信息被折叠进一个静态函数指针里——每个 F 实例化出不同的call静态函数它们共享同一个签名R(*)(void*, Args...)于是任意 F 都能被存进同一个my_function对象。调用时只经过一次间接call指针对象指针也被擦成void*。这正是本集汇编call *24(%rcx)的背后结构。理解了它std::function 为什么不能内联/为什么有间接调用就一目了然了。⑳ 扩展专题六std::invoke 与完美转发在 lambda 里的用处lambda 捕获/调用常和std::invoke、std::forward配合templateclassF,class...Argsdecltype(auto)invoke_helper(Ff,Args...args){returnstd::invoke(std::forwardF(f),std::forwardArgs(args)...);}std::invoke统一处理函数对象/函数指针/成员指针三种可调用形态std::forward保留参数的左/右值性完美转发E12 会正式展开泛型 lambda 完美转发是写通用转发层的标准姿势编译期展开、零运行时成本。这也呼应了 E03 的调用约定模板转发层最终生成的就是普通调用指令参数按 ABI 放在寄存器/栈里没有任何隐藏开销。㉑ 扩展专题七lambda 与状态——闭包不是纯函数把 lambda 当有状态对象而非函数理解很多设计就顺了计数器/生成器mutable闭包内部维护状态如[n0]() mutable { return n; }缓存闭包成员存缓存结果operator()先查缓存比较器带参数[threshold](int x){ return x threshold; }把阈值作为状态带进算法。这些用法下闭包成员就是隐式状态和 E11 开头的捕获 成员完全一致。lambda 不是没名字的函数而是没名字的、可带状态的对象——这句理解上的转变是这一集最大的收获。㉒ 扩展 FAQ第三轮Q为什么有的std::function调用会多一条cmpq $0, ...A那是空函数检查——std::function可能是空的默认构造/被赋空调用空std::function抛bad_function_call。每次调用都要先检查非空本集.L16路径。Qlambda 与仿函数函数对象性能完全一样吗A是的。lambda 就是编译器帮你写的仿函数struct operator()。同构 → 同性能。QC17/20 的constexpr lambda呢AC17 起 lambda 可以 constexpr能在编译期调用C20 起可以默认捕获、支持模板 lambda 等。核心仍是隐藏 struct只是 operator() 可以是 constexpr。Q把大闭包放std::function一定会堆分配吗A超过 SBO 缓冲就会。SBO 大小是实现细节libstdc 通常是 16 字节左右所以捕获 100 字节对象大概率堆分配——这也是别在 std::function 里塞大闭包的理由。Qstd::bind和 lambda 选哪个A现代 C 一律优先 lambda更清晰、可内联、无额外的绑定对象开销。std::bind主要用于遗留代码。㉓ 扩展实验第三轮实现迷你 function按 ⑲ 的骨架写一个my_function跑通并反汇编确认一次间接调用。mutable 计数器[n0]() mutable生成器自增 10 次-O0 -S看成员n的读-改-写指令。SBO 实测边界捕获 8/16/24 字节对象进std::function反汇编找堆分配_Znwy出现的位置。constexpr lambdaC17 下在static_assert里用 constexpr lambda 算值确认编译期求值。std::bind vs lambda同功能各写一份-O2 -S对比生成代码lambda 通常更干净。㉔ 悬念lambda 里最常见的写法是按值捕获 std::move可std::move到底是什么它真的把数据搬走了吗集 E12我们看移动语义的汇编真相。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案