std::function是 C++ 里最好用的工具之一,也是热路径上最容易捅刀子的工具之一。它能把 lambda、函数指针、仿函数统一装进同一个类型里存起来;但这份「统一」不是免费的:同样的 lambda,直接传给模板参数能跑得飞快,包进std::function之后就可能慢上好几倍,还可能在你没注意的时候偷偷申请堆内存。这篇把这三块开销(存储、调用、内联)拆开量一遍,最后给出不该犹豫的选型规则。
1. 引子:同一段循环,换个参数类型就慢了好几倍
先看一个现象。下面三段代码做的是完全一样的事:把x + 1这个操作重复调用两千万次并累加。唯一的差别是「可调用对象怎么传进去」:
// bench.cpp — 编译: g++ -std=c++17 -Wall -O2 bench.cpp -o bench#include<chrono>#include<cstdio>#include<functional>constexprintkIterations=20000000;// 2e7 次调用intadd_one(intx){returnx+1;}// ① 模板参数:调用点看得见 lambda 的完整类型template<typenameF>longlongbench_template(F f){longlongsum=0;for(inti=0;i<kIterations;++i)sum+=f(i);returnsum;}// ② std::function:类型被擦除成一个统一的壳longlongbench_std_function(conststd::function<int(int)>&f){longlongsum=0;for(inti=0;i<kIterations;++i)sum+=f(i);returnsum;}// ③ 函数指针:编译器同样不知道它到底指向谁longlongbench_function_pointer(int(*f)(int)){longlongsum=0;for(inti=0;i<kIterations;++i)sum+=f(i);returnsum;}intmain(){autolambda=[](intx){returnx+1;};std::function<int(int)>erased=lambda;constautot0=std::chrono::steady_clock::now();constlonglongs1=bench_template(lambda);constautot1=std::chrono::steady_clock::now();constlonglongs2=bench_std_function(erased);constautot2=std::chrono::steady_clock::now();constlonglongs3=bench_function_pointer(add_one);constautot3=std::chrono::steady_clock::now();constautoms=[](autoa,autob){returnstd::chrono::duration<double,std::milli>(b-a).count();};std::printf("三处结果一致: %lld / %lld / %lld\n",s1,s2,s3);std::printf("template : %.1f ms\n",ms(t0,t1));std::printf("std::function : %.1f ms\n",ms(t1,t2));std::printf("function ptr : %.1f ms\n",ms(t2,t3));}三处结果一致: 200000010000000 / 200000010000000 / 200000010000000 template : ~~ ms std::function : ~~ ms function ptr : ~~ ms耗时那三行用~~占位:具体数字随机器和这次运行浮动,写死没意义。要看的是相对关系:在这份实测里std::function那一档明显贵于另外两档,而template那一档通常最快(-O2下它甚至可能被优化到接近零耗时,因为整段循环被折叠成了一个常量)。为什么会有这个差距,得从std::function的内部结构说起。
2. 核心概念:std::function 是一个类型擦除容器
一句话:std::function<Sig>是**类型擦除(type erasure)**容器:对外只暴露一个签名Sig,把「里面装的到底是什么类型」藏起来。代价是它必须在运行时通过函数指针转一手才能调用。
官方文档:std::function — cppreference
std::function<int(int)>在 libstdc++(gcc 13.2.0,x86-64)里的实际布局大致是这样:
std::function<int(int)> 对象(栈上,实测 32 字节) ┌──────────────────────────────────────────────────┐ │ _M_functor : 16 字节「小对象缓冲区」(SBO) │ │ 被存的可调用对象 <= 16 字节、 │ │ 且移动构造 nothrow 时 → 就地存放 │ │ (零堆分配) │ │ 否则 → 这块空间改存一个堆指针 │ ├──────────────────────────────────────────────────┤ │ _M_manager : 函数指针 → 指向「管理函数」 │ │ (负责拷贝 / 销毁 / 判断是否在缓冲里) │ │ _M_invoker : 函数指针 → 指向「调用函数」 │ │ (真正调用时的间接跳转入口) │ └──────────────────────────────────────────────────┘记住两件事就够了:存储可能落到堆上,调用必然多一层间接。
3. 实测:sizeof 与「到底有没有堆分配」
先量大小。std::function是定长大小的:无论装什么进去,它自己永远是那么大,这正是它能放进容器的原因。
// sizes.cpp — 编译: g++ -std=c++17 -Wall -O2 sizes.cpp -o sizes#include<cstdio>#include<functional>intfree_function(intx){returnx+1;}intmain(){autono_capture=[](intx){returnx+1;};intbase=100;autocaptured=[base](intx){returnx+base;};int(*fptr)(int)=free_function;std::function<int(int)>erased=no_capture;std::printf("无捕获 lambda sizeof = %zu\n",sizeof(no_capture));std::printf("捕获 1 个 int 的 lambda sizeof = %zu\n",sizeof(captured));std::printf("函数指针 sizeof = %zu\n",sizeof(fptr));std::printf("std::function<int(int)> sizeof = %zu\n",sizeof(erased));constboolfits=sizeof(captured)<=16;// libstdc++ 的 SBO 缓冲是 16 字节std::printf("捕获 1 个 int 能否进 SBO = %s\n",fits?"能":"不能");std::printf("三者调用结果 = %d %d %d\n",fptr(1),captured(1),erased(1));}无捕获 lambda sizeof = 1 捕获 1 个 int 的 lambda sizeof = 4 函数指针 sizeof = 8 std::function<int(int)> sizeof = 32 捕获 1 个 int 能否进 SBO = 能 三者调用结果 = 2 101 2无捕获 lambda 只占 1 字节(空类也要有唯一地址,所以最小是 1),而std::function恒定 32 字节。这个差距平时无所谓,但如果你要把成千上万个回调塞进std::vector,32 字节 × N 的缓存占用差距就实实在在。
真正要命的是堆分配。下面这段替换了全局operator new/operator delete来计数(这只是测量手段,业务代码里绝不手写new/delete):
// alloc_count.cpp — 编译: g++ -std=c++17 -Wall -O2 alloc_count.cpp -o alloc_count#include<cstdio>#include<cstdlib>#include<functional>#include<new>// 仅用于测量的全局钩子:统计堆分配次数std::size_t g_allocs=0;void*operatornew(std::size_t n){++g_allocs;if(void*p=std::malloc(n))returnp;throwstd::bad_alloc();}voidoperatordelete(void*p)noexcept{std::free(p);}voidoperatordelete(void*p,std::size_t)noexcept{std::free(p);}structBig{doublea,b,c,d;};// 32 字节,远超 16 字节的 SBO 缓冲intmain(){intbase=100;autosmall=[base](intx){returnx+base;};// 捕获 4 字节,能进 SBOBig big{};autolarge=[big](intx){returnx+static_cast<int>(big.a);};// 捕获 32 字节,进不去g_allocs=0;std::function<int(int)>f1=small;std::printf("装小 lambda(4 字节)时的堆分配次数 = %zu\n",g_allocs);g_allocs=0;std::function<int(int)>f2=large;std::printf("装大 lambda(32 字节)时的堆分配次数 = %zu\n",g_allocs);std::printf("调用结果 = %d %d\n",f1(1),f2(2));}装小 lambda(4 字节)时的堆分配次数 = 0 装大 lambda(32 字节)时的堆分配次数 = 1 调用结果 = 101 2结论很直接:小对象优化(Small Buffer Optimization, SBO)只对够小的可调用对象生效。捕获一个int是 0 次分配;捕获一个 32 字节的结构体就变成 1 次分配,而且这个分配发生在你把 lambda 塞进std::function的那一刻,不在调用时。注意std::function要求被存对象可拷贝构造,所以捕获std::unique_ptr的 lambda 根本装不进去:
#include<functional>#include<memory>// 这个 lambda 捕获了 move-only 的 unique_ptrautomake_owned_lambda(){return[p=std::make_unique<int>(1)]{return*p;};}voidcannot_be_stored(){// 下面这行编译不过(C++17):std::function 要求可拷贝构造,unique_ptr 不可拷贝。// std::function<int()> f = make_owned_lambda(); // ✗ 编译错误// 想装 move-only 的可调用对象,得等 C++23 的 std::move_only_function。}上面这段没有main(),是纯展示片段。
官方文档:std::function 的模板参数要求(CopyConstructible)、std::move_only_function(C++23)
4. 为什么调用慢:两层间接,全程无法内联
看调用路径的差别,一眼就明白:
① 模板参数:编译期类型完全可见 → 前端就能内联 caller ──直接调用──► lambda 的 operator() (没有间接跳转,甚至整段循环都能被向量化 / 常量折叠) ② 函数指针:编译器不知道它指向哪个函数 caller ──间接跳转 (*fp)──► ??? (跳转目标要靠分支预测器猜,一猜错就是流水线冲刷) ③ std::function:先过「调用函数」这道门,再进到对象里 caller ──间接跳转 (_M_invoker)──► 缓冲 / 堆里的对象 ──► operator() (两层间接,而且 _M_invoker 是不透明函数指针,内联完全没戏)三条理由,按重要性排:
| 开销来源 | 模板参数 | 函数指针 | std::function |
|---|---|---|---|
| 能否内联 | 能,类型全可见 | 不能,指向不明 | 不能,且多一层壳 |
| 调用指令 | 直接调用 | 一次间接跳转 | 一次间接跳转 + 壳内再转一次 |
| 能否被优化器跨函数优化 | 能 | 不能 | 不能 |
| 对象大小 | 编译期已知,最小 | 8 字节 | 恒定 32 字节(libstdc++) |
| 堆分配 | 无 | 无 | 超出 SBO 时 1 次 |
| 分支预测 | 无需预测 | 需预测,目标稳定时还不错 | 同函数指针,但跳转点更多 |
关键结论:std::function的调用开销主要不是「多几条指令」,而是切断了内联。一旦不能内联,编译器就没法把这个回调和小循环一起优化,常量折叠、向量化、寄存器分配统统失效。这就是第 1 节里耗时差距的根本原因,跟「虚函数比普通函数慢多少」是同一类问题。
官方文档:C++ Core Guidelines · Per.11(别把函数实现搬到热路径之外)、std::function::operator() 的调用语义
5. 那什么时候必须用 std::function
看到上面这些,很容易得出「永远别用std::function」的错误结论。事实是:它解决的是模板解决不了的问题,类型稳定。模板参数会让每个 lambda 都实例化出一份新代码,你没法把「不同类型的东西」放进同一个容器、同一个类成员。
| 需求 | 该用什么 | 为什么 |
|---|---|---|
把回调存起来(vector/map/ 类成员) | std::function | 容器要求元素同类型,模板做不到 |
| 运行时替换行为(配置驱动、插件) | std::function | 可以整体赋值换一个实现 |
| 跨 ABI / 跨 DLL传递回调 | std::function(或函数指针) | 模板实例无法跨二进制边界稳定传递 |
| 类型稳定的公开接口(头文件少暴露模板) | std::function | 编译期耦合小,编译更快 |
| 性能敏感的热路径、做算法的高阶函数 | 模板参数 | 可内联,零擦除开销 |
auto就能接住的就地使用 | lambda 本身 | 最轻,连std::function的 32 字节都省了 |
一句话决策:要存、要换、要跨界 →std::function;只在当前调用栈里用一次 → 模板参数或auto。
6. 完整示例:一个回调注册表
这个例子把前面所有点串起来,它必须用std::function,因为回调要被存进容器:
// event_bus.cpp — 编译: g++ -std=c++17 -Wall -O2 event_bus.cpp -o event_bus#include<cstdio>#include<functional>#include<map>#include<string>#include<utility>#include<vector>usingHandler=std::function<void(conststd::string&)>;classEventBus{std::map<std::string,std::vector<Handler>>handlers_;// 存起来 → 非 std::function 不可public:voidon(conststd::string&event,Handler handler){handlers_[event].push_back(std::move(handler));}voidemit(conststd::string&event,conststd::string&payload)const{constautoit=handlers_.find(event);if(it==handlers_.end()){std::printf("[bus] 没有订阅者: %s\n",event.c_str());return;}for(constauto&handler:it->second)handler(payload);}};intmain(){EventBus bus;conststd::string tag="v1";bus.on("login",[tag](conststd::string&who){// 带捕获 → 闭包状态被存下来std::printf("[%s] 欢迎 %s\n",tag.c_str(),who.c_str());});bus.on("login",[](conststd::string&who){// 无捕获 → 走 SBO,堆上不花钱std::printf("[audit] 记录登录: %s\n",who.c_str());});bus.emit("login","miao");bus.emit("logout","miao");std::printf("--- 运行时追加一套回调 ---\n");bus.on("login",[](conststd::string&who){// 运行时动态扩容std::printf("[v2] hi %s\n",who.c_str());});bus.emit("login","miao");}[v1] 欢迎 miao [audit] 记录登录: miao [bus] 没有订阅者: logout --- 运行时追加一套回调 --- [v1] 欢迎 miao [audit] 记录登录: miao [v2] hi miaoEventBus里那个std::vector<Handler>就是std::function存在的理由:[tag]、无捕获 lambda、函数指针、仿函数,全都转成同一个Handler类型后存进同一个vector。这就是「32 字节换类型稳定」的交易,在事件总线这种调用频率远低于注册频率的地方,完全值得。
7. 延伸阅读
- std::function — cppreference:成员函数、类型要求、与
std::bind的关系,写代码时随手查 - Lambda 表达式 — cppreference:搞清「lambda 是编译器生成的类」这件事,才能理解它为什么能内联
- 类型擦除 — cppreference:
std::function、std::any、std::shared_ptr删除器背后共用的机制 - C++ Core Guidelines · F.27 / Per.11:标准库给出的「什么时候该按值持有可调用对象」的官方立场
本知识库内的相关篇目:
- 《函数指针、仿函数、lambda、std::function 四方对比与选型》 —— 同一件「可调用对象」的事,C++ 给了四种写法。
- 《C++ lambda 表达式完全指南:从语法糖到编译器生成的闭包类型》 —— lambda 不是一个「特殊的函数」
- 《C++ lambda 捕获列表详解:值捕获、引用捕获、初始化捕获与悬垂引用》 —— 捕获列表决定闭包能活多久、占多少字节、看到的是快照还是实时值。
8. 一句话总结
std::function用「可能的堆分配 + 恒定 32 字节 + 必然的间接调用」换来「统一的类型」,因此它该出现在要存、要换、要跨边界的地方;热路径上一律优先模板参数或auto,让编译器有机会把回调彻底内联掉。