写 STL 算法的时候[](const auto a, const auto b) { return a.x b.x; }已经成了肌肉记忆。但「lambda 到底是什么」这个问题很多人答不上来 —— 它不是函数也不是std::function而是编译器为每个 lambda 表达式现场生成的一个匿名类operator()是它的成员函数。搞清楚这层映射关系之后「无捕获的能转函数指针」「两个长得一样的 lambda 类型不同」「为什么它比std::function快」这些问题就都能自己推出来了。1. 引子没有 lambda 的时候有多啰嗦想在排序时用「按价格降序」这种一次性规则C11 之前的正统写法是写一个函数对象function object也叫仿函数 functor// 片段C98 时代的一次性比较规则样板代码比逻辑还长structPriceGreater{booloperator()(constItema,constItemb)const{returna.priceb.price;}};std::sort(items.begin(),items.end(),PriceGreater{});// 规则和调用点隔了十万八千里规则写在类里、调用点在下头读代码的人得来回跳。而且这个类只在这一处用一次还得给它起名字、放进命名空间或者塞在匿名 namespace 里。lambda 解决的正是这个「一次性、就地定义」的需求 —— 它是 C Core Guidelines 里 F.50「函数不够用时就用 lambda」那条的实现方式。官方文档Lambda expressions — cppreference2. 完整语法结构六个部件哪些能省lambda 的完整形式是六个部件拼起来的日常代码里通常只写其中三个// 片段一个「全写」的 lambda大部分部件平时都不写constintbase10;autof[base](intx)mutablenoexcept-int{returnxbase;};// ^^^^^^ ^^^^^ ^^^^^^^ ^^^^^^^^ ^^^^^ ^^^^^^^^^^^// 捕获列表 参数 mutable 异常说明 返回类型 函数体逐个部件的可省略性部件位置能不能省省掉之后捕获列表[...]最前不能省至少要写空的[]没它就是普通函数参数列表(...)次之不能省C11 起无参也要写()mutable参数之后能省operator()是const成员函数值捕获的成员不能改异常说明noexceptmutable之后能省表示「可能抛异常」- 返回类型noexcept之后能省从return语句推导函数体{...}最后不能省必须有{}能省的三个里- 返回类型是最常省略的但它是有陷阱的省略第 5 节展开。mutable在《泛型 lambda 与 mutable》里专门讲。3. 编译器把它变成了什么闭包类型这是理解 lambda 的关键一步。编译器看到[base](int x) { return x base; }会在内部生成一个没有名字的类标准里叫闭包类型 closure type把捕获的变量变成数据成员把函数体变成operator()你写的源码 编译器生成的概念模型非真实代码 ────────────────────────────────────── ────────────────────────────────────────── const int base 10; class __Lambda_4_10 { // 匿名闭包类 int base; // 值捕获→数据成员 auto f [base](int x) { public: return x base; __Lambda_4_10(int base_) }; : base{base_} {} // 构造函数 │ int operator()(int x) const { // 默认 const │ return x base; │ } │ }; │ │ const auto f __Lambda_4_10{base}; // 构造一个对象 └── 调用 f(32) ≡ f.operator()(32) // 调用即成员调用由此可以推出几条重要结论lambda 是对象不是函数。它有大小、能拷贝、能赋值无捕获的还能转函数指针。闭包类型没有名字。所以只能用auto接或者用decltype想手写类型名除了转成函数指针是做不到的。每个 lambda 表达式都产生一个独一无二的类型。哪怕两个字面写法完全一样它们的类型也不同// 片段长相相同的两个 lambda类型并不相同autoa[](intx){returnx;};autob[](intx){returnx;};// static_assert(std::is_same_vdecltype(a), decltype(b)); // ✗ 编译失败不同类型捕获 数据成员所以捕获了什么、按值还是按引用直接决定这个对象占多少字节 —— 下一节用sizeof实测。4. sizeof 实测无捕获是 1 字节有捕获就长胖空类在 C 里的大小是 1 字节保证不同对象有不同地址没有捕获的 lambda 就是空类#includecstdio#includefunctionalintmain(){constinta1;constdoubleb2.0;constautono_cap[](intx){returnx*2;};constautocap1[a](intx){returnxa;};constautocap2[a,b](intx){returnxastatic_castint(b);};std::printf(无捕获 lambda : %zu 字节\n,sizeof(no_cap));std::printf(捕获 1 个 int : %zu 字节\n,sizeof(cap1));std::printf(捕获 int double : %zu 字节\n,sizeof(cap2));std::printf(std::functionint(int) : %zu 字节\n,sizeof(std::functionint(int){}));std::printf(前者是否 1 字节 : %s\n,sizeof(no_cap)1?是:否);return0;}无捕获 lambda : 1 字节 捕获 1 个 int : 4 字节 捕获 int double : 16 字节 std::functionint(int) : 32 字节 前者是否 1 字节 : 是实测结果读法x86-64gcc 13无捕获 1 字节—— 空类捕获列表为空就没有数据成员。所以把无捕获 lambda 存进容器或者当模板实参传零开销。捕获一个int 4 字节—— 就是一个int成员。捕获intdouble 16 字节不是 12—— 这是对齐与 paddingdouble要求 8 字节对齐int后面得补 4 字节空洞sizeof就被撑到 16。捕获顺序会影响空位多少理论上编译器可以重排成员但别指望。std::functionint(int)固定 32 字节—— 类型擦除的代价而且小对象才放得下装一个捕获了std::string的大 lambda 时它还会去堆上分配一次。官方文档std::function — cppreference —— 页面上「实现定义的小对象优化」那句解释的就是最后这条。这就是「哪种写法更优」的第一个答案能用无捕获 lambda 或捕获少变量就尽量别包std::function前者是 1~N 字节的栈上对象后者是固定 32 字节 可能的一次堆分配 一次间接调用。5. 无捕获 lambda 能变成函数指针无捕获 lambda 的闭包类里没有数据成员编译器会额外给它一个转换到函数指针的转换函数。这意味着它可以塞进任何只认函数指针的老式 API#includecstdio// 老式 C 风格 API只接受函数指针intapply_twice(int(*f)(int),intx){returnf(f(x));}intmain(){// 无捕获 lambda 可以隐式转换成函数指针int(*fp)(int)[](intx){returnx*3;};std::printf(函数指针调用 %d\n,fp(7));// 直接当实参传进去std::printf(apply_twice %d\n,apply_twice([](intx){returnx1;},40));// 取正号可以强制转成函数指针C API 需要明确类型时常用autofp2[](intx){returnx-1;};std::printf(取正号再转换 %d\n,fp2(10));return0;}函数指针调用 21 apply_twice 42 取正号再转换 9三个细节号那个技巧[](...) {...}是社区惯例一元没有副作用的运算符重载但它会触发「函数指针转换」从而让auto推导出int(*)(int)而不是闭包类型。传进 C 库回调、或者需要显式类型推导时非常有用。有捕获的 lambda 转不了。因为它需要一个对象来保存捕获的副本而函数指针只有代码地址、没有「this」。写int (*fp)(int) [a](int x) { return x a; };会直接编译失败 —— 这是编译器帮你拦住的一类错误。转换出来的函数指针不能带捕获这一点在跨 C 边界调用qsort、pthread_create、各种回调注册时要提前想好要么把状态塞进void* context参数要么改用std::function。6. 返回类型推导多条 return 必须类型一致省略- 返回类型时编译器按return语句推导。单条return、或者所有return的类型完全一致都没问题一旦类型不同就报错。这不是「会挑一个公共类型」而是严格相等// 片段故意编译失败不要这么写两条 return 类型不一致autof[](intx){if(x0)return1;// 推导出 intreturn1.5;// ✗ auto 推导不允许 int / double 混用};真实报错gcc 13.2注意它明确点出了「推导出不一致的类型」prog.cc: In lambda function: prog.cc:4:12: error: inconsistent types int and double deduced for lambda return type 4 | return 1.5; | ^~~修法有两种按意图选// 片段两种修法autof1[](intx){// 修法一统一字面量类型if(x0)return1.0;return1.5;// ✓ 都是 double};autof2[](intx)-double{// 修法二显式标注返回类型推荐if(x0)return1;// int 隐式转成 doublereturn1.5;};注意和普通函数对比普通函数里auto f(int x) { if (x0) return 1; return 1.5; }同样是错误规则一致。另外有一条容易忘的边界省略返回类型时如果函数体只有一条return推导就走「auto模板推导」的规则会丢弃引用和const所以想返回引用必须显式写- int否则返回的是个副本 —— 这是「返回值类型不对但能编译」的隐蔽 bug。7. STL 里的典型用法lambda 最大的舞台就是算法库。四种典型角色比较器、谓词、映射函数、二元运算#includealgorithm#includecstdio#includenumeric#includevectornamespace{constexprintkThreshold10;}structItem{constchar*name;intprice;};intmain(){std::vectorItemitems{{apple,5},{banana,3},{cherry,8},{durian,30}};// 1) 排序lambda 当比较器std::sort(items.begin(),items.end(),[](constItemx,constItemy){returnx.pricey.price;});std::printf(按价格降序);for(constautoit:items)std::printf( %s(%d),it.name,it.price);std::printf(\n);// 2) 查找第一个超过阈值的constautorichstd::find_if(items.begin(),items.end(),[](constItemit){returnit.pricekThreshold;});if(rich!items.end()){std::printf(第一个超过 %d 的是 %s\n,kThreshold,rich-name);}// 3) 计数价格是偶数的constautoeven_cntstd::count_if(items.begin(),items.end(),[](constItemit){returnit.price%20;});std::printf(价格是偶数的有 %lld 个\n,static_castlonglong(even_cnt));// 4) 归约lambda 当二元运算constinttotalstd::accumulate(items.begin(),items.end(),0,[](intacc,constItemit){returnaccit.price;});std::printf(总价 %d\n,total);return0;}按价格降序 durian(30) cherry(8) apple(5) banana(3) 第一个超过 10 的是 durian 价格是偶数的有 2 个 总价 46几点经验参数一律接const。算法会把元素的常量引用传进来写const Item既不拷贝又能接住所有值类别写Item会多一次拷贝写成非 const 引用则编译失败算法传进来的是const迭代器的解引用时。比较器必须是严格弱序strict weak ordering。return x.price y.price;用了「大于」而不是这一点不能随手写 —— 用会让相等元素互相「小于」std::sort直接越界崩溃。这条和 lambda 无关但用 lambda 写比较器时特别容易犯。捕获列表里能用constexpr全局常量这里的kThreshold就不要捕获进来 —— 不捕获的 lambda 是 1 字节的空类还可能被优化成常量捕获了就变成带成员的闭包对象。std::count_if返回ptrdiff_tprintf 里显式static_castlong long是为了避免%d撞上 64 位宽度的警告。官方文档std::sort、std::find_if8. 内联友好性为什么 lambda 比 std::function 快这是 lambda 最被低估的优势。std::function是类型擦除容器它把 lambda 的具体类型藏起来只保留「用统一签名调用」的能力 —— 代价是调用时必须通过指针间接跳转编译器看不到函数体内联inline不了。维度直接传 lambda / 模板形参std::functionSig具体类型保留编译器看得见擦除只剩签名调用方式直接调用可内联经函数指针 / 虚表间接跳转对象大小无捕获 1 字节有捕获 捕获成员之和固定 32 字节libstdc可能触发堆分配能否放进同一个容器不同 lambda 类型不同放不进去可以这正是它存在的理由适合场景热路径、模板算法、每处都不一样的小规则需要统一类型存储 / 跨模块传递回调实测一下两边的体积差和调用方式#includecstdio#includefunctional// 老接口参数是 std::function具体类型被擦除调用退化为一次间接跳转intrun_std_function(conststd::functionint(int)f,intx){returnf(x);}// 模板接口任何可调用对象都能进来具体类型保留编译器看得到函数体templatetypenameFintrun_template(Ff,intx){returnf(x);}intmain(){constautolam[](intx){returnx*2;};std::printf(无捕获 lambda : %zu 字节\n,sizeof(lam));std::printf(std::functionint(int) : %zu 字节\n,sizeof(std::functionint(int){}));std::printf(run_template(lam, 21) %d\n,run_template(lam,21));std::printf(run_std_function(lam, 21) %d\n,run_std_function(lam,21));return0;}无捕获 lambda : 1 字节 std::functionint(int) : 32 字节 run_template(lam, 21) 42 run_std_function(lam, 21) 42结果一样都是 42但代价完全不同run_template是模板F被推导成闭包类型本身函数体是可见的x * 2会被完整内联进run_template最终等价于直接写x * 2零额外开销。run_std_function里f(x)只能通过类型擦除后的调用桩跳过去多一次间接跳转同时挡住内联和后续的常量传播、向量化。热循环里这段开销会累积。顺带一提std::function的 32 字节是按值传参时白拷的固定成本而主题是「用const传」也只能省掉拷贝、省不掉间接调用。结论模板参数F/auto形参优先只有真正需要「同一个变量先后装不同可调用对象」「把回调存进容器或跨 ABI 边界传出」时才用std::function。想亲眼确认内联有没有发生把两版代码丢进 Compiler Explorer 看-O2的汇编差别一眼可见。官方文档std::function — cppreference、C Core Guidelines F.509. 完整示例一条成绩处理流水线把比较器、谓词、映射、归约四种 lambda 串成一个能整体跑的程序#includealgorithm#includecstdio#includeiterator#includestring#includevectorstructStudent{std::string name;intscore;};intmain(){conststd::vectorStudentstudents{{Amy,72},{Bob,91},{Cid,58},{Dan,85},{Eve,44}};// 1) 筛选及格名单std::vectorStudentpassed;std::copy_if(students.begin(),students.end(),std::back_inserter(passed),[](constStudents){returns.score60;});// 2) 排序分数降序std::sort(passed.begin(),passed.end(),[](constStudenta,constStudentb){returna.scoreb.score;});// 3) 映射分数 - 等级std::vectorstd::stringgrades;std::transform(passed.begin(),passed.end(),std::back_inserter(grades),[](constStudents){returns.score85?A:B;});for(std::size_t i0;ipassed.size();i){std::printf(%-4s %3d %s\n,passed[i].name.c_str(),passed[i].score,grades[i].c_str());}// 4) 找最值lambda 当比较器constautobeststd::max_element(students.begin(),students.end(),[](constStudenta,constStudentb){returna.scoreb.score;});std::printf(最高分%s %d\n,best-name.c_str(),best-score);return0;}Bob 91 A Dan 85 A Amy 72 B 最高分Bob 91编译命令g -stdc17 -O2 -Wall lambda_demo.cpp -o lambda_demo。这段代码里集中了几个前面讲过的点四个 lambda全都没有捕获因此每一个都是 1 字节的空类std::copy_if/std::sort/std::transform/std::max_element拿到的都是模板实参operator()会被完全内联进算法里 —— 这正是 STL 算法配 lambda 能做到「和手写循环一样快」的原因。s.score 85 ? A : B推导出的返回类型是const char*赋给std::vectorstd::string时隐式构造字符串如果想省掉这次构造改成返回std::string也可以代价是每次调用多一次构造按需选。%-4s、%3d只控制对齐不影响数值。全程只用标准库、没有裸new/delete、没有std::endl符合知识库的基调。10. 延伸阅读Lambda expressions — cppreference语法结构与「闭包类型」的官方定义第 3 节的图就是它的白话版std::function — cppreference重点看类型擦除与小对象优化的说明Function declaration — cppreference返回类型推导的规则和普通函数完全一致对照读更容易记C Core Guidelines — F.50 / F.52什么时候该用 lambda、什么时候反而该写具名函数Compiler Explorer验证「lambda 有没有被内联」的唯一可靠手段11. 一句话总结lambda 会被编译器展开成一个匿名闭包类捕获列表变成数据成员、函数体变成默认const的operator()—— 所以无捕获 lambda 只有 1 字节、可以转成函数指针有捕获就按成员大小长胖还要考虑 padding而具体类型保留意味着能被内联这正是它比std::function快的根本原因省掉- 返回类型时多条return必须类型严格一致。