面试宝典(十八):手写 std::function 类型擦除

📅 2026/8/18 13:10:17
面试宝典(十八):手写 std::function 类型擦除
0. 问题为什么需要类型擦除我们希望有一个可调用对象容器std::vectorstd::functionint(int)handlers;handlers.push_back([](intx){returnx1;});// lambdahandlers.push_back(std::plusint{});// 仿函数handlers.push_back([](intx){returnx*2;});// 另一个 lambda这三个东西的静态类型完全不同每个 lambda 是独有匿名类型仿函数又是另一类型却要存进vector同一类型。普通模板做不到——std::vectorT的 T 必须唯一。类型擦除就是把不同静态类型在某一组统一接口下包装起来对外只暴露这个统一接口把差异擦掉。对外看是std::functionint(int)对内用一层间接虚函数或函数指针调用真实的 callable。1. 方案 A虚函数派发最直观核心是一个多态基类定义统一接口模板派生类把任意 callable 装进去#includememory#includeutility// 统一接口接受 Args...返回 RtemplateclassR,class...ArgsclassFunction;// 前向templateclassR,class...ArgsclassFunctionR(Args...){structConcept{virtual~Concept()default;virtualRinvoke(Args...args)0;virtualConcept*clone()const0;// 支持拷贝};templateclassFstructModel:Concept{F f_;explicitModel(F f):f_(std::move(f)){}Rinvoke(Args...args)override{returnf_(std::forwardArgs(args)...);}Concept*clone()constoverride{returnnewModel(f_);}};std::unique_ptrConceptptr_;public:Function()default;templateclassFFunction(F f):ptr_(std::make_uniqueModelF(std::move(f))){}// 拷贝深拷贝内部 callableFunction(constFunctiono):ptr_(o.ptr_?o.ptr_-clone():nullptr){}Functionoperator(constFunctiono){if(this!o)ptr_o.ptr_?o.ptr_-clone():nullptr;return*this;}// 移动Function(Function)default;Functionoperator(Function)default;Roperator()(Args...args)const{returnptr_-invoke(std::forwardArgs(args)...);}explicitoperatorbool()const{returnbool(ptr_);}};验证Functionint(int)f[](intx){returnx1;};Functionint(int)gf;// 拷贝一份 lambdastd::coutf(41) g(41)\n;// 42 42f[](intx){returnx*2;};std::coutf(21) g(41)\n;// 42 42g 不受影响要点Concept是擦除层接口ModelF是一个模板派生类把具体类型F藏在f_里。因为对外只看到FunctionR(Args...)模板参数F被擦进了Model这就是类型擦除。clone()实现值语义拷贝——std::function是可拷贝的拷贝的是 callable 本身不是引用。必须支持。2. 方案 A 的代价每个Function对象持有一个堆分配make_uniqueModel即使只是一个空 lambda。每次调用走一次虚函数invoke有间接跳转开销且阻碍内联。对小对象多数 lambda 很小来说堆分配 虚调用是浪费。这正是标准库引入 SBO 的原因。3. 方案 B小对象优化SBO标准库思路标准库std::function不用虚函数虚函数表指针也要存而用函数指针 manager 内联缓冲区一块固定大小缓冲区libstdc 约 见_M_max_size通常能放下含少量捕获的 lambda。一个manager函数指针负责 copy / destroy / 以及取出 callable 调用。小 callable 直接placement new进缓冲区无需堆大 callable 退化到堆。手写 SBO 版简化用函数指针而非虚表#includecstring#includenew#includeutilitytemplateclassSigclassSmallFunction;templateclassR,class...ArgsclassSmallFunctionR(Args...){// 缓冲区足够放下典型 lambda含若干捕获staticconstexprsize_t BUF32;alignas(std::max_align_t)charbuf_[BUF];// 调用契约从 storage 取 callable 调它usingInvokerR(*)(void*storage,Args...args);usingManagervoid(*)(void*dst,void*src,intaction);// copy/destroy/moveInvoker invoker_nullptr;Manager manager_nullptr;boolin_heap_false;// 针对具体类型 F 的静态函数编译期生成无虚表templateclassFstaticRinvoke_impl(void*s,Args...args){Ff*static_castF*(s);returnf(std::forwardArgs(args)...);}templateclassFstaticvoidmanage_impl(void*dst,void*src,intaction){if(action0){// copynew(dst)F(*static_castF*(src));}elseif(action1){// destroystatic_castF*(dst)-~F();}}public:SmallFunction()default;templateclassF,classstd::enable_if_tsizeof(F)BUFSmallFunction(F f){static_assert(std::is_invocable_r_vR,F,Args...);new(buf_)F(std::move(f));// 直接放缓冲区无堆分配invoker_invoke_implF;manager_manage_implF;in_heap_false;}~SmallFunction(){if(manager_)manager_(buf_,nullptr,1);}// destroyRoperator()(Args...args)const{returninvoker_(const_castchar*(buf_),std::forwardArgs(args)...);}explicitoperatorbool()const{returninvoker_!nullptr;}};SBO 的关键收益小 lambda 完全在栈上缓冲区运行零堆分配、调用通过普通函数指针仍可内联友好、无虚表查表。这是标准std::function对小对象快于手写虚函数版的原因。注以上为教学简化真实标准库还处理in_heap_大对象走new、移动语义、异常安全。但缓冲区 manager 函数指针的核心结构就是标准实现骨架。4. 为什么不直接用模板参数存 callable有人问为什么不直接templateclass F class Func { F f; };因为那样Func的类型取决于 F无法放进vector统一类型、无法作为函数参数按值传递类型不确定。类型擦除的目的正是把 F 从对外接口中移除对外只暴露R(Args...)这一种签名类型。这是编译期多态模板到运行期多态统一接口的桥梁。5. 与 std::bind / std::mem_fn 的关系std::bind(f, a, b)返回一个可调用对象绑定后的仿函数它能被装进std::function——因为function只要求能调用不关心内部怎么绑的。成员函数调用std::bind(Class::m, obj, _1)或 lambda[obj](int x){ return obj.m(x); }都能擦除进function。理解这点就知道bind不是必须的lambda 几乎总能替代它且更可读。6. std::function 的性能真相开销来源一次间接调用虚函数或 manager 函数指针 可能的小对象搬运。不可内联编译器看不到真实 callable被擦除了无法内联热点调用——这是std::function慢于裸函数/模板的直接原因。替代方案std::function_ref/folly::FunctionRef非拥有引用不拷贝、不分配只借用 callable零开销。适合只在本函数作用域内调用一次的场景。模板参数templateclass F void forEach(F f)零开销但暴露模板、可能增加代码膨胀、无法存入异构容器。if constexpr 概念C20编译期分发。面试常问高频回调用 function 还是模板——答案是性能敏感热路径用模板或 function_ref通用容器/回调存储用 std::function。7. 实战用自写 Function 做事件分发#includevector#includeiostreamintmain(){std::vectorFunctionint(int)v;v.push_back([](intx){returnx1;});v.push_back([](intx){returnx*3;});v.push_back([](intx){returnx-10;});for(autof:v)std::coutf(20) ;// 21 60 10// 拷贝语义autocpv[0];v.clear();std::cout\n拷贝后: cp(5)\n;// 6}这证明我们手写的Function具备值语义拷贝、异构存储与标准库行为一致。8. 类型擦除的通用模式升华std::function只是类型擦除的一个实例。同一套路可用于std::any擦除任意类型只暴露拷贝/销毁/typeid。std::shared_ptrvoid 自定义删除器擦除资源类型。序列化器、访问者模式、插件接口对外统一接口对内隐藏具体类型。记住骨架基类/Concept 定义统一接口 → 模板派生/Model 包裹具体类型 → 对外只暴露基类指针或 manager。这套思维比背 API 重要得多。9. 速答 12 题临场背诵std::function 解决什么问题把任意可调用对象统一成单一类型便于存容器/传参。什么是类型擦除把不同静态类型在统一接口下包装对外隐藏差异。手写 function 的核心结构Concept接口 Model模板派生包裹 F。为什么需要 clonefunction 可拷贝要深拷贝内部 callable值语义。虚函数方案的开销每次堆分配 一次虚调用小对象浪费。SBO 是什么小对象优化把小 callable 直接放缓冲区免堆分配。标准库 function 用虚函数吗不用用 manager 函数指针 内联缓冲区更快。为什么不直接模板存 F那样类型由 F 决定无法统一存储。std::function 能内联吗通常不能callable 被擦除编译期不可见。function_ref 是什么非拥有引用零开销不拷贝不分配适合临时回调。bind 和 lambda 怎么选lambda 几乎总能替代 bind更可读优先 lambda。类型擦除还用在哪std::any、shared_ptr删除器、插件接口等。10. 临场口诀不同类型想同装类型擦除来帮忙Concept 定接口Model 包真身小对象放缓冲SBO免堆分配快如风function 不可内联热路径用 ref 或模板。10. 进阶move-only 与完整堆退化标准std::function要求 callable可拷贝。但 lambda 捕获了std::unique_ptr等不可拷贝对象时它只能移动。若要手写的Function支持这种只移不拷的 lambda需要去掉clone()/ 拷贝构造只保留移动构造与移动赋值 default或手写转移buf_内容。用std::is_copy_constructible在编译期禁用拷贝避免误用。这对应标准库的std::move_only_functionC23 引入。手写时结构同上只是把manager的 copy action 去掉把内部对象std::move进缓冲区。面试答为什么有些 lambda 不能进 std::function——因为 std::function 要求可拷贝而捕获 unique_ptr 的 lambda 不可拷贝需用 move_only_function 或模板参数绕过。关于堆退化第 3 节的 SBO 版假设sizeof(F) BUF。真实实现还要处理大对象当sizeof(F) BUF时改为new F放堆缓冲区里存指针manager的 destroy action 改为delete。这样小对象零分配、大对象安全回退——这正是标准库兼顾性能与正确性的关键手写版务必补上分支。11. 类型擦除反模式与面试连环追问反模式过度擦除能用模板参数的热路径偏要用std::function白白损失内联与增加分配。原则对外暴露统一接口、对内追求零开销时再用模板只有需要异构存储/运行时多态才擦除。把 function 当万能容器std::function有状态持有 callable频繁创建销毁带来分配。高频场景缓存复用或用 function_ref。忽略异常安全SBO 版new (buf_) F(...)若 F 构造抛异常缓冲区处于半初始化生产需try/catch回滚。面试连环追问示例Q你的 function 能存成员函数吗A可以用 lambda 捕获this或std::bind包一层擦除后统一成R(Args...)。Q拷贝后两个 function 共享同一 callable 吗A不共享值语义各自独立靠 clone。Q为什么 function 比模板慢A间接调用无法内联 可能分配模板在编译期展开可内联。Qfunction_ref 和 function 区别A前者非拥有、零开销、不拷贝不分配、生命周期由调用方保证后者拥有、可存储。Q能用 concept 替代 function 吗AC20 可用templateinvocableint F约束模板参数保留零开销但失去异构存储按需求取舍。12. 类型擦除 vs CRTP 静态多态现代 C 篇06讲过CRTP奇异递归模板模式实现静态多态templateclass D class Base { void f(){ static_castD*(this)-impl(); } }。CRTP 在编译期绑定派生类零虚函数开销、可内联但每个派生类产生独立类型无法异构存储进同一容器。类型擦除与 CRTP 是静态多态 vs 运行时多态的两端CRTP零开销、可内联、类型不同、不能统一存。适合性能极致且类型集合封闭。类型擦除function/any有间接开销、不可内联、类型统一、可异构存。适合需要把不同具体类型当同一种处理的场景如回调容器。高级工程师的选择逻辑先问这些类型需要异构存储/运行时决策吗——需要就擦除不需要就 CRTP/模板。这是把多篇知识06 模板 本篇擦除融会贯通的体现。13. 完整堆退化终版思路把第 3 节的 SBO 版补全为生产级要处理三件事大小分支if constexpr (sizeof(F) BUF alignof(F) alignof(...))走缓冲区否则new F放堆缓冲区存指针in_heap_true。移动语义移动时把源缓冲区内容或堆指针搬过来源置空避免悬空。异常安全缓冲区构造若抛异常不泄漏堆路径用std::unique_ptr或手动try/catch delete。核心仍然是缓冲区 manager 函数指针骨架只是 manager 的 action 枚举多加move且new/delete路径由in_heap_决定。手写一遍就彻底懂了标准库为什么快又稳。14. libstdc std::function 源码结构走读标准库实现GCC libstdc大体是_Function_base持有_M_functor一个联合体小对象直接内联大对象存指针与_M_manager函数指针负责 clone/destroy/move/init。具体 callable 通过_M_init_functor以placement new装入_M_functor的缓冲区SBO。调用时_M_invoker从_M_functor取出 callable 执行——一次间接调用无虚表。当 callable 超过_M_max_size约能容纳含少量捕获的 lambda时退化到堆。读这段代码能验证本篇手写的缓冲区 manager 函数指针模型确实就是工业实现骨架。建议面试前翻一遍能讲清_M_manager三个 action 即超出绝大部分候选人。15. 速查可调用对象全家福对比方案拥有 callable可异构存储可内联分配适用裸函数指针否否否无C 接口、极简回调lambda无捕获是否是无局部一次性回调std::function是是否可能通用存储、回调容器std::move_only_function(C23)是是否可能只移 callable捕获 unique_ptrstd::function_ref(C26/ folly)否是否无临时借用、热路径零开销模板参数F取决于否是无性能极致、类型封闭选型的根本问题只有两个这个 callable 需要被拥有并存储吗它会出现在热路径吗需要存储 → function 系热路径且不需存储 → function_ref 或模板只移不拷 → move_only_function。把这五个选项的差异讲清楚面试官基本认定你真正用过、理解过而非背过 API。17. 临场自检三问类型擦除“std::function 凭什么能装下完全不同的类型”—— 类型擦除用 Concept/Model或 manager 函数指针 缓冲区把具体类型 F 藏在统一接口R(Args...)之后对外只剩一种签名类型。“为什么 function 不可内联、有分配”—— 真实 callable 被间接调用编译期不可见故无法内联小对象走 SBO 免堆大对象堆分配热路径改用 function_ref 或模板。“什么时候不该用 std::function”—— 需要异构存储/运行时多态才用若只是传递一次性回调且该处是热点用 function_ref零开销或模板参数可内联更优避免无谓间接与分配。能答出类型擦除做什么、SBO 省什么、何时该换 ref/模板std::function 这题稳了。动手建议把你手写的Function与标准std::function各写一份基准测试如百万次调用同一 lambda用perf stat对比指令数与缓存命中你会直观看到间接调用无法内联带来的差距也会明白为什么热路径要改用function_ref。更进一步去翻一眼 GCC 源码里libstdc/include/std/functional的_Function_base与_M_manager对照本篇第 14 节的走读把我手写过升级成我读过标准库怎么写。这种从手写到读源码的跨越正是区分中高级与普通候选人的分水岭。场景题延伸用你手写的Function实现一个简易事件总线EventBus支持按事件类型注册多个回调、触发时依次调用。这能把 function 的存储、拷贝、调用串成一次真实使用面试时常作为白板题出现。再进阶一步给Function增加target_type()接口返回std::type_info模拟std::function::target_type用于运行时检查内部 callable 的实际类型——这会逼你真正理解擦除层如何保留类型信息而不只是把类型丢掉了事。最后提醒一个跨篇易错点把 lambda 赋值给 function 时若用了引用捕获[]必须确保其生命周期长于 function否则回调触发时访问的是悬空引用这和第 2 篇智能指针S02的生命周期管理是一脉相承的——类型擦除解决了异构存储却没解决生命周期后者仍要靠 RAII 与引用计数兜底。把这些练熟面试官但凡提到 function、std::bind 或类型擦除你都能现场从手写实现一路讲到标准库源码形成降维打击。另外C23 的std::move_only_function与 C26 的std::function_ref都是本篇手写模型的官方补完——理解手写版后看标准新增特性会非常轻松因为它们只是把你手写的 move-only 分支与 ref 分支标准化了而已。