那次做图形编辑器,我被一个问题卡住了:场景里有圆形、矩形、贝塞尔路径,还有用户注册的脚本回调,它们的数据结构完全不一样,但渲染循环只想做一件事——挨个调用 draw()。用模板写,每一种类型都得单独特化,一个循环装不下;用继承写,我总不能要求第三方算法库的类都继承我预定义的 Shape 基类。转了一圈,最后救场的正是 C++ 里的类型擦除技术。
这篇我直接从为什么需要类型擦除讲起,然后拆 std::function、std::any 的底牌,附一份手写骨架代码,最后聊清楚哪些场景该拥抱它、哪些场景该绕开它。面试题里那句“类型擦除是什么”,看这一篇应该就够应付了。
1. 为什么需要类型擦除:模板多态解决不了的那盘棋
1.1 模板的天然短板:编译期必须知道一切
C++ 的模板是个编译期权谋大师。你写一个 template <typename T> T max(T a, T b),编译器会在每个调用点针对具体类型生成对应代码。好处是零抽象、可内联,坏处是:类型在编译期就必须确定。
一旦你想要一个容器,里面既能放 int 又能放 string,你让编译器怎么办?vector<int> 和 vector<string> 是两种完全不同的类型,不存在一个 vector 可以把两者都装进去。模板是生成代码的规则,不是运行时的抽象边界。
回调场景更明显。UI 系统里一组事件处理器,有的是 lambda,有的是某个对象的成员函数,有的是普通函数指针。你没法用模板一个参数把它们统一存进同一个 std::vector,更没法把这样的集合作为成员变量在类之间传递。因为模板本身没有“类型统一”的能力。
1.2 继承方案的边界在哪里
老派做法是定义基类 EventHandler,派生几个子类包住不同调用方式。这没问题,但前提是你手里的类型多少还有点配合——能继承、能改、不抵触侵入式设计。
可现实中的类型往往不配合你:第三方库的算法节点、引擎脚本系统导出的 OpaqueHandle、从 C ABI 拿到的 void* 封装对象,它们根本不继承你的基类,也没法让你塞进虚函数体系里去。更别提 lambda,它只是一个匿名函数对象,没有基类可继承,也不存在什么“所有可调用对象公共基类”这种东西。
如果强行用继承做通用回调系统,你得为每一种调用形式写一个包装类:函数指针包装器、成员函数指针包装器、仿函数包装器……写起来痛苦,维护起来更痛苦。
1.3 类型擦除到底擦掉了什么
类型擦除的思路是:把“这个对象具体是什么类型”这个问题,从编译期挪到运行期。用模板派生类作为适配器,把一个任意类型 T 包装进一个固定基类;外部只需要看到一个固定接口。T 的类型被“擦掉”了,但它对应的操作被保留在虚函数表里。
这就是为什么 std::function 能吃掉任意可调用对象,std::any 能装任意可拷贝类型,std::thread 能包装平台相关的线程入口。它们统一做了一件事:类型被擦除,操作被保留。
一句话版本:类型擦除 = 模板做适配器,虚函数做调度,把“类型的多变性”关进一个小盒子里,对外只露出一个稳定的接口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 类型擦除的核心机制:虚函数表如何“抹平”类型差异
2.1 “擦除类型”到底擦掉了什么
擦除的是编译器对具体类型的编译期依赖,而不是对象本身的数据。你擦掉的只是“这个对象是什么”,留下的始终是“这个对象能干什么”。
生活类比:HR 的流程只需要每个人会写汇报,不关心谁是销售谁是研发。汇报动作被抽成了统一接口,具体部门类型被擦掉了,但每个部门实际完成工作的能力被保留在各自的实现里。这和类型擦除的思路一模一样。
还有一个容易混淆的点:类型擦除和 void* 擦除的区别。C 语言用 void* 也把具体类型藏起来了,但调用时你得手动告诉它是什么类型,编译器没有帮你兜底。C++ 的做法是在虚函数内部把 T 重新还原成具体调用,类型安全由编译器保证,外部不会直接看到 T,也就不会用错。这就是为什么 std::function 拿到的未必是原始对象,但调用永远是对的。
2.2 最经典的类型擦除样板:基类 + 模板派生类
直接看代码。假设我们想要一个形状渲染系统,支持的形状类型来自不同模块:
cpp复制#include <iostream>
#include <memory>
#include <vector>
class Drawable {
public:
virtual ~Drawable() = default;
virtual void draw(std::ostream& os) const = 0;
};
template <typename T>
class DrawableModel final : public Drawable {
public:
explicit DrawableModel(T obj) : obj_(std::move(obj)) {}
void draw(std::ostream& os) const override {
obj_.draw(os);
}
private:
T obj_;
};
这里 Drawable 是公共接口,外层看到的全部。DrawableModel<T> 是模板适配器,T 在这里已经被实例化,但它继承了一个非模板基类。构造时传任意 T,保存在内部;draw() 虚函数内部实际调用 T::draw。外部存的是 Drawable* 或 vector<unique_ptr<Drawable>>,调用统一走虚函数。
模板参数 T 的实例化发生在构造函数被调用的那一刻,但一旦放进基类指针,外层就不再感知 T。类型被成功擦除。
使用起来就变成了:
cpp复制struct Circle {
void draw(std::ostream& os) const {
os << "circle\n";
}
};
struct Rectangle {
void draw(std::ostream& os) const {
os << "rectangle\n";
}
};
int main() {
std::vector<std::unique_ptr<Drawable>> shapes;
shapes.push_back(std::make_unique<DrawableModel<Circle>>(Circle{}));
shapes.push_back(std::make_unique<DrawableModel<Rectangle>>(Rectangle{}));
for (const auto& s : shapes) {
s->draw(std::cout);
}
}
Circle 和 Rectangle 完全不知道 Drawable 的存在,它们只是恰好提供了 draw() 方法。这就是非侵入式设计带来的好处:第三方类型不需要知道你的接口就能融入你的系统。
2.3 操作函数表:类型擦除背后必须支持的三板斧
一个能真正落地的类型擦除容器,不只是把对象存进去就行,还要考虑:调用(invoke)、拷贝(clone)、析构(destroy)。这三个操作是类型擦除组件的标配。
- 调用:虚函数实现,把外层统一调用分发给内部真实对象。
- 克隆:外层拿到的是基类指针,拷贝一个派生对象时不知道具体类型,只能用虚 clone 来制造一份。这是“虚拟拷贝”的经典用法。
- 析构:通过虚析构释放内部对象,避免出现“基类指针 delete 子类对象却只析构基类”的未定义行为。
你会发现,真正做类型擦除的往往是那张操作表,而不是某个花哨语法。你敲下 std::function 时,十万八千里外其实就是这样一张表。
3. 手写一个 std::function 骨架:最小可运行的类型擦除
3.1 为什么拿 std::function 当教材
std::function 体量小、语义明确、应用广,而且所有关键机制都包含在一个类模板里:模板构造函数、虚拟拷贝、调用分发。手写一个最小版本,比单纯看源码更能理解类型擦除。
3.2 第一版:基于堆分配的经典实现
cpp复制#include <iostream>
#include <functional>
#include <memory>
#include <utility>
template <typename Signature>
class my_function;
template <typename R, typename... Args>
class my_function<R(Args...)> {
public:
my_function() = default;
template <typename T>
my_function(T&& f)
: ptr_(new callable_model<std::decay_t<T>>(std::forward<T>(f))) {}
my_function(const my_function& other)
: ptr_(other.ptr_ ? other.ptr_->clone() : nullptr) {}
my_function(my_function&& other) noexcept
: ptr_(std::exchange(other.ptr_, nullptr)) {}
my_function& operator=(my_function other) {
swap(other);
return *this;
}
~my_function() {
delete ptr_;
}
explicit operator bool() const noexcept {
return ptr_ != nullptr;
}
R operator()(Args... args) const {
if (!ptr_) throw std::bad_function_call();
return ptr_->invoke(std::forward<Args>(args)...);
}
void swap(my_function& other) noexcept {
std::swap(ptr_, other.ptr_);
}
private:
struct callable_base {
virtual ~callable_base() = default;
virtual callable_base* clone() const = 0;
virtual R invoke(Args... args) = 0;
};
template <typename T>
struct callable_model final : callable_base {
T f;
explicit callable_model(T&& f) : f(std::move(f)) {}
callable_base* clone() const override {
return new callable_model(*this);
}
R invoke(Args... args) override {
return f(std::forward<Args>(args)...);
}
};
callable_base* ptr_ = nullptr;
};
几个必须解释的细节:
- 构造函数模板是唯一的入口,它对任意可调用对象 T 开放,同时用
std::decay_t<T>把引用和 const 剥掉,保证按值持有一份对象。 clone()就相当于虚拟拷贝,这是拷贝构造函数的底层依赖。operator()是 const 的,但ptr_是裸指针,所以内部可以调用非 const 的invoke,这与标准库行为一致。- 赋值函数用 copy-and-swap 惯用法,既处理了自赋值,又利用拷贝构造保证异常安全。
3.3 小对象优化 SBO:把大部分对象从堆里救回来
上面那个版本功能上没问题,但一个空 lambda 也要 new 一次。堆分配虽然小,但架不住高频回调时触发的分配开销拖后腿。
标准实现基本都加了小对象优化(Small Buffer Optimization)。思路很简单:类内部挖一小块内存,比如 24 字节或 32 字节;对象放得下就放进去,放不下再走堆。
cpp复制template <typename R, typename... Args>
class my_function<R(Args...)> {
using invoker_t = R (*)(void*, Args&&...);
using cloner_t = void* (*)(const void*);
using destroyer_t = void (*)(void*);
inline static constexpr size_t BufferSize = 24;
inline static constexpr size_t Alignment = alignof(std::max_align_t);
alignas(Alignment) std::byte buffer_[BufferSize];
void* obj_ = nullptr;
invoker_t invoker_ = nullptr;
destroyer_t destroyer_ = nullptr;
// 真实实现里还会有 cloner_ 等操作表指针
};
模板构造函数里用 if constexpr (sizeof(T) <= BufferSize && alignof(T) <= Alignment) 决定放 buffer 还是堆。所有对对象的操作都通过函数指针进行,不再有虚函数表,因为函数指针可以在模板实例化期间确定。
注意:标准并不保证 SBO,但主流实现基本都有。libstdc++ 和 libc++ 的缓冲区大小还不一样,跨平台开发时不要假设一个 lambda 一定不触发堆分配。
3.4 一个实际使用示例
cpp复制struct Event {
int code;
};
void handleEvent(const Event& e) {
std::cout << "free function: " << e.code << "\n";
}
class Handler {
public:
void onEvent(const Event& e) {
std::cout << "member function: " << e.code << "\n";
}
};
int main() {
std::vector<my_function<void(const Event&)>> handlers;
handlers.emplace_back(handleEvent);
handlers.emplace_back([](const Event& e) {
std::cout << "lambda: " << e.code << "\n";
});
auto h = std::make_shared<Handler>();
handlers.emplace_back([h](const Event& e) {
h->onEvent(e);
});
Event ev{42};
for (auto& fn : handlers) {
fn(ev);
}
}
这里最后一个 handler 用了 lambda 捕获 shared_ptr<Handler>,是因为如果直接捕获 Handler*,对象的生命周期需要外部保证;捕获 shared_ptr 则让 function 自己持有所有权,更安全。顺带说明一点:如果 lambda 捕获的是 unique_ptr,那么这个 lambda 不可拷贝,塞进 std::function 会直接编译失败,因为 function 要求可调用对象可拷贝。这种情况下要么用 shared_ptr,要么用移动语义加 mutable 加 shared 状态的技巧,但最简单还是 shared_ptr。
4. 从 std::any 看类型擦除的完整落地:操作表 + 存储区
4.1 std::any 到底在擦什么
std::function 擦的是“可调用对象”,std::any 擦的是“值”。any 像是一个类型安全的万能口袋:你可以往里塞 int、塞 std::string、塞自定义对象,然后从另一个地方原样取回来。
any 在标准库里的实现通常分两块:操作表和存储区。操作表是一组类型相关的函数指针,存储区可以是内部缓冲区或堆。对象被放进 any 后,外部对类型一无所知,但操作表知道怎么析构、怎么拷贝、怎么移动。
4.2 用 typeid 守住类型安全
any_cast 成功的关键是 typeid 比较。any 内部会保存一份 typeid(T) 的指针,而 T 可能是用户自己的类型,所以实现直接用 std::type_info 的地址比较做运行时判断。如果匹配,就把 void* 转回 T*;不匹配就抛 std::bad_any_cast。
很多人问 any 和 void* 有什么差别,差别就在这里:void* 没带任何类型信息,取出来靠程序员自觉;any 带了 type_info,类型安全由运行时检查兜底。这也是 any 能成为现代 C++ 代码库常客的原因。
4.3 一个简化的 my_any 骨架
cpp复制#include <any>
#include <typeinfo>
#include <type_traits>
#include <cstddef>
class my_any {
public:
template <typename T>
my_any(T&& value)
: type_(&typeid(std::decay_t<T>)) {
using U = std::decay_t<T>;
static_assert(std::is_copy_constructible_v<U>,
"my_any requires copy constructible type");
if constexpr (sizeof(U) <= BufferSize && alignof(U) <= Alignment) {
new (buffer_) U(std::forward<T>(value));
obj_ = buffer_;
destroy_ = [](void* p) {
static_cast<U*>(p)->~U();
};
} else {
U* ptr = new U(std::forward<T>(value));
obj_ = ptr;
destroy_ = [](void* p) {
delete static_cast<U*>(p);
};
}
}
~my_any() {
if (obj_) destroy_(obj_);
}
template <typename T>
friend T my_any_cast(my_any& a) {
using U = std::decay_t<T>;
if (*a.type_ != typeid(U)) throw std::bad_any_cast();
return *static_cast<U*>(a.obj_);
}
private:
static constexpr size_t BufferSize = 32;
static constexpr size_t Alignment = alignof(std::max_align_t);
alignas(Alignment) std::byte buffer_[BufferSize];
const std::type_info* type_ = nullptr;
void* obj_ = nullptr;
void (*destroy_)(void*) = nullptr;
};
这是思路示意,省略了拷贝构造、移动构造、拷贝赋值这些完整管理逻辑。真实实现还会维护 copy 和 move 的操作表指针。但你可以看到核心:SBO 分支决定对象存在 buffer 还是堆上,析构函数里同时处理栈上对象(手动析构)和堆上对象(delete),这是 SBO 实现的关键。
any_cast 需要注意一个细节:标准库的 any_cast<T> 返回值时要求类型可拷贝构造,而 any_cast<T&> 返回引用时不需要。存进去是 int,取出来用 any_cast<int> 没问题;但如果存的是 const int,用 any_cast<int&> 就会抛异常,因为类型严格不匹配。类型擦除后的运行时检查,靠的就是这种“精确类型”的严格性。
5. 类型擦除的代价与边界:性能、调试与可维护性
5.1 性能账:虚函数、间接调用、SBO
类型擦除不是免费的。最大的成本是虚函数调用或函数指针调用带来的间接寻址。编译器在调用点不知道被调用的具体实现,没法内联,也无法做常量和分支优化。
第二个成本是拷贝。function 和 any 都是值语义,拷贝时要深度复制内部对象。如果对象很大,再加上堆分配,一条本来只要几十个周期的代码路径可能膨胀到上千个周期。这也是为什么标准库要加 SBO:先把最常见的、体积小的对象留在栈上,只有装不下的才走堆。
我见过一个真实案例:一段高频回调代码里用了 std::function 做事件派发,单次调用其实只要 20 ns,但 lambda 捕获了一个不小的上下文对象,每次拷贝都触发堆分配,整体性能掉了一半。后来改用 shared_ptr 捕获上下文,或者把 std::function 换成模板接口,性能才回来。
对照一下:写成模板直接在编译期确定类型,编译器可以把整个函数体内联成一个循环;擦除后只能一个一个虚调用。高频路径使用类型擦除,要非常谨慎。
5.2 调试与可读性:类型名称从符号里消失
类型擦除后,对象在调用栈里呈现的是某个 callable_model<T> 或 void* 指针,IDE 的智能提示往往无法推导真实类型。你只知道手里有一个 std::function,不知道里面到底是谁。
这也是为什么类型擦除应该做在 API 边界,而不是内部到处撒。内部逻辑能保持模板就保持模板,只有对外接口需要稳定类型时才擦。否则一旦出 bug,调试就是在层层虚函数调用里翻找,定位成本很高。
5.3 和 C++20 concepts 的区别
有同学把 concepts 和类型擦除混在一起。Concepts 是编译期约束,约束的不是某一种具体类型,而是满足某个接口的任何类型。它在编译期做检查,不改变类型,也没法把不同类型塞进一个容器。
举个例子:template <Drawable T> void render(const T& d) 只接受有 draw() 方法的类型,这是编译期约束。但如果你想让 vector 同时装 Circle 和 Rectangle,concepts 帮不了你,因为没有运行时多态。类型擦除才是那个能把不同类型统一进容器的方案。
两者其实是配合关系:concepts 负责约束模板参数满足什么接口,类型擦除负责运行时统一。写模板时用 concepts 做编译期检查,跨模块传递时再用擦除封一层。
5.4 选型决策表
| 需求场景 | 推荐方案 | 理由 |
|---|---|---|
| 编译期就确定类型集合,不需要运行时统一接口 | 模板直接写 | 零开销,可内联 |
| 类型种类固定且自己可控 | 虚函数 + 继承 | 简单直接,语义清晰 |
| 类型来自第三方,无法改基类 | 类型擦除 | 非侵入式,不要求原类型配合 |
| 只需要编译期约束,无需运行时多态 | concepts | 编译期检查,零运行开销 |
| 类型不可枚举,又要跨模块传递 | 类型擦除 | 接口固定,底层实现自由 |
6. 实战中的避坑清单与工程经验
6.1 std::function / std::any 使用时的常见误伤
先说一个最常见的:空 std::function 调用会抛 std::bad_function_call。调用前用 if (f) f(); 检查一下,成本极低,能省不少 crash 排查时间。
第二个误伤是 lambda 捕获 unique_ptr 后塞进 std::function。前面说过,function 要求可调用对象可拷贝,而捕获 unique_ptr 的 lambda 不可拷贝。很多人第一次遇到这个编译错误会懵,其实只要把捕获改成 shared_ptr,或者用自定义仿函数类管理生命周期,就解决了。
第三个误伤是 any_cast 的类型精确匹配。存 int 进去,用 any_cast<long> 取回来会抛异常;存 const std::string 进去,any_cast<std::string> 也会失败。规则就一条:取的类型必须和存的类型完全一致(忽略顶层 const 和引用 cv 限定需谨慎,最稳妥是保持完全一致)。
第四个容易被忽视的点是拷贝成本。any 和 function 的拷贝构造都是深拷贝,如果你把它放进 vector 又频繁扩容,会触发大量堆分配和对象复制。优先 move,或者用 shared_ptr<any> 这样的包装减少拷贝。
6.2 什么时候坚决不用类型擦除
类型擦除不是银弹,有些场景碰都别碰。
第一,编译期类型已知的热点路径。比如一个排序比较器,两个整数比较,直接写模板或者函数对象比 std::function 快一个数量级。类型擦除的间接调用在这里毫无价值。
第二,需要自动推导返回类型的场景。std::function<R(Args...)> 的返回类型 R 是明确的,如果你希望根据调用对象的 operator() 自动推导返回类型,类型擦除做不到。这时候该用 auto 返回类型 + 模板,或者 C++14 的泛型 lambda 配合。
第三,类型数量少且封闭的场景。系统里只有两种消息类型,写两个接口就行,没必要为了“未来可能”引入一层类型擦除包装。YAGNI 原则在类型擦除这里特别适用。
6.3 面试高频盲区
题目:“std::function 是怎么实现的?”
简化回答:模板构造函数存储任意可调用对象,内部通过一个非模板基类接口做调用分发,具体类型被模板派生类包装,基类指针持有派生对象。拷贝时用虚拟 clone,调用时用虚函数。再加上小对象优化避免小 lambda 的堆分配。
题目:“类型擦除和模板有什么区别?”
模板是编译期多态,类型在编译期确定,零开销但类型集合必须已知;类型擦除是运行时多态,类型在运行期被隐藏,可以容纳任意类型,但要付出虚函数调用和拷贝的成本。
题目:“std::any 如何保证类型安全?”
底层保存 typeid(T) 信息,any_cast 时先比较 type_info,不匹配抛 bad_any_cast,匹配才做指针转换。这就是运行时类型检查。
还有一个容易踩的盲区:自定义类型放进 any 时必须可拷贝构造,否则编译直接报 static_assert。很多以为 any 能装不可拷贝类型,其实标准库的 any 就是要求 CopyConstructible,不可拷贝的类型应该用 shared_ptr 或自定义包装。
6.4 跨模块与 ABI 的实战经验
类型擦除常用于跨模块接口设计。一个典型的做法是:对外导出一个非模板的虚函数接口,内部用一个模板实现类来做适配。这样外部模块只需要依赖稳定的虚表结构和固定签名,不需要看到内部大量模板实例化代码,编译依赖和头文件暴露都少很多。
但这里有个坑:跨 DLL / 动态库边界传递 std::function 或 std::any 时,要保证两端的 RTTI 和异常机制一致。如果两边用了不同的 CRT 或者不同的编译器设置,typeid 比较可能失效,bad_any_cast 抛不出来反而造成未定义行为。
所以我的建议是:跨模块边界尽量传递固定签名的接口,内部用类型擦除封装业务细节;如果一定要跨模块传 any,先确认两边的运行时环境一致,并在接口层做好异常边界处理。
在项目里,我习惯把类型擦除当做一个“适配层工具”而不是“默认抽象工具”。内部多态用模板可以直接解决,只有当我需要把完全不同的第三方类型放进同一个容器、同一个成员变量、同一个跨模块接口时,才果断请类型擦除出场。它像是一张万能插座,把多种插头统一成一个口,但你不会给家里每个电器都加这么一张转换器——该直连的直连,该转换的才转换。
