做游戏服务端那会儿,我第一次在项目里系统性用上类型擦除(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::any
2.1 std::function:可调用对象的统一收纳箱
std::function大概是 C++11 之后最常用的类型擦除组件。它的接口长这样:
#include <functional> #include <iostream> void free_func(int x) { std::cout << "free: " << x << '\n'; } int main() { std::function<void(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再塞进去,C++23 之后才能直接用std::move_only_function:
auto payload = std::make_shared<int>(42); std::function<void()> f = [payload]() { std::cout << *payload << '\n'; };如果你想确认f里装的是不是某种具体类型,可以用target<T>():
if (auto* p = f.target<decltype(free_func)>()) { p->operator()(10); // 类型匹配时才会进来 }这在写测试和调试的时候比较有用,但别把它当常规 API 到处用,用多了就说明你的抽象层设计有问题。
2.2 std::any:类型安全版的 void*
std::any是 C++17 加入的,它把任意类型塞进同一个容器,同时保留运行时的类型信息。最典型的用法是配置系统和参数传递:你要透传一堆"不知道是什么、也不想知道是什么"的数据。
#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_cast<double>(box); std::cout << "double: " << v << '\n'; } // 类型不对,抛 std::bad_any_cast try { int wrong = std::any_cast<int>(box); } catch (const std::bad_any_cast& e) { std::cout << "caught: " << e.what() << '\n'; } // 用指针形态做"安全试探" if (auto* p = std::any_cast<double>(&box)) { std::cout << "found double: " << *p << '\n'; } return 0; }这里有两个细节我每次都要跟人强调。一是any_cast<T&>返回的是引用,它指向std::any内部那份对象的引用,any一旦被重新赋值或销毁,这个引用就成了悬空引用,别长期保存它。二是std::any拷贝的时候要求内部对象可拷贝,你塞一个只能移动的对象进去,再试图拷贝整个any,编译期就会直接报错。
标准库实现通常也会做小对象优化,小的对象直接存在any内部缓冲里,不需要堆分配;大对象才走堆。不要一上来就断言"any 一定比手写慢",具体还得看类型大小和拷贝频率。
2.3 std::variant 与 std::any 怎么选
std::variant也是 C++17 加入的,但它不是类型擦除,而是"有限类型集合的联合体"。它和std::any的差异一张表就能看明白:
| 对比点 | std::variant | std::any |
|---|---|---|
| 类型集合 | 编译期固定,写在模板参数里 | 运行期任意,随时可换 |
| 访问方式 | std::get<T>/std::visit | any_cast<T> |
| 类型错误 | 编译期基本能查出来 | 运行期抛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这个操作抽象成虚函数;model<T>是模板派生类,负责把真实对象 T 包起来并实现全部虚函数;对外暴露的Drawable则只持有一个指向concept_base的智能指针。
#include <memory> #include <utility> #include <typeinfo> class Drawable { public: template <typename T> Drawable(T obj) : self_(std::make_shared<model<T>>(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_ptr<const concept_base> self_; };这里std::shared_ptr<const concept_base>有个细节:draw()和type_info()都是 const 虚函数,所以用指向 const 的智能指针完全没问题;model<T>构造时接收的是 T 的值,构造完data_就是独立的一份,后面可以安全地保存到容器里。
shared_ptr默认的拷贝语义是共享底层对象,这在很多场合已经够了。但如果你需要"拷贝一份 Drawable 就得到一份独立数据"的深拷贝语义,就得给concept_base加一个 clone 虚函数:
virtual std::shared_ptr<const concept_base> clone() const = 0; // model<T> 里实现 std::shared_ptr<const concept_base> clone() const override { return std::make_shared<model<T>>(data_); }然后给Drawable写拷贝构造函数:
Drawable(const Drawable& other) : self_(other.self_ ? other.self_->clone() : nullptr) {}这个取舍没有标准答案,但你必须想清楚再动手,别让调用方猜你的意图。我的经验是:默认用深拷贝更符合值语义的习惯,如果测出拷贝确实是性能瓶颈,再改成共享语义也不迟。
3.3 拷贝、析构与类型恢复:最容易翻车的地方
类型恢复是我认为最容易被低估的一环。你只会在测试、序列化和调试时用到它,但一旦用错,整个类型擦除的安全优势就全没了。上面的实现里is<T>()用的是typeid比较,配合访问器可以拿到原始引用:
template <typename T> const T& get() const { const auto* p = dynamic_cast<const model<T>*>(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 把model<T>构建在缓冲区里,放不下才 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 虚函数,别偷懒。
再说对象切片。这是继承多态里最容易踩的坑,在类型擦除的包装场景里也常见:如果你把model<T>对象按值赋给concept_base,T 的部分就会被拦腰砍掉,虚函数表指针也会跟着错乱。规矩就是:通过基类访问派生类的时候,永远用指针或引用,绝不用值。我用shared_ptr而不是裸concept_base成员,部分原因就是为了从语法层面把这条路封死。
4.3 any_cast 的类型安全边界:const 和引用别搞混
std::any_cast的规则比很多人想的细:any_cast<T>(box)返回的是 T 的拷贝,any_cast<T&>(box)返回引用,any_cast<T*>(&box)返回指针——而且 T 的 const 修饰必须和 any 内部的类型一致,否则判不中。
我见过不止一次这样的 bug:内部存的是const std::string,外面用any_cast<std::string&>去取,结果抛异常。排查方法是在any_cast之前先用box.type() == typeid(T)打印确认,多数时候一看就知道是 const 修饰不匹配。还有,any_cast<T*>在类型不匹配时返回空指针而不是抛异常,这个特性适合用在"试探一下有没有这个类型"的场景:
if (auto* p = std::any_cast<T>(&box)) { // 只有类型匹配才走到这里 }4.4 常见问题速查表:排错照着这张表查就行
| 现象 | 可能原因 | 排查/解决思路 |
|---|---|---|
| 空 std::function 调用崩溃 | 忘记判空 | 调用前if (f),或 catchstd::bad_function_call |
| std::function 装不上移动捕获 lambda | C++23 前要求目标可拷贝 | lambda 里包std::shared_ptr,或用std::move_only_function |
| any_cast 抛 bad_any_cast | 类型不一致或 const 修饰不匹配 | 先用box.type() == typeid(T)确认 |
| any_cast 拿到引用后悬空 | any 被重新赋值或销毁 | 不要把any_cast<T&>的结果长期保存 |
| 手写包装类拷贝后共享数据 | 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++ 里最值得的"投资"之一。