写 STL 算法的时候,[](const auto& a, const auto& b) { return a.x < b.x; }已经成了肌肉记忆。但「lambda 到底是什么」这个问题,很多人答不上来 —— 它不是函数,也不是std::function,而是编译器为每个 lambda 表达式现场生成的一个匿名类,operator()是它的成员函数。搞清楚这层映射关系之后,「无捕获的能转函数指针」「两个长得一样的 lambda 类型不同」「为什么它比std::function快」这些问题就都能自己推出来了。
1. 引子:没有 lambda 的时候有多啰嗦
想在排序时用「按价格降序」这种一次性规则,C++11 之前的正统写法是写一个函数对象(function object,也叫仿函数 functor):
// 片段:C++98 时代的一次性比较规则,样板代码比逻辑还长structPriceGreater{booloperator()(constItem&a,constItem&b)const{returna.price>b.price;}};std::sort(items.begin(),items.end(),PriceGreater{});// 规则和调用点隔了十万八千里规则写在类里、调用点在下头,读代码的人得来回跳。而且这个类只在这一处用一次,还得给它起名字、放进命名空间(或者塞在匿名 namespace 里)。lambda 解决的正是这个「一次性、就地定义」的需求 —— 它是 C++ Core Guidelines 里 F.50「函数不够用时就用 lambda」那条的实现方式。
官方文档:Lambda expressions — cppreference
2. 完整语法结构:六个部件,哪些能省
lambda 的完整形式是六个部件拼起来的,日常代码里通常只写其中三个:
// 片段:一个「全写」的 lambda(大部分部件平时都不写)constintbase=10;autof=[base](intx)mutablenoexcept->int{returnx+base;};// ^^^^^^ ^^^^^ ^^^^^^^ ^^^^^^^^ ^^^^^ ^^^^^^^^^^^// 捕获列表 参数 mutable 异常说明 返回类型 函数体逐个部件的可省略性:
| 部件 | 位置 | 能不能省 | 省掉之后 |
|---|---|---|---|
捕获列表[...] | 最前 | 不能省 | 至少要写空的[],没它就是普通函数 |
参数列表(...) | 次之 | 不能省 | C++11 起无参也要写() |
mutable | 参数之后 | 能省 | operator()是const成员函数,值捕获的成员不能改 |
异常说明noexcept | mutable之后 | 能省 | 表示「可能抛异常」 |
-> 返回类型 | 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_v<decltype(a), decltype(b)>); // ✗ 编译失败:不同类型- 捕获 = 数据成员,所以捕获了什么、按值还是按引用,直接决定这个对象占多少字节 —— 下一节用
sizeof实测。
4. sizeof 实测:无捕获是 1 字节,有捕获就长胖
空类在 C++ 里的大小是 1 字节(保证不同对象有不同地址),没有捕获的 lambda 就是空类:
#include<cstdio>#include<functional>intmain(){constinta=1;constdoubleb=2.0;constautono_cap=[](intx){returnx*2;};constautocap1=[a](intx){returnx+a;};constautocap2=[a,b](intx){returnx+a+static_cast<int>(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::function<int(int)> : %zu 字节\n",sizeof(std::function<int(int)>{}));std::printf("前者是否 1 字节? : %s\n",sizeof(no_cap)==1?"是":"否");return0;}无捕获 lambda : 1 字节 捕获 1 个 int : 4 字节 捕获 int + double : 16 字节 std::function<int(int)> : 32 字节 前者是否 1 字节? : 是实测结果读法(x86-64,gcc 13):
- 无捕获 = 1 字节—— 空类,捕获列表为空就没有数据成员。所以把无捕获 lambda 存进容器或者当模板实参传,零开销。
- 捕获一个
int= 4 字节—— 就是一个int成员。 - 捕获
int+double= 16 字节,不是 12—— 这是对齐与 padding:double要求 8 字节对齐,int后面得补 4 字节空洞,sizeof就被撑到 16。捕获顺序会影响空位多少,理论上编译器可以重排成员,但别指望。 std::function<int(int)>固定 32 字节—— 类型擦除的代价,而且小对象才放得下,装一个捕获了std::string的大 lambda 时它还会去堆上分配一次。
官方文档:std::function — cppreference —— 页面上「实现定义的小对象优化」那句,解释的就是最后这条。
这就是「哪种写法更优」的第一个答案:能用无捕获 lambda 或捕获少变量就尽量别包std::function,前者是 1~N 字节的栈上对象,后者是固定 32 字节 + 可能的一次堆分配 + 一次间接调用。
5. 无捕获 lambda 能变成函数指针
无捕获 lambda 的闭包类里没有数据成员,编译器会额外给它一个转换到函数指针的转换函数。这意味着它可以塞进任何只认函数指针的老式 API:
#include<cstdio>// 老式 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){returnx+1;},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(x>0)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(x>0)return1.0;return1.5;// ✓ 都是 double};autof2=[](intx)->double{// 修法二:显式标注返回类型(推荐)if(x>0)return1;// int 隐式转成 doublereturn1.5;};注意和普通函数对比:普通函数里auto f(int x) { if (x>0) return 1; return 1.5; }同样是错误,规则一致。另外有一条容易忘的边界:省略返回类型时,如果函数体只有一条return,推导就走「auto模板推导」的规则(会丢弃引用和const);所以想返回引用必须显式写-> int&,否则返回的是个副本 —— 这是「返回值类型不对但能编译」的隐蔽 bug。
7. STL 里的典型用法
lambda 最大的舞台就是算法库。四种典型角色:比较器、谓词、映射函数、二元运算:
#include<algorithm>#include<cstdio>#include<numeric>#include<vector>namespace{constexprintkThreshold=10;}structItem{constchar*name;intprice;};intmain(){std::vector<Item>items{{"apple",5},{"banana",3},{"cherry",8},{"durian",30}};// 1) 排序:lambda 当比较器std::sort(items.begin(),items.end(),[](constItem&x,constItem&y){returnx.price>y.price;});std::printf("按价格降序:");for(constauto&it:items)std::printf(" %s(%d)",it.name,it.price);std::printf("\n");// 2) 查找:第一个超过阈值的constautorich=std::find_if(items.begin(),items.end(),[](constItem&it){returnit.price>kThreshold;});if(rich!=items.end()){std::printf("第一个超过 %d 的是 %s\n",kThreshold,rich->name);}// 3) 计数:价格是偶数的constautoeven_cnt=std::count_if(items.begin(),items.end(),[](constItem&it){returnit.price%2==0;});std::printf("价格是偶数的有 %lld 个\n",static_cast<longlong>(even_cnt));// 4) 归约:lambda 当二元运算constinttotal=std::accumulate(items.begin(),items.end(),0,[](intacc,constItem&it){returnacc+it.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_t,printf 里显式static_cast<long long>是为了避免%d撞上 64 位宽度的警告。
官方文档:std::sort、std::find_if
8. 内联友好性:为什么 lambda 比 std::function 快
这是 lambda 最被低估的优势。std::function是类型擦除容器:它把 lambda 的具体类型藏起来,只保留「用统一签名调用」的能力 —— 代价是调用时必须通过指针间接跳转,编译器看不到函数体,内联(inline)不了。
| 维度 | 直接传 lambda / 模板形参 | std::function<Sig> |
|---|---|---|
| 具体类型 | 保留(编译器看得见) | 擦除(只剩签名) |
| 调用方式 | 直接调用,可内联 | 经函数指针 / 虚表间接跳转 |
| 对象大小 | 无捕获 1 字节,有捕获 = 捕获成员之和 | 固定 32 字节(libstdc++),可能触发堆分配 |
| 能否放进同一个容器 | 不同 lambda 类型不同,放不进去 | 可以(这正是它存在的理由) |
| 适合场景 | 热路径、模板算法、每处都不一样的小规则 | 需要统一类型存储 / 跨模块传递回调 |
实测一下两边的体积差和调用方式:
#include<cstdio>#include<functional>// 老接口:参数是 std::function,具体类型被擦除,调用退化为一次间接跳转intrun_std_function(conststd::function<int(int)>&f,intx){returnf(x);}// 模板接口:任何可调用对象都能进来,具体类型保留,编译器看得到函数体template<typenameF>intrun_template(F&&f,intx){returnf(x);}intmain(){constautolam=[](intx){returnx*2;};std::printf("无捕获 lambda : %zu 字节\n",sizeof(lam));std::printf("std::function<int(int)> : %zu 字节\n",sizeof(std::function<int(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::function<int(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.50
9. 完整示例:一条成绩处理流水线
把比较器、谓词、映射、归约四种 lambda 串成一个能整体跑的程序:
#include<algorithm>#include<cstdio>#include<iterator>#include<string>#include<vector>structStudent{std::string name;intscore;};intmain(){conststd::vector<Student>students{{"Amy",72},{"Bob",91},{"Cid",58},{"Dan",85},{"Eve",44}};// 1) 筛选:及格名单std::vector<Student>passed;std::copy_if(students.begin(),students.end(),std::back_inserter(passed),[](constStudent&s){returns.score>=60;});// 2) 排序:分数降序std::sort(passed.begin(),passed.end(),[](constStudent&a,constStudent&b){returna.score>b.score;});// 3) 映射:分数 -> 等级std::vector<std::string>grades;std::transform(passed.begin(),passed.end(),std::back_inserter(grades),[](constStudent&s){returns.score>=85?"A":"B";});for(std::size_t i=0;i<passed.size();++i){std::printf("%-4s %3d %s\n",passed[i].name.c_str(),passed[i].score,grades[i].c_str());}// 4) 找最值:lambda 当比较器constautobest=std::max_element(students.begin(),students.end(),[](constStudent&a,constStudent&b){returna.score<b.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++ -std=c++17 -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::vector<std::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必须类型严格一致。