公司动态
从 std::function 出发,一文吃透 C++ 类型擦除(Type Erasure):原理、手写实现与开源方案
开场一个让你头皮发麻的场景假设你在写一个事件系统希望把任何可调用的东西函数指针、lambda、仿函数、成员函数统一塞进一个 vector 里// 这些家伙长得完全不一样却想放进同一个容器 void free_func(int x) { /* ... */ } // 普通函数 auto lambda [](int x) { /* ... */ }; // lambda每个 lambda 都是独一无二的类型 struct Functor { void operator()(int x) const {} }; // 仿函数 std::vector??? callbacks; // ??? 到底该写什么类型如果你是 C 语言出身第一反应是函数指针void (*)(int)。但 lambda 和仿函数根本不是函数指针它们是一堆看不见类型的对象。C 怎么让这些类型完全不同、却行为相似的东西共用一套接口答案是三个字类型擦除Type Erasure。这个词听起来很玄其实你天天在用——std::function、std::any、std::variant、std::thread、std::shared_ptr 的删除器背后全是它的身影。这篇文章我会先讲清楚类型擦除是什么、解决什么问题优点用快递盒子类比带你拆开它的底层——虚表vtable手把手从零写一个迷你版 any 和迷你版 function完整可运行代码对比标准库三剑客与三个主流开源库boost::type_erasure、dyno、te最后给你一张常见问题速查表踩坑不迷路。1. 什么是类型擦除先把它说成人话一句话定义类型擦除 把具体类型藏起来只暴露一个统一的、运行时才知道的接口。快递盒子类比强烈建议先理解这个你寄快递时不会把手机、书、螺丝刀直接裸露着塞进货车而是先装进大小不一的纸箱再把纸箱搬上车。货车上只有一种东西纸箱。纸箱 统一的外壳std::function / std::any 的外壳类型手机 / 书 / 螺丝刀 具体类型int、MyClass、某个 lambda搬箱工人 调用方不需要知道箱子里是什么只管搬关键纸箱上贴着一张操作说明卡写着开箱方法——这就是虚表vtable。⚠️预警 1类型擦除 ≠ 类型丢失类型擦除只是在编译期把类型信息藏进虚表运行时如果需要还能通过 typeid / dynamic_cast 找回部分信息std::any_cast 就是这么干的。它没有丢掉类型而是藏起来按需再取。技术上的准确表述类型擦除是一种非侵入式多态相对于继承的侵入式多态。继承要求你的类必须派生自某个基类侵入而类型擦除不要求——任何类型只要满足某组操作可调用、可拷贝、可哈希……就能被包进统一外壳。2. 为什么需要它—— 使用优点痛点即优点2.1 优点一打破侵入式继承的枷锁继承式接口长这样// 侵入式你的类必须继承接口改不了别人的类 struct ICallback { virtual void call(int) 0; }; struct MyClass : ICallback { // 必须自己主动继承 void call(int x) override { /* ... */ } };问题如果 MyClass 是第三方库写的、或者你不想让它和你的框架耦合继承就做不到了。类型擦除则零侵入std::functionvoid(int) f MyClass{}; // 不需要继承任何东西拿来即用2.2 优点二把不同类型装进同一容器没有类型擦除你没法把一堆 lambda 塞进 vector。有了它std::vectorstd::functionvoid(int) handlers; handlers.emplace_back([](int x) { std::cout A: x; }); handlers.emplace_back([](int x) { std::cout B: x; }); // 两种不同类型两个匿名 lambda和谐共处2.3 优点三编译期隔离缩短构建时间模板是编译期展开一个模板用 N 种类型实例化 N 次头文件里塞满实现改一行全部重编。类型擦除把接口和实现分离接口只依赖一个外壳类型实现细节被藏进 .cpp极大降低编译耦合。2.4 优点四抹平 ABI 差异跨库边界友好想象两个模块一个编译时用了旧版 Foo另一个用了新版 Foo。如果接口直接依赖 Foo 的具体类型二进制接口ABI一旦变化程序直接崩。而 std::function 这类外壳在 ABI 层是稳定的——它只暴露可调用、可拷贝这几个固定操作底层实现怎么改都不影响调用方重新编译。这也是为什么很多插件系统、脚本绑定pybind11、sol2都用类型擦除做桥接。⚠️预警 2优点 ≠ 免费类型擦除用一次虚函数间接调用 可能的堆分配换来了灵活性。性能敏感的内层循环里请慎用详见第 6 节对比表。3. 底层机制解剖虚表vtable是怎么当操作说明卡的现在进入本文最硬核的部分。先看一个经典例子搞懂它你就搞懂了类型擦除 80% 的原理。3.1 从一个基类 三个虚函数看起#include iostream #include memory // 一个抽象操作接口任何想被擦除的类型都必须能回答这三个问题 struct VTable { virtual ~VTable() default; // 1. 怎么销毁 virtual std::unique_ptrVTable clone() const 0; // 2. 怎么拷贝深拷贝 virtual void greet() const 0; // 3. 怎么执行核心行为 }; // 模板化的搬运工把具体类型 T 翻译成 VTable 能理解的操作 template typename T struct VTableImpl : VTable { T value_; // 真正的数据就藏在这里 explicit VTableImpl(T v) : value_(std::move(v)) {} std::unique_ptrVTable clone() const override { return std::make_uniqueVTableImplT(value_); // 用 T 的拷贝构造实现深拷贝 } void greet() const override { std::cout value_ std::endl; // 调用 T 自己的行为 } }; // 这就是纸箱对用户暴露的、类型固定的外壳 class Greeter { public: template typename T Greeter(T v) // 万能构造任何 T 都能进来 : impl_(std::make_uniqueVTableImplT(std::move(v))) {} Greeter(const Greeter other) // 拷贝时通过虚函数深拷贝 : impl_(other.impl_ ? other.impl_-clone() : nullptr) {} void greet() const { impl_-greet(); } // 转发给虚表 private: std::unique_ptrVTable impl_; // 唯一的成员指向基类的指针 }; int main() { Greeter g1(42); // T int Greeter g2(std::string(hello)); // T std::string Greeter g3(g2); // 拷贝走的不是模板而是虚函数 clone() g1.greet(); // 42 g2.greet(); // hello g3.greet(); // hello return 0; }3.2 逐行拆解发生了什么阶段发生的事通俗说法构造 Greeter(42)编译器实例化 VTableImplint把它 new 出来把 int 数据塞进去基类指针 impl_ 指向它手机装进纸箱贴上手机操作卡调用 g1.greet()通过基类指针触发虚函数调用运行时找到 VTableImplint::greet工人看一眼卡片按手机操作卡开箱拷贝 Greeter(g2)拷贝构造调用虚函数 clone()内部用 std::string 的拷贝构造重新开模做了一个一模一样的纸箱析构基类 ~VTable() 是虚的正确销毁派生对象纸箱按卡上的销毁方法处理请盯住一行class Greeter 里唯一的成员是 std::unique_ptrVTable impl_。这意味着——无论你装进去的是 int、string 还是 500MB 的自定义对象Greeter 本身的类型和大小永远不变。这就是类型被擦除的字面意义编译期只有 Greeter 一种类型可见真正的 T 被藏进了虚表之后。⚠️预警 3虚函数是运行时跳转不是编译期内联编译器对虚调用通常做间接跳转无法像普通函数那样轻松内联。如果 greet() 本身很小且被高频调用擦除版本比直接调用慢一个量级。这就是 std::function 在热路径上被嫌弃的原因。3.3 小对象优化SBO/SOO避免每次都堆分配上面代码每次构造都 make_unique 一次堆分配。堆分配很慢std::function 和 std::any 都用了一招小对象优化Small Buffer Optimization——对象够小就直接塞进外壳自带的缓冲区只有放不下才去堆上分配。// 伪代码示意std::function 内部大致长这样 class function { // 自带一块 16/32 字节的缓冲区各实现不同libstdc 是 16 字节 alignas(std::max_align_t) char buffer_[16]; const vtable* vt_; // 指向操作说明卡 // 如果 sizeof(T) 16 alignof(T) 对齐 OK把 T 直接构造进 buffer_ // 否则buffer_ 里存一个指向堆上 T 的指针 };这解释了为什么 std::function 的大小远超一个指针sizeof(std::functionvoid()) 在 libstdc 上是 32 字节其中一半是留给小对象的缓冲区。4. 具体使用方式手写迷你 any 与迷你 function小白可操作纸上得来终觉浅。我们分三步亲手写两个迷你版。全程只有一个 .cpp 文件直接复制粘贴就能编译运行。4.1 环境准备# 任选其一 g -stdc17 -O2 main.cpp -o demo ./demo # GCC clang -stdc17 -O2 main.cpp -o demo ./demo # Clang # MSVC用 VS 开发者命令行cl /std:c17 main.cpp⚠️预警 4需要 C14下面代码用到了 std::make_uniqueC14和结构化绑定C17。如果你的环境只有 C11把 make_unique 换成裸 new 即可。4.2 第一步迷你 any能装任何类型还能取回来#include iostream #include memory #include typeindex #include utility // ---------- 迷你 Any ---------- class MiniAny { public: // 1) 万能构造任何类型都能装进来 template typename T MiniAny(T v) : type_(typeid(T)), holder_(std::make_uniqueHolderImplT(std::move(v))) {} // 2) 拷贝必须深拷贝否则两个 MiniAny 共享同一份数据 MiniAny(const MiniAny other) : type_(other.type_), holder_(other.holder_ ? other.holder_-clone() : nullptr) {} // 3) 查询真实类型 const std::type_info type() const { return type_; } // 4) 取回如果 T 对不上返回 nullptr比抛异常更小白友好 template typename T T* get() const { if (type_ ! typeid(T)) return nullptr; // 类型不匹配安全返回空 auto* p dynamic_castconst HolderImplT*(holder_.get()); return p ? p-value_ : nullptr; } private: struct Holder { virtual ~Holder() default; virtual std::unique_ptrHolder clone() const 0; // 虚的深拷贝接口 }; template typename T struct HolderImpl : Holder { T value_; explicit HolderImpl(T v) : value_(std::move(v)) {} std::unique_ptrHolder clone() const override { return std::make_uniqueHolderImplT(value_); } }; std::type_index type_; // 记录箱子里是什么 std::unique_ptrHolder holder_; // 虚表指针 数据 }; int main() { MiniAny a1(42); // 装 int MiniAny a2(std::string(type erasure)); // 装 string MiniAny a3(a2); // 拷贝 std::cout a1 type a1.type().name() \n; if (auto* p a1.getint()) std::cout a1 as int *p \n; if (auto* s a3.getstd::string()) std::cout a3 as string *s \n; if (a3.getdouble() nullptr) // 故意取错类型 std::cout getdouble safely returns nullptr\n; return 0; }运行输出g 下a1 type i a1 as int 42 a3 as string type erasure getdouble safely returns nullptr这 60 行代码就是 std::any 的骨架。它证明了类型擦除最核心的两件事任何类型都能进万能模板构造按需取回type_index 比对 dynamic_cast。⚠️预警 5为什么用 dynamic_cast 而不是 static_cast因为 holder_ 是基类指针指向的实际是 HolderImplT。模板实例化时每个 T 生成一个不同子类dynamic_cast 负责在运行时确认真的是这个子类。这一步就是 RTTI运行时类型识别是类型擦除安全性的基石。4.3 第二步迷你 function任何可调用对象都能调用#include iostream #include memory #include utility // ---------- 迷你 Function ---------- template typename Signature class MiniFunction; // 主模板只声明下面针对函数签名特化 // 特化只支持返回 void、一个 int 参数的签名方便讲原理 template class MiniFunctionvoid(int) { public: // 万能构造接受任何能用 (int) 调用的东西 template typename Callable MiniFunction(Callable c) : invoker_(std::make_uniqueInvokerImplCallable(std::move(c))) {} // 核心转发调用operator() 让对象用起来像函数 void operator()(int x) const { invoker_-invoke(x); } private: // 抽象操作接口只问一句你会怎么用 (int) 调用你 struct Invoker { virtual ~Invoker() default; virtual void invoke(int x) const 0; }; // 具体类型的翻译官 template typename Callable struct InvokerImpl : Invoker { Callable c_; explicit InvokerImpl(Callable c) : c_(std::move(c)) {} void invoke(int x) const override { c_(x); } // 转调真正的对象 }; std::unique_ptrInvoker invoker_; }; int main() { MiniFunctionvoid(int) f1 [](int x) { std::cout lambda: x \n; }; MiniFunctionvoid(int) f2 [](int x) { std::cout lambda double: x * 2 \n; }; struct Adder { // 仿函数重载 operator() void operator()(int x) const { std::cout functor: x 100 \n; } }; MiniFunctionvoid(int) f3 Adder{}; f1(1); // 输出 lambda: 1 f2(2); // 输出 lambda double: 4 f3(3); // 输出 functor: 103 return 0; }三行核心代码看懂迷你 function 就学会了struct Invoker { virtual void invoke(int x) const 0; }; // 接口 template typename Callable struct InvokerImpl : Invoker { void invoke(int x) const override { c_(x); } }; // 桥接 std::unique_ptrInvoker invoker_; // 唯一成员虚表指针妙处InvokerImplCallable 是模板每来一种 lambda 编译器自动生成一种翻译官但 MiniFunctionvoid(int) 只有一个类型。调用方看到的永远只有 invoker_-invoke(x)具体是谁在响应运行时虚表说了算。4.4 第三步给迷你 function 升级「小对象优化」进阶可选把第 4.3 节的成员换成// 需要一个不依赖具体类型的小对象容器 struct Invoker { virtual ~Invoker() default; virtual void invoke(int x) const 0; virtual Invoker* cloneTo(void* buffer) const 0; // 拷贝到指定缓冲区 }; template typename Callable struct InvokerImpl : Invoker { // ... 同上但 cloneTo 用 placement new 构造到 buffer Invoker* cloneTo(void* buffer) const override { return new (buffer) InvokerImplCallable(c_); } };配合 alignas(std::max_align_t) char buffer_[16]; 和 bool inPlace_; 两个成员就能实现小对象直接存、大对象堆分配。这一步留给读者当练习写完后你会对 std::function 内部结构有肌肉记忆。5. 生产级开源方案标准库三剑客 三个知名开源库手写版明白了原理生产环境请直接用现成的。除了标准库还有多个优秀开源实现各有特色。5.1 标准库三剑客零依赖首选工具擦除的是什么典型用法注意事项std::functionR(Args...)可调用性callable回调、事件、命令模式可能堆分配std::functionvoid() 无法判断是否为空 lambdastd::any任意类型any type异质容器、插件参数传递any_cast 类型不对抛 bad_any_cast小心拷贝开销std::variantA,B,C类型集合中的一个判别联合状态机、解析器 AST必须在编译期枚举所有类型不是任意// 三剑客一键体验 #include functional #include any #include variant #include iostream int main() { // 1) function擦除可调用性 std::functiondouble(double) f [](double x) { return x * x; }; std::cout f(3) f(3) \n; // 2) any擦除任意类型 std::any a std::vectorint{1, 2, 3}; auto vec std::any_caststd::vectorint(a); // 类型错会抛异常 std::cout any size vec.size() \n; // 3) variant编译期已知的多选一 std::variantint, double, std::string v hi; std::cout variant holds string std::holds_alternativestd::string(v) \n; return 0; }5.2 开源库对比boost::type_erasure / dyno / te特性boost::type_erasuredynotetiny erasure作者/维护方Boost 官方社区Louis DionneBoost.Hana 作者Jonathan MüllerC 讲师定位完整、全能实验性、极致性能极简、教学友好定义接口方式anyCallable(_1) 声明式dyno::poly 概念te::function / te::poly是否需要 RTTI可关可关可关小对象优化有可配有有头文件数量庞大几十个头文件十几个1 个头文件学习成本高中低典型场景企业级大项目性能敏感、模板库内部快速原型、教学、小项目用 te 写一个可打印的擦除接口完整可编译// te 库只有 1 个头文件https://github.com/boost-ext/te #include te.hpp #include iostream #include string struct Drawable { // 声明式定义接口任何有 draw(std::ostream) const 行为的类型都满足 void draw(std::ostream out) const { te::call([](auto const self, std::ostream out) { self.draw(out); }, *this, out); } }; struct Circle { void draw(std::ostream out) const { out circle; } }; struct Square { void draw(std::ostream out) const { out square; } }; int main() { te::polyDrawable p1 Circle{}; te::polyDrawable p2 Square{}; p1.draw(std::cout); // circle std::cout \n; p2.draw(std::cout); // square std::cout \n; return 0; }怎么选一句话结论标准库够用 → 别折腾std::function / std::any 走天下想要声明式接口 企业级成熟度 → boost::type_erasure想要性能极致、愿意写概念concept → dyno想快速理解/教学/轻量使用 → te1 个头文件拷进项目就能跑。6. 使用优点 使用场景总结重点速览使用优点什么时候该用解耦依赖调用方只依赖外壳类型不依赖具体实现类std::function 是回调解耦的王者。异质容器把不同具体类型装进同一个 vector / map实现插件化、事件总线、观察者模式。加速编译把模板实现藏进 .cpp头文件只暴露外壳减少模板爆炸template bloat。ABI 稳定跨模块、跨 DLL/SO 边界传递参数时外壳类型在二进制层稳定降低重编译成本。运行时多态但不强制继承第三方类型无需继承你的接口拿来即用对库作者极其友好。用场景照着抄作业场景用哪个示例回调 / 事件 / 定时器std::functionGUI 按钮点击回调、线程池任务任意参数传递std::any插件系统传参数、属性表状态机 / 可选值std::variant解析器 token、HTTP 响应体命令模式 / 撤销栈std::function vector编辑器操作栈跨 DLL 接口std::function / 自定义 erasure插件加载器框架对外 API 设计te / dyno库作者不想污染用户继承体系性能敏感多态手写 SBO erasure / dyno游戏引擎 ECS 组件什么时候别用同样重要❌ 内层循环里高频调用且虚调用成为瓶颈 → 用模板 if constexpr 编译期分派❌ 只需要一种多态形态且你能控制所有类型 → 普通虚继承更简单❌ 拷贝频繁且对象巨大 → std::any 每次拷贝都是深拷贝谨慎。7. 手写版 vs 标准库 vs 开源库一张对比表维度手写版本文 4 节标准库function/any开源库te/dyno代码量60~100 行0 行直接用0 行引入头文件功能完整度基础完整、带异常安全声明式、可裁剪性能无 SBO较慢SBO 高度优化SBO 可关 RTTI学习价值★★★★★★★★★★★★生产可用性不建议直接用强烈推荐视项目而定8. 常见问题速查表FAQ问题一句话答案补充类型擦除和普通虚函数有什么区别虚函数要求你的类继承基类类型擦除不要求类型擦除内部也是虚函数但翻译官是模板自动生成的std::function 为什么比裸函数指针慢多了虚函数间接调用 可能堆分配小对象优化后小 lambda 只在栈上std::any 能放进引用吗不能直接放需放 std::reference_wrapperTstd::any 要求可拷贝引用不可拷贝std::any_cast 类型不对会怎样传指针返回 nullptr传引用抛 std::bad_any_cast两种用法行为不同别记混类型擦除能用 sizeof(T) 判断内部类型吗不能外壳大小固定想查类型用 any.type() typeidlambda 存进 std::function 后typeid 还能区分吗std::function 不提供类型查询接口需要时用 std::any 包一层多个 lambda 同签名std::function 容器能区分谁是谁吗不能它们被擦除为同一种可调用对象想区分就自己包一层 id跨 DLL 传 std::function 安全吗前提两侧用同一 ABI同编译器/同设置MSVC 的 /MD vs /MT 混用会炸类型擦除能不能避免虚函数开销不能完全避免但 SBO 关闭 RTTI 可降低dyno 在这块做到极致手写迷你版可以直接上生产吗不建议缺异常安全、缺 SBO、缺空状态检查9. 结尾一张图记住全篇具体类型 T ──模板实例化──▶ VTableImplT翻译官 │ 继承自 ▼ VTable操作说明卡虚函数 ▲ 唯一成员指针 用户看到的固定外壳 ────────────┘ std::function / std::any / te::poly / 你手写的 MiniAny类型擦除不是魔法它是模板负责生成翻译官 虚函数负责运行时路由 外壳负责统一类型的组合拳。理解了这三者的分工std::function、std::any 在你眼里就不再是黑盒你甚至能根据自己的场景写出更轻量的版本。下一步练手建议给迷你 function 加上小对象优化第 4.4 节给迷你 any 加上空状态MiniAny() 默认构造用 te 库实现一个可比较 可打印的擦除接口感受声明式接口的爽感。如果本文对你有帮助欢迎点赞收藏评论区聊聊你被 std::function 或 std::any 坑过的经历。