C++开发者几乎都会在某个阶段遇到同一个困惑:模板和虚函数都叫“多态”,但一个活在编译期,一个活在运行期。当你想把 std::vector<std::unique_ptr<IDrawable>> 换成 std::vector<std::function<void()>> 时,其实已经在碰类型擦除(Type Erasure)了。我在写图形引擎的渲染队列时,被迫把几十种差异极大的绘制对象统一塞进一个容器,才真正意识到这东西的威力有多大。它能让模板的“任意类型兼容”和虚函数的“运行期统一接口”同时生效,代价是藏在编译器背后的一整套精巧设计。这篇文章想把我踩过的坑、看过的源码、手写过的实现一次性讲透,适合已经用过 std::function 但对内部机制存疑的中级开发者,也适合准备在项目里做抽象层但不知道该用模板还是虚函数的架构设计者。
1. 模板与虚函数之间:类型擦除到底解决什么问题
1.1 两种多态各有各的死穴
模板多态的“死穴”在于类型必须在编译期确定。比如我写了一个模板函数:
cpp复制template <typename T>
void draw(const T& obj) {
obj.render();
}
只要调用点能看到完整的类型信息,这个函数就能正常工作。可一旦类型信息被隐藏起来——比如你想在运行时根据配置加载不同的绘制算法,或者想把一堆行为各异但接口统一的对象放进同一个容器,模板就无能为力了。我早期写过一套资源管理器,每种资源加载逻辑不同,想统一登记到一个 std::map<std::string, ???> 里,就是卡在这个“问号”上。
虚函数多态解决了运行期的类型统一问题:
cpp复制struct IDrawable {
virtual ~IDrawable() = default;
virtual void render() const = 0;
};
但它的死穴同样明显:所有使用者必须继承同一个接口。你没法让一个第三方库里的 ImageData、一个自己写的 SceneNode、一个从 C 接口拿到的 NativeHandle 都去继承 IDrawable,除非你为每个类型各写一个适配器包装类。写多了之后,代码里全是机械重复的胶水层。
1.2 类型擦除是对两种多态的正面回应
类型擦除的核心思路一句话就能说清:把“具体类型”这个信息在边界处抹掉,但把“行为”保留下来。它由三部分组成:
- 一个非虚的外部模板接口,负责接收任意具体类型;
- 一个内部虚基类,定义统一的行为签名;
- 一个模板派生类,把外部收到的具体类型“粘”到虚函数实现上。
这样外部看起来是普通的模板调用,内部会生成一张虚函数表,运行期通过虚表完成行为分发。等于同时保住了模板的“宽进”和虚函数的“统一出”。这是理解后面所有标准库实现的基础,也是很多面试题爱考的隐藏考点。
提示:判断一个类型擦除设计是否合格,就看它对外暴露的接口里有没有出现模板参数。如果用户永远不用在调用处写
<T>,说明擦除做得干净;反之只能算是半擦除。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 标准库里的三个类型擦除范本:function、any、variant的底层逻辑
2.1 std::function:用虚函数表收编一切可调用对象
看 std::function 的典型用法:
cpp复制std::function<void(int)> handler;
handler = [](int x) { std::cout << x; };
handler = std::bind(&Logger::log, &logger, std::placeholders::_1);
handler = [this](int x) { handle(x); };
无论 lambda、函数指针、成员函数绑定,只要调用签名是 void(int),就能赋值给同一个 std::function。它是怎么做到的?现代实现里大致是这样:std::function 内部持有指向虚基类 _Function_base 的指针;当你给它赋值一个 lambda 时,模板构造函数实例化出一个 _Function_handler<_Functor> 派生类,它把 lambda 拷贝到内部存储区,并覆写 _M_invoke 虚函数。之后每次 operator() 调用,就通过虚基类指针走到 _M_invoke 上,在那里把存储的对象重新解释为原始 lambda 类型并执行。
我在自己的小型事件系统里复刻过这套逻辑,核心代码不超过 60 行,却能在不依赖任何第三方库的情况下实现 delegate 效果。这也说明 std::function 并非魔法,它是可以学习和重写的设计范式。
2.2 std::any:老玩家都认识的 boost::any 转正
C++17 的 std::any 是一个可以存放任意可拷贝类型的容器:
cpp复制std::any data;
data = 42;
data = std::string("hello");
if (data.type() == typeid(std::string)) {
auto& s = std::any_cast<std::string&>(data);
}
它的内部同样是一个虚基类,包含 type()、clone()、destroy()等虚函数;模板派生类 _Manager<_Tp> 针对每个存入类型特化。std::any 能存在,因为类型擦除把“类型判断”也擦进了虚函数表里:type() 虚函数返回 typeid(T),让使用者可以在运行期安全地查询当前保存的类型。这也解释了为什么 std::any_cast<T> 会做类型检查——它本质上是用 data.type() == typeid(T) 做一次比对。
注意:
std::any要求类型可拷贝。实践中我开始用std::any后第一周就踩了坑:我往里塞了一个std::unique_ptr,结果编译直接报错。因为unique_ptr不可拷贝,无法通过模板构造函数实例化。如果确实要存不可拷贝对象,得包一层shared_ptr或自己实现专用的 any。
2.3 std::variant:另一种擦除思路,静态分发
严格来说 std::variant 不是类型擦除,它是“类型安全联合体”。但它和类型擦除经常被放在一起讨论,因为它解决的是类似问题——把多种不同类型统一管理。区别在于:
std::variant的所有可能类型在编译期就被列出来,存在栈上或对象内部,不涉及堆分配;- 访问者模式(
std::visit)由编译器根据所有可能类型生成分发表,性能通常好于虚函数调用。
我处理协议解析时经常这么用:
cpp复制using Value = std::variant<int64_t, double, std::string, bool>;
Value parseField(const char* data) {
if (data[0] == 'i') return int64_t{42};
if (data[0] == 'd') return 3.14;
return std::string("unknown");
}
当类型集合固定且可以穷举时,variant 是类型擦除更好的替代品;当类型集合开放、运行时才能确定时,std::any 或自定义擦除容器是唯一选择。判断自己到底属于哪一种场景,是架构设计里很关键的一步。
3. 手写一个轻量类型擦除:从模板构造函数到虚表分发的完整链路
3.1 一个能装的容器:DrawableList 的需求与设计
理论堆久了容易飘,直接动手写一个。假设我要做一个小型渲染器,所有东西最后都得被“画出来”。但我不希望每个绘制对象都继承同一个接口类,因为有的是第三方库的类型,有的是 C 函数返回的结构体。我想要的接口是:
cpp复制struct DrawableList {
template <typename T>
void push(T obj); // 任何有 draw(const T&) 重载的类型
void drawAll() const;
};
调用方式:
cpp复制Circle c(1.0f);
Square s(2.0f);
Texture t("tex.png");
DrawableList list;
list.push(c);
list.push(s);
list.push(t);
list.drawAll();
这里的关键需求有两条:第一,push 是模板函数,能接受任意类型;第二,drawAll 内部统一通过运行期多态调用。任务就是把这两条缝在一起。
3.2 三层结构:约定虚基类、代理模板、持有者
第一层,定义一个不涉及具体类型的虚基类,规定好运行期行为:
cpp复制class DrawableConcept {
public:
virtual ~DrawableConcept() = default;
virtual void draw() const = 0;
};
第二层,写一个模板派生类,专门把“某个具体类型”包装成虚基类的样子:
cpp复制template <typename T>
class DrawableModel final : public DrawableConcept {
public:
explicit DrawableModel(T obj) : object_(std::move(obj)) {}
void draw() const override {
draw(object_); // 调用该类型专属的绘制函数
}
private:
T object_;
};
第三层,对外暴露的非虚接口类,内部持有 DrawableConcept 指针:
cpp复制class Drawable {
public:
template <typename T>
Drawable(T obj)
: concept_(std::make_unique<DrawableModel<T>>(std::move(obj))) {}
void draw() const { concept_->draw(); }
private:
std::unique_ptr<DrawableConcept> concept_;
};
DrawableList 就直接变成 std::vector<Drawable>。push 的调用点在 Drawable 的模板构造函数里,它的作用是在编译期捕获 T 的具体类型,实例化出对应的 DrawableModel<T>,然后把这份具体类型信息“固化”进虚表。运行期 draw() 调用走的是 concept_ 的虚函数表,不再有任何模板参数出现。
这就是整个类型擦除的精髓:模板负责在编译期“记住”类型,虚函数负责在运行期“忘记”类型。两者各干各的,配合得天衣无缝。
3.3 拷贝、移动与生命周期:藏在细节里的坑
上面这个实现只是个骨架。如果直接拿去做生产,很快会撞上拷贝语义的问题。Drawable 内部持有 unique_ptr,意味着它只能移动不可拷贝。但很多时候我们需要擦除后的对象本身具备值语义——比如把 Drawable 放进 std::vector,或者返回一个 Drawable 作为函数结果。
解决办法是在虚基类或模型类里手动实现一个 clone():
cpp复制class DrawableConcept {
public:
virtual ~DrawableConcept() = default;
virtual std::unique_ptr<DrawableConcept> clone() const = 0;
virtual void draw() const = 0;
};
template <typename T>
class DrawableModel final : public DrawableConcept {
public:
DrawableModel clone() const override {
return std::make_unique<DrawableModel<T>>(object_);
}
};
然后 Drawable 显式实现拷贝构造函数:
cpp复制Drawable(const Drawable& other)
: concept_(other.concept_->clone()) {}
Drawable& operator=(const Drawable& other) {
if (this != &other) {
concept_ = other.concept_->clone();
}
return *this;
}
这一步很容易被忽略,我最初在 UI 框架里集成绘制对象时,就因为没写拷贝构造,导致项目从 -std=c++14 切到 -std=c++17 后大量“使用已删除函数”的报错。值语义的类型擦除容器,clone 是必需品。
3.4 小型对象优化(SBO):别让每个对象都上堆
直接 make_unique 创建模型类,每次 push 都会发生一次堆分配。对于大量小对象(比如点、颜色、尺寸),这开销可能非常可观。标准库的 std::function 通过小型对象优化(SBO)把小型函数对象直接存进 std::function 内部一个 aligned_storage 缓冲区里,只有对象超出缓冲区容量时才上堆。
仿照这个思路,可以给 Drawable 加一个固定大小的存储区:
cpp复制class Drawable {
public:
template <typename T>
Drawable(T obj) {
using Model = DrawableModel<T>;
static_assert(sizeof(Model) <= BufferSize, "Object too large for SBO buffer");
static_assert(alignof(Model) <= alignof(std::max_align_t), "");
new (&buffer_) Model(std::move(obj));
concept_ = reinterpret_cast<const DrawableConcept*>(&buffer_);
}
private:
alignas(std::max_align_t) std::byte buffer_[64];
const DrawableConcept* concept_;
};
这里因为 DrawableModel<T> 本身包含 T 类型的实例,所以如果 T 不大,整个模型对象能塞进 64 字节缓冲区里,避免堆分配。我在一个游戏消息系统中这样改过之后,热路径上的发消息耗时有明显下降——当然具体数据依赖你的对象大小和分配次数,但思路值得记下。
提示:加入 SBO 后,拷贝构造和移动语义的实现会变复杂。通常的做法是为整个缓冲区再实现一层“浅拷贝/深拷贝”控制逻辑。如果项目对性能不那么敏感,先用
unique_ptr简化实现也没有问题。
4. 性能账本:类型擦除的三种开销与dyno式SBO缓解策略
4.1 堆分配、二次间接与虚函数调用
类型擦除不是免费的午餐,它至少带来三类成本:
- 堆分配:每次创建一个擦除对象,往往意味着一次动态内存分配。即使做了 SBO,也只是把成本从 heap 转移到栈上缓冲区,并没有完全消失。
- 二次间接调用:调用
draw()需要先获得concept_指针,再通过虚函数表查找函数地址。比起直接调用模板函数或内联虚调用,多加了一层指针跳转。 - 模板代码膨胀:每擦除一种类型,编译器就会实例化一份
DrawableModel<T>。类型越多,二进制体积越大。
我在一个低延迟信号处理模块里做过对比测试:直接模板化调用与通过 std::function 擦除调用,每次调用的 CPU 周期数差距大约在 2-3 倍左右。这不代表类型擦除“慢到不能用”,而是说在每帧调用上百万次的场景里,这个差异就会从“噪声”变成“瓶颈”。
4.2 用间接层换灵活性,还是用直接调用换速度
优化类型擦除的第一步不是改实现,而是判断“这里到底需不需要擦除”。经验法则我用了很多年:
- 调用频率极高并且类型集合固定的地方,优先用
std::variant+std::visit,编译期就能把调用链打开; - 调用频率一般,但需要开放类型的扩展点,用
std::function或自写擦除容器; - 对延迟极其敏感且无法预知的类型,考虑另一种完全不同的方案——把类型擦除限制在 IO 或边界层,核心计算路径保持模板直通。
比如我在序列化系统里,Node 类型擦除只用于“反序列化完成后对外暴露统一接口”,一旦拿到具体类型,也就是进入逻辑层之后,立刻转成模板化访问路径。这样擦除层的开销只在数据流转的边界出现一次,而不是贯穿整个生命期。
4.3 dyno模式启发:虚基类直接持有类型与操作
如果你想参考更完整的工业级设计,可以看看 dyno 库的做法:它把“类型信息”和“操作信息”分离成两个虚表,一个管类型生命周期(构造、拷贝、析构、移动),一个管领域的自定义操作。调用点通过 vtable 指针统一分发,同时支持把 vtable 对象本身当作值传递,进一步减少间接层。
这个模式给我的最大启发是:抽象擦除容器时,不要只针对一种操作硬编码虚基类。把操作集合做成一个 Concept 策略,模板化地定义“某种类型必须支持哪些操作”,然后统一生成对应的模型实现。这样你手写的容器不再只服务于 draw(),也能服务于 serialize()、hash()、compare() 等任意行为。做到这一步,你就不是在“用类型擦除做一个小工具”,而是在“设计一套可复用的类型擦除框架”。
5. 边界条件与工程坑位:空对象、异常安全、const正确性、跨DLL
5.1 空对象处理:std::function的bad_function_call与自研方案
std::function 未赋值时可调用,会抛出 std::bad_function_call。std::any 未赋值时 has_value() 返回 false。这些是标准库给出的约定,但自研容器时很多新人会忽略空状态。
我在自己的 Drawable 中引入了“空”语义:如果用一个特殊 tag 初始化,concept_ 可以置空。调用 draw() 时,先检查 concept_ 是否为空。这比抛异常更安全,也更符合“一个默认构造的绘制对象 = 什么都不画”的直觉。
cpp复制class Drawable {
public:
Drawable() = default; // 空对象
explicit operator bool() const { return concept_ != nullptr; }
void draw() const {
if (concept_) {
concept_->draw();
}
}
};
如果是网络请求回调这类场景,我建议沿用 std::function 的抛异常行为,方便在开发阶段暴露“忘记赋值”的 bug。空对象语义没有绝对标准,取决于你的业务容错需求。
5.2 异常安全:析构、拷贝、移动都可能抛出什么
类型擦除容器里会存在用户自定义类型的拷贝构造和移动构造。这些操作可能抛异常。如果容器自身在 clone() 或移动赋值中不小心,很容易出现双重释放或对象切分。
我习惯制定这样几条规则:
- 析构函数永远标记
noexcept,并且确保内部模型类析构不会抛出; - 拷贝构造如果失败,保持原对象不变,新对象处于有效但空的状态;
- 移动构造尽量标记
noexcept,避免标准容器在重新分配时因为移动构造可能抛异常而退回拷贝,产生额外的性能负担。
std::function 的实现里对这一层处理得很仔细,看过它的源码会发现大量 noexcept 标注不是装饰,而是让容器能在异常发生时维持基本保证的关键。
5.3 const正确性:擦除后的对象还能不能被改
虚基类接口中,draw() const 和 draw() 是两个完全不同的行为。类型擦除容器的 const 语义会沿虚函数表传播:
cpp复制class DrawableConcept {
public:
virtual void draw() const = 0; // 只读路径
virtual void draw() = 0; // 可变路径
};
如果对外暴露的接口是 const Drawable&,那么内部只能调用 const 版本的虚函数。这要求设计者在定义擦除接口时就想清楚“被擦除的对象是否允许原地修改”。我遇到过项目里把 std::any_cast<std::string&> 用在 const std::any& 上,编译器直接报错。这不是类型擦除的 bug,而是 const 传播的必然结果。
5.4 跨DLL边界:typeid和虚表可能不是你想象的稳定
当类型擦除跨动态库边界使用时,有两个经典问题:
typeid(T)的比较依赖 RTTI,而不同编译单元的typeinfo是否一致取决于编译器和链接器实现;跨多个 DLL 时风险会上升。- 虚函数表指针在模块间传递本身是安全的,但如果一个模块用
/GR-关闭了 RTTI,另一个模块开着,typeid和dynamic_cast的行为就会开始变得不确定。
我的建议是:如果项目必须跨 DLL 传递擦除对象,尽量用纯虚接口 + 工厂函数,而不要依赖 typeid 去做运行时类型判断。如果想判断类型,可以在擦除层自己暴露一个 const char* typeName() const 虚函数,返回静态字符串,这比依赖 RTTI 更可控。
注意:静态字符串地址在跨 DLL 时也可能发生重复(不同模块各自定义同名字符串),所以严格方案是用 GUID 或全局唯一 token 作为类型标识。
6. 和C++20概念的关系:擦除与约束不是同一枚硬币
6.1 concepts约束的是“能做什么”,类型擦除决定了“怎么做”
C++20 的 concept 是编译期约束,它解决的是“这个模板类型有没有满足特定要求”的检查问题:
cpp复制template <typename T>
concept Drawable = requires(const T& obj) {
{ draw(obj) } -> std::same_as<void>;
};
void render(const Drawable auto& obj) {
draw(obj);
}
它不能替代类型擦除。concept 只负责把关,类型信息依然在编译期存在;类型擦除负责把类型信息隐藏,让运行期行为统一。两者是不同层次的东西,但可以协同:在类型擦除的模板构造函数里加入 Drawable 约束,可以把“不支持 draw 的类型传进来”的错误从运行期/链接期提前到编译期。
cpp复制template <Drawable T>
Drawable(T obj)
: concept_(std::make_unique<DrawableModel<T>>(std::move(obj))) {}
这段代码的意思是:仅当类型满足“可以被绘制”的概念时,模板构造函数才会参与重载决议。不等同于真的替你在运行期做检查,而是把错误尽可能提前暴露。
6.2 现代C++里的类型擦除先看标准库,再看Boost,最后才自造轮子
我花了大把时间手写擦除,但如果是新项目,第一选择永远是标准库:
- 需要统一调用任意可调用对象,用
std::function; - 需要存储任意类型,用
std::any; - 需要类型集合静态已知,用
std::variant。
只有当这些都无法满足时——比如你需要“对外开放的接口不允许暴露模板参数”的 ABI 稳定边界,或者需要性能更可控的 SBO 策略,或者需要跨模块稳定的类型标识——才考虑自研。到那一步,前面 3.3、4.3 和 5.4 节的内容就会派上用场。
我在实际项目中最大的一次“后悔”就是早期在没有必要的地方大量使用自研类型擦除,导致后来迭代时既要维护虚接口,又要维护模板约束,心智负担翻倍。类型擦除是一种很有诱惑力的抽象,它让代码看起来简洁、统一、可扩展。但每一次抽象都有成本,擦除掉的类型信息越彻底,后期定位问题时的线索就越少。只有在边界明确、行为稳定、类型集合开放这三个条件同时成立时,我才会把类型擦除放进核心架构。否则,优先选择约束更紧、类型信息保留更完整的方案——比如 std::variant 或直接模板化——往往更省心。
