
做游戏服务端那会儿我第一次在项目里系统性用上类型擦除type erasure技术起因是一套战斗系统。每种技能都有自己的结算逻辑有的走伤害公式有的摇概率有的挂持续 buff。当时代码里塞了一堆switch(type)加void*每加一个新技能就要改三四处地方更头疼的是void*转回来没有任何编译器保护类型写错就是线上事故。后来我把整个技能系统重构成了基于类型擦除的方案才算是把这块烂账理清。类型擦除这名字听着唬人说白了就是把对象的类型标签在你不需要它的地方藏起来让一堆不同类型的东西都能被同一个接口接收、存储和处理等你真需要拿回原始类型时它又能安全地帮你验明正身。你天天用的std::function、std::any底层就是这套思路。如果你在准备 C 面试这更是个高频考点面试官问std::function底层怎么实现的答案就在这篇文章里。适合谁来读从 C 入门半年到两三年、正在接触泛型和设计模式的开发者或者是在重构里遇到异构容器回调注册插件接口这类需求的同学都可以往下看。基础概念我会尽量讲透但默认你至少知道虚函数和模板是啥。1. 类型擦除的核心思路它到底在解决什么问题1.1 三个典型场景异构队列、回调注册、接口抽象先说需求侧你大概率遇到过这么几件事。第一件是异构队列。事件系统、消息系统、任务系统往往同时要装多种类型鼠标事件、键盘事件、网络包、定时器回调。C 的std::vector只能装同一种类型怎么把不同类型的东西塞进一个容器有人立刻想到void*有人想到大 union还有人想到用一个enum Type加switch分发。这些方案都能跑但都谈不上优雅而且类型安全全靠自觉。类型擦除给出的答案是容器里只装壳壳内部各自保管真实对象。第二件是回调注册。UI 按钮要绑定回调、网络库要注册收包函数、线程池要把任务存起来排队。回调的类型千差万别函数指针、lambda、仿函数、std::bind的结果。你总不能给每一种都写一个存储容器所以需要一个能收纳一切可调用对象的统一类型——这正是std::function存在的原因。第三件是接口抽象也叫外部多态。你手上有一堆互不相关的类圆形、图片、文字块它们恰好都有draw()方法但谁也没继承谁也不方便给几十个第三方类逐个加父类。类型擦除可以让你把具有draw()行为的任意类型当作同一类对象来持有和调用却又不强制它们继承你的基类。这条在写插件、渲染层和脚本绑定的时候特别常见。1.2 为什么不用模板、void* 或纯虚函数有读者会问模板不也能解决问题吗模板确实能在编译期统一行为但模板是编译期多态两个不同类型的模板实例在运行期依然互不相通。你想把Shader和SoundClip放进同一个std::vector模板做不到。而且模板实例化多了会导致编译时间和二进制体积膨胀这也是项目大了以后一个非常真实的痛点不少人只盯着运行性能忽略了编译期的成本。void*的问题不用我多说你存进去容易拿出来全靠记忆类型一错就是未定义行为线上排查极其痛苦。union 的问题更明显联合体同一时刻只能活一个成员还得天天盯active_type这个标签字段一多稍微分心就翻车。这两种方案都属于裸奔式做法适合应付 C 接口兼容不适合作为核心架构的骨架。纯虚函数呢它是继承多态要求所有类型都从同一个基类派生。问题在于现实里你经常没法改人家的类——第三方库的类型你总不能都去给人家加一个IRenderable基类吧。类型擦除的精髓恰恰在于行为从外部注入类型从接口上摘除。它对被包装的类型零入侵这是它和继承体系最本质的区别。1.3 类型擦除的本质把类型从接口上摘掉其实擦除这个词是站在使用者视角说的。你对外提供的接口是统一的、不知道具体类型的壳这个壳内部用模板把真实对象包起来再通过一层虚函数把操作分派给真实对象。整个过程中外部完全接触不到那个具体类型 T——就像把身份证收进保险柜走廊上只留下一个写着凭工牌进入的柜台。这样做最大的回报是运行时拿到灵活性的同时调用方依然拿到类型安全。你不需要在某个地方维护一张全局类型注册表因为类型信息被封进了那个壳里面需要时还能通过typeid或者dynamic_cast安全地取回来。理解了这一点后面所有实现细节就都顺了标准库里的组件和你自己手写的版本走的都是同一条路。2. 标准库的成熟方案std::function 与 std::any2.1 std::function可调用对象的统一收纳箱std::function大概是 C11 之后最常用的类型擦除组件。它的接口长这样#include functional #include iostream void free_func(int x) { std::cout free: x \n; } int main() { std::functionvoid(int) f; // 普通函数 f free_func; f(1); // lambda f [](int x) { std::cout lambda: x * 2 \n; }; f(2); // 仿函数 struct Functor { void operator()(int x) const { std::cout functor: x 10 \n; } }; f Functor{}; f(3); return 0; }这里f前后装了三样完全不同的可调用对象但对调用方来说都只是一个能接受 int 并返回 void 的东西。std::function内部做的事情和我后面手写的那个壳本质上一模一样一个虚基类保存 invoke/copy/destroy 操作一个模板派生类把真实可调用对象包起来。几个实用细节。第一调用前最好判空空std::function直接调用会抛std::bad_function_call。第二std::function要求目标可拷贝你要是有移动捕获的 lambda标准做法是把它包进std::shared_ptr再塞进去C23 之后才能直接用std::move_only_functionauto payload std::make_sharedint(42); std::functionvoid() f [payload]() { std::cout *payload \n; };如果你想确认f里装的是不是某种具体类型可以用targetT()if (auto* p f.targetdecltype(free_func)()) { p-operator()(10); // 类型匹配时才会进来 }这在写测试和调试的时候比较有用但别把它当常规 API 到处用用多了就说明你的抽象层设计有问题。2.2 std::any类型安全版的 void*std::any是 C17 加入的它把任意类型塞进同一个容器同时保留运行时的类型信息。最典型的用法是配置系统和参数传递你要透传一堆不知道是什么、也不想知道是什么的数据。#include any #include string #include iostream int main() { std::any box; box 42; box std::string(hello); box 3.14; if (box.type() typeid(double)) { double v std::any_castdouble(box); std::cout double: v \n; } // 类型不对抛 std::bad_any_cast try { int wrong std::any_castint(box); } catch (const std::bad_any_cast e) { std::cout caught: e.what() \n; } // 用指针形态做安全试探 if (auto* p std::any_castdouble(box)) { std::cout found double: *p \n; } return 0; }这里有两个细节我每次都要跟人强调。一是any_castT返回的是引用它指向std::any内部那份对象的引用any一旦被重新赋值或销毁这个引用就成了悬空引用别长期保存它。二是std::any拷贝的时候要求内部对象可拷贝你塞一个只能移动的对象进去再试图拷贝整个any编译期就会直接报错。标准库实现通常也会做小对象优化小的对象直接存在any内部缓冲里不需要堆分配大对象才走堆。不要一上来就断言any 一定比手写慢具体还得看类型大小和拷贝频率。2.3 std::variant 与 std::any 怎么选std::variant也是 C17 加入的但它不是类型擦除而是有限类型集合的联合体。它和std::any的差异一张表就能看明白对比点std::variantstd::any类型集合编译期固定写在模板参数里运行期任意随时可换访问方式std::getT/std::visitany_castT类型错误编译期基本能查出来运行期抛bad_any_cast存储位置栈上直接占用内存堆上或内部小缓冲空状态没有空总有值可能为空我的选择逻辑很朴素只要类型集合是编译期已知的有限几个就优先std::variant——它不触发虚函数和堆分配std::visit还能在编译期保证覆盖全部情况只有当类型真的在运行期不确定比如从配置文件里读进来的参数才上std::any这属于不得不用的兜底方案。用错方向的结果往往就是该在编译期解决的问题被拖到了运行期凭空多了一堆 try-catch 和类型判断。3. 手写一个类型擦除组件从零实现外部多态3.1 设计目标与接口定义从需求反推接口标准库固然好用但很多场景标准库给不了你要的形状。比如我需要一个能放入std::vector的Drawable它要能包装任何带draw() const方法的类型同时支持拷贝并保留各自的类型信息。std::function只能装可调用对象std::any不能直接调用接口方法所以只能自己动手。先定义需求再写代码我的需求清单是能从任意T构造能干净地析构底层对象能调用统一的draw()拷贝包装对象时要有符合直觉的语义深拷贝还是共享语义必须想清楚最好还能查回原始类型。最后一条看起来可有可无但真做序列化、测试断言和调试工具的时候你就知道它能救命。3.2 核心二件套concept_base 与 model第一个版本我用堆分配思路最直白。concept_base是看不见的接口层负责把draw这个操作抽象成虚函数modelT是模板派生类负责把真实对象 T 包起来并实现全部虚函数对外暴露的Drawable则只持有一个指向concept_base的智能指针。#include memory #include utility #include typeinfo class Drawable { public: template typename T Drawable(T obj) : self_(std::make_sharedmodelT(std::move(obj))) {} void draw() const { self_-draw(); } // 类型查询判断当前包装的是不是 T template typename T bool is() const noexcept { return self_-type_info() typeid(T); } private: struct concept_base { virtual ~concept_base() default; virtual void draw() const 0; virtual const std::type_info type_info() const noexcept 0; }; template typename T struct model : concept_base { model(T data) : data_(std::move(data)) {} void draw() const override { data_.draw(); } const std::type_info type_info() const noexcept override { return typeid(T); } T data_; }; std::shared_ptrconst concept_base self_; };这里std::shared_ptrconst concept_base有个细节draw()和type_info()都是 const 虚函数所以用指向 const 的智能指针完全没问题modelT构造时接收的是 T 的值构造完data_就是独立的一份后面可以安全地保存到容器里。shared_ptr默认的拷贝语义是共享底层对象这在很多场合已经够了。但如果你需要拷贝一份 Drawable 就得到一份独立数据的深拷贝语义就得给concept_base加一个 clone 虚函数virtual std::shared_ptrconst concept_base clone() const 0; // modelT 里实现 std::shared_ptrconst concept_base clone() const override { return std::make_sharedmodelT(data_); }然后给Drawable写拷贝构造函数Drawable(const Drawable other) : self_(other.self_ ? other.self_-clone() : nullptr) {}这个取舍没有标准答案但你必须想清楚再动手别让调用方猜你的意图。我的经验是默认用深拷贝更符合值语义的习惯如果测出拷贝确实是性能瓶颈再改成共享语义也不迟。3.3 拷贝、析构与类型恢复最容易翻车的地方类型恢复是我认为最容易被低估的一环。你只会在测试、序列化和调试时用到它但一旦用错整个类型擦除的安全优势就全没了。上面的实现里isT()用的是typeid比较配合访问器可以拿到原始引用template typename T const T get() const { const auto* p dynamic_castconst modelT*(self_.get()); if (p nullptr) { // 类型不匹配时给出明确错误而不是裸奔崩溃 throw std::bad_cast(); } return p-data_; }需要注意dynamic_cast依赖 RTTI如果你编译时为了省体积关了 RTTI-fno-rtti这一套就不能用。如果项目确实要支持关 RTTI 的构建建议在concept_base里显式记录类型标签用typeid返回的type_info比较并且把所有类型判断收敛到访问器里避免到处散落dynamic_cast。多数项目其实不需要考虑这么极端的配置但知道有这条路面试或者写底层库的时候能加分。析构方面shared_ptr帮我们兜底了这是它相比裸指针最省心的地方。但如果你切到下面的小对象优化版本析构就必须自己盯紧这也是手写擦除类最容易出 bug 的环节。3.4 进阶小对象优化SBO与性能细节堆分配不是免费的。如果你的Drawable要以很高的频率创建和销毁每次都make_shared就会产生不小的开销。标准库std::function、std::any内部都做了小对象优化把比较小的对象直接塞进对象本体里只有大的才走堆。手写版也可以照搬这个思路。做法是给Drawable预留一块定长的缓冲区构造时判断 T 的大小和对齐是否放得下放得下就用 placement new 把modelT构建在缓冲区里放不下才 new 到堆上class Drawable { static constexpr size_t kBufferSize 64; alignas(std::max_align_t) std::byte buffer_[kBufferSize]; concept_base* self_ nullptr; // ... };关键点有三个一是缓冲区对齐必须满足alignof(std::max_align_t)否则遇到对齐要求高的类型会炸二是析构时必须通过虚析构函数统一调度否则缓冲区里的对象不会被正确析构三是必须手写移动构造和移动赋值因为把一个 SBO 对象搬走后源对象的缓冲区状态必须清空否则就会双析构。这块代码要写对其实挺考验功底。我的经验是先用最朴素的 shared_ptr 版本把功能跑通确认接口和需求没问题之后再用性能分析的数据决定要不要上 SBO不要一开始就优化SBO 的 bug 往往藏得很深而且功能完全一样可读性还更差。4. 类型擦除实战中的踩坑记录与排查技巧4.1 动态派发成本虚函数一旦藏起来内联就没了类型擦除内部的实现依赖虚函数这意味着每次调用draw()都至少有一次间接跳转。编译器没法在编译期看到实际类型所以draw()不可能内联对性能敏感的热循环影响是实打实的。我在一个物理引擎的调试工具里测过每帧要把几百个包围盒画出来每个包围盒都包装成一个Drawable再逐个调用。换成模板直接访问后帧耗时降了大概 18%。不是说类型擦除不能用而是说它适合放在调用不频繁但接口要稳定的地方比如 UI 事件、命令分发、插件边界不适合放在每帧几百万次的内层循环。做技术选型时先问自己一句这个调用在热路径上吗另外把类型包装进std::function或std::any后对象的体积会变大拷贝频率高的话内存带宽也会成为瓶颈。实测下来能传 const 引用就传 const 引用尽量不要复制一个大体积的擦除对象。4.2 拷贝语义与对象切片两个亲兄弟式的坑先说过度拷贝。如果你的包装对象用的是shared_ptr共享语义那么复制包装对象并不会复制底层数据赋值后两个包装对象指向同一份数据。这在某些场景是特性但在另一些场景就是 bug——最典型的是把包装对象放进std::vector后后续修改一个另一个也跟着变。要深拷贝就得像我前面那样补一个 clone 虚函数别偷懒。再说对象切片。这是继承多态里最容易踩的坑在类型擦除的包装场景里也常见如果你把modelT对象按值赋给concept_baseT 的部分就会被拦腰砍掉虚函数表指针也会跟着错乱。规矩就是通过基类访问派生类的时候永远用指针或引用绝不用值。我用shared_ptr而不是裸concept_base成员部分原因就是为了从语法层面把这条路封死。4.3 any_cast 的类型安全边界const 和引用别搞混std::any_cast的规则比很多人想的细any_castT(box)返回的是 T 的拷贝any_castT(box)返回引用any_castT*(box)返回指针——而且 T 的 const 修饰必须和 any 内部的类型一致否则判不中。我见过不止一次这样的 bug内部存的是const std::string外面用any_caststd::string去取结果抛异常。排查方法是在any_cast之前先用box.type() typeid(T)打印确认多数时候一看就知道是 const 修饰不匹配。还有any_castT*在类型不匹配时返回空指针而不是抛异常这个特性适合用在试探一下有没有这个类型的场景if (auto* p std::any_castT(box)) { // 只有类型匹配才走到这里 }4.4 常见问题速查表排错照着这张表查就行现象可能原因排查/解决思路空 std::function 调用崩溃忘记判空调用前if (f)或 catchstd::bad_function_callstd::function 装不上移动捕获 lambdaC23 前要求目标可拷贝lambda 里包std::shared_ptr或用std::move_only_functionany_cast 抛 bad_any_cast类型不一致或 const 修饰不匹配先用box.type() typeid(T)确认any_cast 拿到引用后悬空any 被重新赋值或销毁不要把any_castT的结果长期保存手写包装类拷贝后共享数据shared_ptr 共享语义补 clone 虚函数做深拷贝小对象优化后程序崩溃/双析构移动构造没写、缓冲区没清空检查移动构造/移动赋值源对象置空-fno-rtti下 dynamic_cast 报错RTTI 被关闭改用 type_info 记录 typeid 比较热路径上性能下降虚函数不可内联移到非热路径或改用模板/静态分发这张表我贴在自己项目的 README 里有一阵子了基本覆盖了团队新人最高频的几个问题。排错顺序我建议先从类型对不对入手再看生命周期最后才怀疑性能——前两个是确定性 bug性能问题反而好测。5. 关于类型擦除工程取舍的一些个人经验5.1 什么场景真的值得引入类型擦除用不用类型擦除我的判断标准一直很朴素如果类型集合在编译期有限且已知优先用模板和std::variant别为了抽象而抽象只有当类型集合在运行期开放或者需要把互不相关的类型统一收纳时才值得为类型擦除付出运行时开销。事件系统、命令系统、插件注册表这些类型集合天然开放的场景类型擦除几乎是唯一务实的选择。我在上一份工作里维护过一个插件化的渲染器每个插件都会注册自己的一堆自定义资源类型主程序根本不可能提前知道有哪些类型这时候std::any和自制的擦除资源句柄就是主力。但我也见过过度设计的反面教材业务模块内部的两个类明明可以用普通虚函数或者模板解决偏要套一层擦除壳结果就是代码跳来跳去、性能下降、调试变难。太早引入类型擦除的代价就是接口多了一层抽象新人看代码要多跳一层调试时也要多翻一个虚函数调用链。所以我的习惯是先让业务跑起来等出现了类型集合要开放的硬需求再把这个抽象层加进去。5.2 和模板、虚函数混用的组合拳类型擦除不是银弹它和模板、虚函数其实是互补关系。我常用的组合姿势是外层接口用类型擦除来稳住统一入口内层实现继续用模板发挥零开销优势组件边界处用虚函数做稳定 ABI。我自己的渲染系统就是这么拆的对外只暴露Drawable这样一个擦除类型调用方根本不关心具体绘制对象到底是什么内部每种图元仍然是一个普通类模板把它们包装进擦除层跨模块的渲染上下文接口则保持纯虚函数保证二进制兼容。这样的好处是每一层都在做自己最擅长的事擦除层管统一模板层管性能虚函数层管兼容。如果项目里手写的类型擦除类不止一个你会发现它们结构高度雷同一个 concept 基类、一个 model 模板、若干接口方法。维护多个这样的类时务必保持命名和文件布局一致。我在团队里定过一个潜规则concept 基类就叫xxx_concept模板实现就叫xxx_model放在同一个头文件里注释里写清楚这是类型擦除的标准结构新功能照着这个模式加。一个结构清晰的擦除类比把抽象藏在宏和黑魔法里要省钱得多——踩过几次坑之后你会明白这块代码最缺的不是高级而是直白。这套模式我用了好几年至今仍然觉得它是我在 C 里最值得的投资之一。