ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

函数指针、仿函数、lambda、std::function 四方对比与选型

函数指针、仿函数、lambda、std::function 四方对比与选型 「回调」这件事在 C 里有四种表达方式函数指针、仿函数functor也叫函数对象 function object、lambda、std::function。新手常见的问题是随手抓一个就用 —— 该用 lambda 的地方写了个全局函数该用std::function的地方硬塞函数指针。这篇把它们摆到同一张桌子上用实测数据和同一个场景把差异量清楚最后给出一张照着走就不会错的决策树。1. 引子一次「顺手用函数指针」引发的重构设想一个最普通的排序需求按年龄给一组人排序。函数指针看起来最省事片段假设已#include string/vector/algorithmstructPerson{std::string name;intage;intscore;};boolby_age(constPersona,constPersonb){returna.ageb.age;}std::sort(v.begin(),v.end(),by_age);// 能跑看着也清爽但需求一变就崩了产品说「分数达标的排前面达标的人内部再按年龄排」。这个规则带一个阈值 —— 函数指针没地方放这个阈值。于是只能加一个全局变量或者写一堆by_age_and_score_threshold_85。这就是四种方式分野的起点能不能携带状态。下面从大小开始量。2. 先量大小sizeof 实测// sizes.cpp — 编译: g -stdc17 -Wall -O2 sizes.cpp -o sizes#includecstdio#includefunctional// ① 函数指针无状态intadd_one(intx){returnx1;}// ② 仿函数手写类状态就是成员structAddN{intn;explicitAddN(intv):n{v}{}intoperator()(intx)const{returnxn;}};intmain(){autono_capture[](intx){returnx1;};// ③ 无捕获 lambdaintoffset7;autocapturing[offset](intx){returnxoffset;};// ③ 有捕获 lambdaint(*fptr)(int)add_one;// 函数指针AddN functor{7};// 仿函数std::functionint(int)erasedno_capture;// ④ std::functionstd::printf(函数指针 sizeof %zu\n,sizeof(fptr));std::printf(仿函数 AddN sizeof %zu\n,sizeof(functor));std::printf(无捕获 lambda sizeof %zu\n,sizeof(no_capture));std::printf(捕获 1 个 int 的 lambda sizeof %zu\n,sizeof(capturing));std::printf(std::functionint(int) sizeof %zu\n,sizeof(erased));std::printf(四者调用结果 %d %d %d %d\n,fptr(1),functor(1),no_capture(1),erased(1));}函数指针 sizeof 8 仿函数 AddN sizeof 4 无捕获 lambda sizeof 1 捕获 1 个 int 的 lambda sizeof 4 std::functionint(int) sizeof 32 四者调用结果 2 8 2 2三个值得记住的数字函数指针 8 字节就是一个地址没有状态可言。无捕获 lambda 1 字节—— 它确实是个类但这个类没有任何数据成员空类最小占 1 字节保证每个对象地址唯一。std::function 32 字节固定不变。它是「类型擦除壳」的尺寸装什么都是这个大小被装的对象超过内部 16 字节缓冲区时会额外落到堆上详见《std::function 为什么比 lambda 慢》。为什么关心大小把这四种东西放进std::vector...当回调队列时每个元素的字节数直接决定遍历时的缓存局部性。1 字节 vs 32 字节差 32 倍 —— 一万个回调就是 10KB 和 320KB 的区别后者已经把 L1 挤出局了。官方文档Lambda 表达式 — cppreference看「闭包类型」那段lambda 是编译器生成的类这个事实写在标准里3. 六维大表四方对比维度函数指针仿函数lambdastd::function能否携带状态✗ 只能靠全局变量✓ 成员变量✓ 捕获列表✓ 存的闭包带状态能否内联✗ 指向不明✓ 类型可见即可内联✓ 同仿函数✗ 隔一层壳内联无望调用开销一次间接跳转与普通函数调用相同同仿函数间接跳转 壳内再转一次sizeof实测84含 1 个 int1无捕获/ 4捕获 int32恒定能否跨 ABI✓ C 兼容✗ 模板/类布局不稳定✗ 闭包类型独一无二✓ 签名固定可跨 DLL能否存进容器✓ 但要求签名完全一致✗ 每个仿函数类型都不同✗ 每个 lambda 类型都不同✓ 这是它的看家本领能否运行时替换✓ 但只能换同签名的✗ 类型不同换不了✗ 类型不同换不了✓ 直接赋值即可可读性中要翻到函数定义处差逻辑离调用点远优就地写就地读中多一层包装这张表里最关键的一行是「能否存进容器」。容器要求所有元素同类型而每个 lambda、每个仿函数都是独一无二的类型—— 所以只有std::function或函数指针能把它们塞进同一个vector。这是std::function唯一不可替代的位置。官方文档std::function — cppreference、C Core Guidelines · F.27用 std::function 共享可调用对象4. 状态在哪里函数指针的困境「能不能带状态」这条差异用一个例子最能说清// state.cpp — 编译: g -stdc17 -Wall -O2 state.cpp -o state#includecstdio// ① 函数指针没有地方存状态只能借全局变量 —— 这是不可重入的根源intg_threshold0;// 反例全局可变状态多线程下直接炸intover_threshold_ptr(intx){returnxg_threshold;}// ② 仿函数把状态放进成员天然可重入想有几套状态就有几套classOverThreshold{intthreshold_;public:explicitOverThreshold(intt):threshold_{t}{}intoperator()(intx)const{returnxthreshold_;}};intmain(){constintlimits[]{10,100,1000};// 函数指针版每次换阈值都得改全局变量std::printf(函数指针版: );for(intlimit:limits){g_thresholdlimit;std::printf(%d ,over_threshold_ptr(50));}std::printf(\n);// 仿函数版两套阈值可以同时活着互不干扰constautoover10OverThreshold{10};constautoover100OverThreshold{100};std::printf(仿函数版: %d %d\n,over10(50),over100(50));}函数指针版: 1 0 0 仿函数版: 1 0函数指针版能跑但它的正确性依赖「调用前一定要记得设置g_threshold」这个隐性契约。一旦两个线程同时用不同阈值判断结果就是错的 —— 而且不会报错。仿函数以及它的语法糖 lambda把状态关进了对象里同一个逻辑可以有无限多个互不干扰的实例。官方文档C Core Guidelines · ES.28 / F.50捕获列表 vs 全局变量5. 同一场景四种写法现在回到第 1 节的排序需求看四种方式各自的形态// comparator.cpp — 编译: g -stdc17 -Wall -O2 comparator.cpp -o comparator#includealgorithm#includecstdio#includefunctional#includestring#includevectorstructPerson{std::string name;intage;intscore;};// ① 函数指针只能表达「固定规则」带不了阈值boolby_age(constPersona,constPersonb){returna.ageb.age;}// ② 仿函数把「按哪个字段排」做成状态一处逻辑多处复用structByField{intfield;// 0 age1 scorebooloperator()(constPersona,constPersonb)const{returnfield0?a.ageb.age:a.scoreb.score;}};voidprint(constchar*title,conststd::vectorPersonv){std::printf(%s: ,title);for(constautop:v)std::printf(%s(%d/%d) ,p.name.c_str(),p.age,p.score);std::printf(\n);}intmain(){conststd::vectorPersonpeople{{miao,30,90},{wang,25,95},{li,35,80}};{autovpeople;std::sort(v.begin(),v.end(),by_age);// ① 函数指针print(① 函数指针 按年龄,v);}{autovpeople;constintmin_score85;// 阈值就是「状态」lambda 直接捕获std::sort(v.begin(),v.end(),[min_score](constPersona,constPersonb){constboola_oka.scoremin_score;constboolb_okb.scoremin_score;if(a_ok!b_ok)returna_ok;// 达标者排前面returna.ageb.age;// 达标内部再按年龄});print(② lambda 达标优先,v);}{autovpeople;std::sort(v.begin(),v.end(),ByField{1});// ③ 仿函数状态 排序键print(③ 仿函数 按分数,v);}{autovpeople;conststd::functionbool(constPerson,constPerson)cmpByField{0};std::sort(v.begin(),v.end(),cmp);// ④ std::function代价最大print(④ function 按年龄,v);}}① 函数指针 按年龄: wang(25/95) miao(30/90) li(35/80) ② lambda 达标优先: wang(25/95) miao(30/90) li(35/80) ③ 仿函数 按分数: li(35/80) miao(30/90) wang(25/95) ④ function 按年龄: wang(25/95) miao(30/90) li(35/80)四段都对但成本差别不小① 函数指针最简单但表达不了「达标优先」这个带阈值的规则。② lambda是这次需求的最优解阈值就地捕获规则就地可读编译器还能内联整个比较 ——std::sort的比较器每次都比较双方调用次数在N log N量级内联收益直接体现在排序耗时上。③ 仿函数适合「同一个规则要在很多地方复用」的情况。它有名字、能被单元测试直接构造比到处复制同一段 lambda 好维护。④std::function这里纯属杀鸡用牛刀比较器只在这一次std::sort调用里用一次完全没有「存起来」的需求白白付出 32 字节 无法内联的代价。排序比较器是最不该用std::function的场景之一。官方文档std::sort注意它对Compare的要求必须满足严格弱序且会被拷贝多次6. 四种方式并存谁才是「统一收纳盒」std::function的价值在下面这个例子里才真正体现 —— 它是唯一能把前三种装进同一个容器的类型// four_ways.cpp — 编译: g -stdc17 -Wall -O2 four_ways.cpp -o four_ways#includecstdio#includefunctional#includestring#includeutility#includevectorstructEvent{std::string name;intvalue;};// ① 函数指针无状态、C 兼容、最轻voidlog_event(constEvente){std::printf([函数指针] %s%d\n,e.name.c_str(),e.value);}// ② 仿函数状态在成员里可以被多处复用classThreshold{intlimit_;public:explicitThreshold(intlimit):limit_{limit}{}voidoperator()(constEvente)const{if(e.valuelimit_)std::printf([仿函数] 超阈值: %s%d\n,e.name.c_str(),e.value);}};classNotifier{std::vectorstd::functionvoid(constEvent)slots_;// 统一收纳盒public:voidsubscribe(std::functionvoid(constEvent)slot){slots_.push_back(std::move(slot));}voidpublish(constEvente)const{for(constautoslot:slots_)slot(e);}std::size_tsize()const{returnslots_.size();}};intmain(){Notifier notifier;conststd::string prefixaudit;notifier.subscribe(log_event);// ① 函数指针进去notifier.subscribe(Threshold{10});// ② 仿函数进去notifier.subscribe([prefix](constEvente){// ③ lambda 进去std::printf([lambda/%s] %s\n,prefix.c_str(),e.name.c_str());});std::functionvoid(constEvent)dynamic[](constEvente){// ④ 本身是 std::functionstd::printf([std::function] 收到 %s\n,e.name.c_str());};notifier.subscribe(dynamic);std::printf(订阅者数量: %zu\n,notifier.size());notifier.publish({cpu,42});notifier.publish({mem,3});}订阅者数量: 4 [函数指针] cpu42 [仿函数] 超阈值: cpu42 [lambda/audit] cpu [std::function] 收到 cpu [函数指针] mem3 [lambda/audit] mem [std::function] 收到 mem四个完全不同的类型被std::functionvoid(const Event)统一成了同一种元素塞进了同一个vector。注意第二次发布{mem, 3}时Threshold{10}那个订阅者没有输出—— 因为 3 没超过它的阈值 10而这份状态是跟着对象走的不需要任何全局变量。7. 选型决策树需要把回调「存起来」/「运行时替换」/「跨库传递」吗 │ ├─ 是 ──► 需要极省内存或者要装 move-only 的可调用对象 │ ├─ 是 ──► std::move_only_functionC23 │ └─ 否 ──► std::function ★ 万能但最贵 │ └─ 否 ──► 需要携带上下文捕获外部变量吗 │ ├─ 需要 ──► 逻辑短、就地用一次 ──► lambda ★ 默认选择 │ 逻辑长、要复用、要单测 ──► 手写仿函数 │ └─ 不需要 ──► 要跨 ABI / 对接 C 回调 / 逻辑极简 ──► 函数指针 ★ 最轻8 字节现代 C 的默认选择顺序照着念就行优先 lambda—— 就地定义、就地可读、可内联、零额外存储是绝大多数场景的正确答案。需要类型稳定的接口才用函数指针—— 比如要给 C 库注册回调、要跨动态库边界、要写进struct当函数表。需要存起来或运行时替换才用std::function—— 事件总线、插件表、配置驱动的策略。用一次就扔的场景别碰它。需要复杂状态和明确类型才写仿函数—— 逻辑超过十几行、要被多处复用、需要单元测试直接构造、或者需要重载多个operator()。官方文档C Core Guidelines · F.27 及 F.50 系列标准库作者对「何时用 lambda、何时用 std::function」的官方立场官方文档Compiler Explorer把四种写法贴进去打开-O2看汇编 —— 函数指针和 lambda 的调用点会直接内联掉std::function那条call指令会实实在在留在那里。这是验证本文所有结论最直接的办法。8. 一句话总结四种可调用对象不是「谁替代谁」的关系而是从轻到重的四档函数指针 8 字节但无状态lambda 是语法糖版的仿函数、能带状态能内联仿函数适合有名字可复用的复杂逻辑std::function用 32 字节和「无法内联」换来「统一类型」—— 只把它用在真正需要存起来、需要运行时替换、需要跨边界的地方。
返回列表