
std::function是 C 里最好用的工具之一也是热路径上最容易捅刀子的工具之一。它能把 lambda、函数指针、仿函数统一装进同一个类型里存起来但这份「统一」不是免费的同样的 lambda直接传给模板参数能跑得飞快包进std::function之后就可能慢上好几倍还可能在你没注意的时候偷偷申请堆内存。这篇把这三块开销存储、调用、内联拆开量一遍最后给出不该犹豫的选型规则。1. 引子同一段循环换个参数类型就慢了好几倍先看一个现象。下面三段代码做的是完全一样的事把x 1这个操作重复调用两千万次并累加。唯一的差别是「可调用对象怎么传进去」// bench.cpp — 编译: g -stdc17 -Wall -O2 bench.cpp -o bench#includechrono#includecstdio#includefunctionalconstexprintkIterations20000000;// 2e7 次调用intadd_one(intx){returnx1;}// ① 模板参数调用点看得见 lambda 的完整类型templatetypenameFlonglongbench_template(F f){longlongsum0;for(inti0;ikIterations;i)sumf(i);returnsum;}// ② std::function类型被擦除成一个统一的壳longlongbench_std_function(conststd::functionint(int)f){longlongsum0;for(inti0;ikIterations;i)sumf(i);returnsum;}// ③ 函数指针编译器同样不知道它到底指向谁longlongbench_function_pointer(int(*f)(int)){longlongsum0;for(inti0;ikIterations;i)sumf(i);returnsum;}intmain(){autolambda[](intx){returnx1;};std::functionint(int)erasedlambda;constautot0std::chrono::steady_clock::now();constlonglongs1bench_template(lambda);constautot1std::chrono::steady_clock::now();constlonglongs2bench_std_function(erased);constautot2std::chrono::steady_clock::now();constlonglongs3bench_function_pointer(add_one);constautot3std::chrono::steady_clock::now();constautoms[](autoa,autob){returnstd::chrono::durationdouble,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::functionSig是**类型擦除type erasure**容器对外只暴露一个签名Sig把「里面装的到底是什么类型」藏起来。代价是它必须在运行时通过函数指针转一手才能调用。官方文档std::function — cppreferencestd::functionint(int)在 libstdcgcc 13.2.0x86-64里的实际布局大致是这样std::functionint(int) 对象栈上实测 32 字节 ┌──────────────────────────────────────────────────┐ │ _M_functor : 16 字节「小对象缓冲区」(SBO) │ │ 被存的可调用对象 16 字节、 │ │ 且移动构造 nothrow 时 → 就地存放 │ │ 零堆分配 │ │ 否则 → 这块空间改存一个堆指针 │ ├──────────────────────────────────────────────────┤ │ _M_manager : 函数指针 → 指向「管理函数」 │ │ 负责拷贝 / 销毁 / 判断是否在缓冲里 │ │ _M_invoker : 函数指针 → 指向「调用函数」 │ │ 真正调用时的间接跳转入口 │ └──────────────────────────────────────────────────┘记住两件事就够了存储可能落到堆上调用必然多一层间接。3. 实测sizeof 与「到底有没有堆分配」先量大小。std::function是定长大小的无论装什么进去它自己永远是那么大这正是它能放进容器的原因。// sizes.cpp — 编译: g -stdc17 -Wall -O2 sizes.cpp -o sizes#includecstdio#includefunctionalintfree_function(intx){returnx1;}intmain(){autono_capture[](intx){returnx1;};intbase100;autocaptured[base](intx){returnxbase;};int(*fptr)(int)free_function;std::functionint(int)erasedno_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::functionint(int) sizeof %zu\n,sizeof(erased));constboolfitssizeof(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::functionint(int) sizeof 32 捕获 1 个 int 能否进 SBO 能 三者调用结果 2 101 2无捕获 lambda 只占 1 字节空类也要有唯一地址所以最小是 1而std::function恒定 32 字节。这个差距平时无所谓但如果你要把成千上万个回调塞进std::vector32 字节 × N 的缓存占用差距就实实在在。真正要命的是堆分配。下面这段替换了全局operator new/operator delete来计数这只是测量手段业务代码里绝不手写new/delete// alloc_count.cpp — 编译: g -stdc17 -Wall -O2 alloc_count.cpp -o alloc_count#includecstdio#includecstdlib#includefunctional#includenew// 仅用于测量的全局钩子统计堆分配次数std::size_t g_allocs0;void*operatornew(std::size_t n){g_allocs;if(void*pstd::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(){intbase100;autosmall[base](intx){returnxbase;};// 捕获 4 字节能进 SBOBig big{};autolarge[big](intx){returnxstatic_castint(big.a);};// 捕获 32 字节进不去g_allocs0;std::functionint(int)f1small;std::printf(装小 lambda4 字节时的堆分配次数 %zu\n,g_allocs);g_allocs0;std::functionint(int)f2large;std::printf(装大 lambda32 字节时的堆分配次数 %zu\n,g_allocs);std::printf(调用结果 %d %d\n,f1(1),f2(2));}装小 lambda4 字节时的堆分配次数 0 装大 lambda32 字节时的堆分配次数 1 调用结果 101 2结论很直接小对象优化Small Buffer Optimization, SBO只对够小的可调用对象生效。捕获一个int是 0 次分配捕获一个 32 字节的结构体就变成 1 次分配而且这个分配发生在你把 lambda 塞进std::function的那一刻不在调用时。注意std::function要求被存对象可拷贝构造所以捕获std::unique_ptr的 lambda 根本装不进去#includefunctional#includememory// 这个 lambda 捕获了 move-only 的 unique_ptrautomake_owned_lambda(){return[pstd::make_uniqueint(1)]{return*p;};}voidcannot_be_stored(){// 下面这行编译不过C17std::function 要求可拷贝构造unique_ptr 不可拷贝。// std::functionint() f make_owned_lambda(); // ✗ 编译错误// 想装 move-only 的可调用对象得等 C23 的 std::move_only_function。}上面这段没有main()是纯展示片段。官方文档std::function 的模板参数要求CopyConstructible、std::move_only_functionC234. 为什么调用慢两层间接全程无法内联看调用路径的差别一眼就明白① 模板参数编译期类型完全可见 → 前端就能内联 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 -stdc17 -Wall -O2 event_bus.cpp -o event_bus#includecstdio#includefunctional#includemap#includestring#includeutility#includevectorusingHandlerstd::functionvoid(conststd::string);classEventBus{std::mapstd::string,std::vectorHandlerhandlers_;// 存起来 → 非 std::function 不可public:voidon(conststd::stringevent,Handler handler){handlers_[event].push_back(std::move(handler));}voidemit(conststd::stringevent,conststd::stringpayload)const{constautoithandlers_.find(event);if(ithandlers_.end()){std::printf([bus] 没有订阅者: %s\n,event.c_str());return;}for(constautohandler:it-second)handler(payload);}};intmain(){EventBus bus;conststd::string tagv1;bus.on(login,[tag](conststd::stringwho){// 带捕获 → 闭包状态被存下来std::printf([%s] 欢迎 %s\n,tag.c_str(),who.c_str());});bus.on(login,[](conststd::stringwho){// 无捕获 → 走 SBO堆上不花钱std::printf([audit] 记录登录: %s\n,who.c_str());});bus.emit(login,miao);bus.emit(logout,miao);std::printf(--- 运行时追加一套回调 ---\n);bus.on(login,[](conststd::stringwho){// 运行时动态扩容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::vectorHandler就是std::function存在的理由[tag]、无捕获 lambda、函数指针、仿函数全都转成同一个Handler类型后存进同一个vector。这就是「32 字节换类型稳定」的交易在事件总线这种调用频率远低于注册频率的地方完全值得。7. 延伸阅读std::function — cppreference成员函数、类型要求、与std::bind的关系写代码时随手查Lambda 表达式 — cppreference搞清「lambda 是编译器生成的类」这件事才能理解它为什么能内联类型擦除 — cppreferencestd::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让编译器有机会把回调彻底内联掉。