
写C的兄弟应该都遇到过这种纠结想写一个函数既能接收int、double又能接收自定义对象甚至还能接收一个lambda但又要让调用方不用关心具体类型。std::function和std::any你肯定摸过它们背后的核心思想就是类型擦除Type Erasure。说白了类型擦除就是“隐藏具体类型只暴露统一的接口”让运行时能操作任意类型同时又保留编译期的类型安全。这篇文章我会把它掰开揉碎讲清楚类型擦除到底是什么、底层是怎么设计的、怎么手写一个出来以及在真实项目里怎么用、有哪些坑要躲。无论你是刚开始学C基础还是准备C面试或者正在写框架和中间件这篇都能让你少走弯路。1. 类型擦除的本质与设计思路1.1 模板与运行期多态的矛盾聊类型擦除之前得先看清楚它解决的是什么矛盾。C有两大法宝模板编译期多态和虚函数运行期多态。模板可以让你写出templatetypename T void foo(T t)这样通用性极强的代码所有类型检查都在编译期完成性能也近乎零成本。但问题在于模板必须在编译期实例化也就是说在运行期你没法把一堆不同类型的东西塞进同一个容器里也没法从某个配置表里按名字去动态选一个函数出来调用。虚函数正好反过来。你定义一个基类接口派生出不同的子类运行时通过基类指针或引用去调用虚函数具体类型可以完全隐藏。但虚函数的局限也很明显它只能处理“从同一个基类派生出来”的类型。如果你手里有一个int、一个自定义的Foo类、一个double (*)(int)函数指针它们之间没有共同基类虚函数就束手无策了。模板是“编译期什么都定死”虚函数是“运行期类型必须收敛到某个基类”两者之间的鸿沟就是类型擦除要填的地方。类型擦除的思路很巧妙用模板来生成一个适配器再用虚函数来统一这个适配器的接口。也就是说外部看起来是一个固定类型比如std::function内部通过模板实例化去适配任何具体类型再用虚表把不同具体类型的操作兜住。这样既享受模板的“任意类型”又能享受虚函数的“运行时统一”代价是一点虚函数调用和可能的堆分配。我见过很多C基础教程直接跳过这段导致初学者理解std::function时总觉得它像魔法。其实它就是“模板生成适配器 虚函数多态”的组合拳后面我会手写一遍你会发现它离你并不远。1.2 核心三件套接口基类、模板适配器、存储要自己搞一个类型擦除容器一定会用到三个组成部分接口基类一个非模板的抽象基类里面定义纯虚函数比如invoke()、clone()、destroy()这些虚函数是“统一操作”的入口。模板适配器一个模板派生类继承接口基类内部存一个具体类型的对象并实现基类的纯虚函数。模板参数就是那个被“擦掉”的具体类型。存储策略适配器对象本身要怎么存可以直接作为成员对象是小对象时很划算也可以指向堆上的指针对象很大时就只能堆分配。简单来说类型擦除就是“用一个模板派生类把具体类型包起来然后通过基类指针来看待它”。比如一个最简单的Any类内部结构大致是这样非模板基类struct Base { virtual ~Base() {}; virtual void* data() 0; }; 模板适配器templatetypename T struct Holder : Base { T value; void* data() override { return value; } };为什么需要clone()因为当你拷贝这个容器时你只知道基类指针不知道具体类型如果不能克隆拷贝就会变成指针的浅拷贝导致两个对象共享同一份数据析构时给你来个 double free。为什么需要虚析构因为你要通过基类指针删除派生类对象不虚析构派生类的资源就释放不干净。存储策略也是很关键的细节。如果能保证放入的对象足够小比如小于16字节我们可以直接在某处留下一块std::aligned_storage缓冲区把对象原位构造进去避免堆分配对象太大就只能乖乖走new。这也是std::function和std::any实现里常见的“小对象优化”后面我专门讲。1.3 标准库与框架中的典型应用先看看标准库里已经有哪些类型擦除的现成品这样你对它的应用场景会更有感觉std::function最典型的例子。它可以把普通函数指针、函数对象、lambda、std::bind结果统统擦除掉统一成“可调用对象”然后像普通函数一样调用。注册回调、线程函数、事件处理器到处都有它。std::any可以装载任意类型的值而且支持查询和取出。它的实现基本上就是“类型擦除 类型信息”的组合。在需要实现异构容器、动态参数表、消息体的时候很好用。std::function_refC26提案轻量版可调用对象不做所有权管理但同样做了类型擦除。各种框架里的“事件总线”游戏引擎里经常注册void()回调但不同的玩家不关心回调原本的类型UI库里按钮点击信号的处理器也经常擦除掉具体函数对象类型统一存进一个表里。你会发现这些应用有一个共同点系统需要以“统一的方式”持有并调用“一堆不同类型的东西”又不想引入一个公共基类。类型擦除完美地解决了这个问题。2. 核心细节从零手写一个类型擦除类2.1 最小可用的Any接口与适配器手写一个支持拷贝、取值、判断空值的类型擦除类是最好的学习方式。我给一个简化但完整的实现#include memory #include typeinfo #include stdexcept class Any { public: templatetypename T Any(T value) { using Decayed std::decay_tT; ptr_ std::make_uniqueHolderDecayed(std::forwardT(value)); } Any() default; Any(const Any other) { if (other.ptr_) { ptr_ other.ptr_-clone(); } } Any operator(const Any other) { if (this ! other) { Any tmp(other); std::swap(ptr_, tmp.ptr_); } return *this; } Any(Any other) noexcept default; Any operator(Any other) noexcept default; templatetypename T T get() const { using Decayed std::decay_tT; if (!ptr_ || typeid(Decayed) ! ptr_-type()) { throw std::bad_cast(); } return *static_castHolderDecayed*(ptr_.get())-value; } bool has_value() const noexcept { return ptr_ ! nullptr; } void reset() noexcept { ptr_.reset(); } private: struct Base { virtual ~Base() default; virtual std::unique_ptrBase clone() const 0; virtual const std::type_info type() const noexcept 0; }; templatetypename T struct Holder : Base { explicit Holder(T val) : value(std::forwardT(val)) {} std::unique_ptrBase clone() const override { return std::make_uniqueHolder(value); } const std::type_info type() const noexcept override { return typeid(T); } T value; }; std::unique_ptrBase ptr_; };看着好像简单但里面有几个关键点。第一模板构造函数用了std::decay_tT否则你传入const char*时T可能被推导成const char[6]类型就跑了。第二拷贝构造函数调用了ptr_-clone()用基类指针多态地复制出正确类型的适配器。第三get()里用typeid做运行时类型比对不一致就抛异常。这就是std::any的核心逻辑一个适配器 虚函数统一操作 RTTI 做类型恢复。也有注意点Any(Any other) noexcept default看起来没问题但如果你希望std::vectorAny高效扩容这样已经够用了。如果后续想加入小对象优化unique_ptrBase这种写法就得换掉后面讲。2.2 拷贝、移动与空值安全类型擦除对象最常用的陷阱就是拷贝和移动行为不一致。上面实现里拷贝是深拷贝移动是浅拷贝这很合理移动就是转移所有权没什么好复制的。但如果你不小心把移动写成拷贝或者干脆没写移动构造函数那std::vectorAny扩容时就会发生深拷贝性能血崩。空值安全也需要专门考虑。默认构造的Any是空对象has_value()要能正确识别reset()要把ptr_重置释放资源。这两点都必须处理否则get()会解引用空指针直接崩溃。赋值运算符我用了经典的 copy-and-swap 写法先拷贝构造tmp再交换ptr_。这样能保证异常安全如果拷贝过程抛异常原有对象不会被改动异常发生在拷贝阶段异常安全被保护了。这一点平时写C可能不太注意但放到框架里就是系统稳定性的关键。如果你想做一个更稳的Any还应提供emplaceT(args...)。它可以直接在内部原位构造T省一次移动构造。配合前面的get()这个Any已经能应付绝大多数核心场景了。2.3 小对象优化SBO前面这个Any实现里每个对象都走make_unique堆分配一次。如果调用频繁性能会很难看。所以成熟的标准库实现会加入小对象优化Small Buffer Optimization。思路其实不复杂在Any对象内部预留一块足够大的对齐缓冲区比如std::aligned_storage_t16, alignof(std::max_align_t)。如果传入的对象大小和内存对齐都落在缓冲区能装下的范围内就直接在缓冲区内存位构造HolderT然后用一个标志位记录当前用的是静态缓冲区还是堆指针。对象超过缓冲区时就退回堆分配。这样带来的收益是大量小回调对象比如捕获了几个参数的小lambda可以零堆分配拍着胸脯跟性能主管说没事。代价是实现复杂度上了一个台阶你得手动管理HolderT的生命周期析构时要根据标志位判断该调用~Base还是直接释放堆指针拷贝时也要根据源对象的状态决定是放缓冲区还是堆分配。如果你不想自己手写这个也可以看看libstdc的std::function实现它就是典型的SBO思路内部保存一个_M_manager函数指针和_M_invoker函数指针管理策略和调用策略都在函数指针里整流对象数据要么在本地缓冲要么在堆上。理解了这个你再看标准库源码都能顺畅不少。下面是一个简陋但能跑的小对象优化骨架示意class SmallAny { public: templatetypename T, size_t S sizeof(HolderT) void emplace(T val) { if constexpr (S InlineSize) { // 不严谨但思路是在缓冲区里构造 ::new (storage_) HolderT(std::forwardT(val)); heap_ false; } else { storage_holder_ std::make_uniqueHolderT(std::forwardT(val)); heap_ true; } } private: static constexpr size_t InlineSize 16; std::aligned_storage_tInlineSize, alignof(std::max_align_t) storage_; void* storage_holder_ nullptr; bool heap_ false; // ... };切忌直接照抄因为这个细节非常多析构要手动调用~HolderT()移动时如果源是小对象需要原位搬移注意对象里可能有自引用拷贝时如果源是小对象也需要原位拷贝。我自己在实现过程中就因为这些生命周期细节踩过好几个crash后面专门提醒。2.4 让Any支持typeid查询标准库std::any的type()方法返回一个const std::type_info方便你判断里面装的是什么类型。我们的实现里已经在Base上定义了virtual const std::type_info type() const noexcept在Holder里返回typeid(T)所以只要给Any加上const std::type_info type() const noexcept { return ptr_ ? ptr_-type() : typeid(void); }这样任何拿到Any的开发者都可以先if (a.type() typeid(Foo))再a.getFoo()而不是盲目get()然后抛异常。这是非常友好的API设计也更能让同事一眼看懂你的意图。类型查询正是类型擦除的另一半擦除的是类型访问的“静态路径”但通过RTTI还保留了“动态查询”的通道。诚然过度使用RTTI会有开销和体积上的问题但合理用在一个极简的Any上是完全没问题的。3. 实战类型擦除在真实项目里的正确姿势3.1 封装一套通用回调管理器很多时候你手上有一堆回调要注册但来源五花八门某个类成员函数、一个函数指针、一个捕获了一堆变量的lambda。你希望把它们统一存储在一个容器里彼此互不干扰而且都能按同样方式调用。这正是类型擦除的主场。我写过一个小型事件管理器回调登记处近似这样class CallbackRegistry { public: templatetypename Callable void register_callback(Callable cb) { using Decayed std::decay_tCallable; auto wrapper new CallbackHolderDecayed(std::forwardCallable(cb)); callbacks_.push_back(std::shared_ptrCallbackBase(wrapper)); } void run_all() { for (auto cb : callbacks_) cb-invoke(); } private: struct CallbackBase { virtual ~CallbackBase() default; virtual void invoke() 0; }; templatetypename T struct CallbackHolder : CallbackBase { explicit CallbackHolder(T cb) : cb_(std::forwardT(cb)) {} void invoke() override { cb_(); } T cb_; }; std::vectorstd::shared_ptrCallbackBase callbacks_; };看到没有和Any的套路一模一样一个非模板基类定义统一接口模板派生类持有具体可调用对象并实现invoke()。这就是类型擦除的普遍形态并不复杂。用的时候你可以传CallbackRegistry registry; registry.register_callback([]() { std::cout lambda\n; }); registry.register_callback(someFunction); registry.register_callback([obj std::make_sharedFoo()] { obj-do_something(); }); registry.run_all();如果只是要参数类型不固定但都能返回同一个结果还可以给invoke加上参数包比如virtual int call(int) 0这样类型擦除就不再只是“模板替换”而是真正变成了一套可扩展的抽象。3.2 用Any搭建异构消息体再举一个实际能落地的场景客户端和服务器通信或者前后端解耦经常需要传输不同类型的“消息体”。比如登录消息一坨LoginRequest点击消息一个ClickEvent心跳消息一个整数。如果为每一种消息都定义一个派生类会很累如果用一个struct Variant联合体类型又受限。此时可以用std::any或自定义Any作为消息体的统一载体。整个系统的事件结构可以设计成struct Message { int message_id; std::any body; };发布者构造消息时直接Message m; m.message_id 1001; m.body LoginRequest{admin, 123456};订阅者收到后根据message_id先判断类型再std::any_castLoginRequest取出数据。这样新加消息类型时不需要改动底层的消息队列和路由代码只需要在收发两个地方处理好类型转换即可。这比塞进一个巨大的if-else联合体贴合实际多了。当然也有代价Any本身可能发生堆分配消息队列里高频拷贝时要注意性能。很多游戏服务器宁可把消息体做成一等公民的定长缓冲区也不愿意每个包都来一次类型擦除堆分配。所以做技术选型时类型擦除可以极大提升开发灵活度但要提前想清楚热点路径上的代价。3.3 性能对比与选型建议很多人在项目里纠结到底用std::function、std::any、还是手写虚函数接口我给一个比较实际的对照表方案类型安全运行时开销灵活性适用场景直接模板编译期完全确定最低静态绑定超高频调用、编译期算法虚函数接口继承体系内类型安全虚表间接调用子类无限扩展固定接口、状态机std::function参数签名一致即可1次虚调用可能堆分配任意可调用对象回调、事件分发std::any取回时要显式转换1次虚调用可能堆分配任意值类型异构容器、动态参数手写Any同上可控可加SBO自定义行为框架层、性能敏感选择依据其实很简单如果你只需要“静静地持有某个类型”用std::any如果你需要“持有并且还能统一调用”用std::function如果觉得标准库API不满足性能要求自己手写一个带SBO的类型擦除类就好比手枪不行换步枪但别自己造原子弹。我实测过一个场景在事件分发器里用std::function存了2万个 lambda回调每帧调用一轮大约比直接用函数指针虚表慢15%但相比开发效率和可维护性提升这个开销在接受范围内。如果你遇到性能瓶颈优先看是不是回调本身做了多余拷贝和锁而不是一上来就否掉类型擦除。3.4 在游戏或UI框架中的实际场景说个更接地气的例子很多C小游戏或UI框架里需要监听按钮点击、键盘事件、动画完成。传统做法是定义一坨ButtonListener、KeyboardHandler、AnimationCallback接口然后一个类实现多个接口代码非常繁琐。用类型擦除之后你只需要一个统一的EventHandlerstd::functionvoid(const Event)然后到处注册lambda。朋友圈里经常有人写C小游戏我见过一个坦克大战demo它的键盘输入回调就是input_system.registKeyHandler(A, [](velocity v) { v - 1; }); input_system.registKeyHandler(D, [](velocity v) { v 1; });这里的registKeyHandler内部其实就用了std::functionvoid(param)做类型擦除把“按键A与具体逻辑”解耦开。你用起来很爽底层代码也很干净。可以说类型擦除让“聚合回调”的代码从设计模式八股变成了日常语法不用再写一坨类。4. 常见问题与排查技巧实录4.1 虚函数开销与优化方向类型擦除的底层是虚函数调用而虚函数调用在C里算不上零成本。它要求查虚表、间接跳转分支预测失败率比直接调用高编译器也没法内联。如果你在一个每秒执行百万次的热点路径里布满类型擦除性能确实会给你教训。优化方向大致有几个减小调用次数把多次类型擦除操作合并成一次比如在循环外先取到类型擦除对象循环内直接调用而不是循环内反复走类型分发。避免堆分配使用带有SBO的实现减少new/delete次数。很多标准库std::function对小函数对象会走本地缓冲但你要确保用的时候不要引入超大的捕获体否则还是会堆分配。优先用模板如果类型在编译期就能定死只是运行时不能枚举那就别硬上类型擦除直接用模板更香。手动去虚拟化如果只有有限的几个类型可以用if constexpr 整数索引模拟有限类型表避开虚表。我这里还真踩过一次坑给一个高频回调系统用std::functionvoid()做定时器任务当时觉得方便结果百万级定时器一帧回调多加了10%的CPU。后面把其中一部分改成函数指针表明显缓解。所以类型擦除是“没有银弹”它帮你擦掉类型也擦掉了性能直觉。4.2 引用、const、移动语义的陷阱类型擦除对象里藏的是什么通常是实际对象的“值”。如果你传入一个引用类型参数比如std::any a 42;赋值时的42是个右值会被拷贝或移动到内部。但如果你传了一个“引用包装器”比如std::reference_wrapperT类型擦除对象里存的就是引用本身本质是指针。用的时候要格外小心int x 100; std::any a std::ref(x); std::any_caststd::reference_wrapperint(a).get() 200; // 正确但如果你直接用std::any a x;那么 a 内部是拷贝后的独立整数修改a不能影响x。很多人会误以为传引用就能改原值其实是把拷贝和引用搞混了。移动语义同样是个坑。类型擦除对象移动之后内部的具体对象移动是调用移动构造还是拷贝构造取决于HolderT里T的移动操作是否被正确实现。如果T本身是 const 成员那移动会被退化为拷贝。在你实现自定义类型擦除时HolderT的拷贝构造一概用T的拷贝移动构造要用T的移动否则就容易出现“移动过来结果还是深拷贝一大坨”的尴尬。4.3 调试技巧如何看清擦除后的真实类型类型擦除后调试器默认只会看到非模板基类你看不清里面到底是哪个类型。我分享几个排查技巧查看type()返回的type_info。用gdb时可以直接p a.type().name()打印出来的可能是4Foo这种被mangled的名字你需要用cfilt工具转换。这个最快。在HolderT里加一个静态调试方法比如static const char* tag() { return typeid(T).name(); }然后基类加一个纯虚函数返回字符串调试时直接看它。如果是std::function可以打印能调用的函数对象的target_type()和target()。这东西比Any更利于调试因为target_type()会告诉你原来存的确是哪个函数指针或类类型。给gdb注册pretty-printer。我自己写过一个针对自定义Any的pretty-printer能快速看到Any(holderHolderT, T... height...)排查事件队列问题特别舒服。4.4 面试高频题与标准答案类型擦除是C面试题的常客网上很多“C八股”都会反复出现。我给你整理几个高频问题顺便附上我的回答思路std::function是怎么实现的 答基于类型擦除。它内部有一个非模板基类定义call(Args...)和manager等虚函数模板派生类持有具体可调用对象并实现这些接口。构造时统一收敛为单一类型调用时通过虚函数分派到内部对象。std::any和void*比有什么优势 答void*丢弃了类型信息使用时必须靠程序员的纪律去维护any内部保存了type_info能进行运行时的类型校验发生不匹配会抛出bad_any_cast更安全。类型擦除会导致哪些运行时开销 答主要是虚函数调用带来的间接跳转、可能引起的分支预测失败以及某些实现里的堆分配。通过小对象优化可以减少堆分配但没有办法完全避免虚函数开销。如何实现一个自己版本的 Any 答接口基类 模板派生类 克隆/销毁/类型查询虚函数。拷贝要深拷贝移动要转移指针或对象默认构造为空实现get里用typeid比对并抛异常。如果要求性能再加小对象优化。类型擦除和模板有什么区别 答模板是编译期多态类型在编译期确定代码可以被内联但没有运行时类型切换能力类型擦除是运行期多态把类型藏进适配器通过虚表在运行时调用牺牲一部分性能换取了运行时的灵活性。这五个问题基本能覆盖一轮面试里关于类型擦除的必杀技了。你只要把这篇文章里手写Any的过程过一遍面试时讲原理和代码都游刃有余。个人在实际项目里体会最深的一点是类型擦除不是银弹它最适合的场景是“模块边界需要统一接口但业务层希望能自由注册任意类型”。如果你只是想在一个局部函数里处理几种类型老老实实写模板就行。如果一定要在接口上擦类型请顺手把type()、clone()、SBO 这些细节都做完整不然迟早会线上崩一跤。最后再分享一个小技巧自定义类型擦除时至少提供emplace、get和type三个接口再严格定义拷贝和移动语义。把这三件套想清楚你的类型擦除组件就能扛得住绝大多数复杂项目的考验了。