C++类型擦除详解:从手写实现到std::function与std::any

发布时间:2026/10/10 17:28:26
C++类型擦除详解:从手写实现到std::function与std::any 先抛一个场景你在写一个事件分发器需要把不同模块的回调函数存进同一个容器里或者你在做插件系统需要让用户传入任意自定义类型而你完全不希望这些类型必须继承你的某个基类又或者你只是想让一个容器同时装下圆形正方形自定义图形这些完全不相干的类型。这些需求有一个共同的落点——C 中的类型擦除type erasure。类型擦除不是什么黑魔法它是一个实实在在的 C 惯用法核心就一句话把编译期已知的具体类型在运行期变成一个统一的、可操作的抽象入口。标准库里你已经无数次用过它std::function擦除了可调用对象的类型std::any擦除了任意值的类型std::shared_ptr的删除器也是被擦掉的。很多人每天都在用却说不太清背后的机制。这篇文章就一层层把这层窗户纸捅破从手写一个最小实现开始到标准库的真实样本再到性能账本和进阶选型尽量让每个细节都能落地。1. 从跨类型调用到擦除这类需求到底在解决什么1.1 三个典型场景表面不相关内核一致第一个场景是我猜你最早遇到std::function的原因写一个按钮类或者定时器用户需要注册回调。回调可能是普通函数、Lambda、成员函数绑定器它们的类型各不相同。用户想要的只是一个能调用、能拷贝、能存储的统一样式。你不可能为每种回调类型都写一个按钮类于是std::functionvoid()出场。第二个场景是异构容器。你在做渲染器场景里既有点、线、三角网格又有自定义的球体。你想把它们统一塞进一个std::vector逐帧调用draw()。如果所有图形都必须继承Shape基类那用户写新图形就要被你绑架如果把它们放进std::vectorstd::any你又没法直接在任何元素上调用draw()。你需要的是保留接口、隐藏类型——这正是类型擦除。第三个场景是接口解耦。你在写一个库内部实现细节不想暴露给头文件于是你写出 Pimpl 惯用法。Pimpl 本质上也是一种粗粒度的类型擦除对外只暴露一个指向实现概念的不透明指针具体实现类被藏进.cpp里。这三个场景的共同内核是调用方只知道能做什么不知道具体是什么。模板也隐藏类型但模板在编译期就把类型钉死了类型擦除把这一决策推迟到运行期。1.2 模板在这里为什么不够用很多人会说用模板不就行了——模板确实能做到编译期多态但它有一个硬约束所有具体类型必须在编译期全部确定。异构容器的问题就在于编译期你不知道用户会在运行期往容器里塞多少个种类的对象。就算你知道模板也无法让两个不同类型共享一段存储因为sizeof(T)不同、拷贝方式不同、生命周期管理不同这些信息在编译期对模板是不可统一的。用生活化的比喻模板像是你先在纸上写好了呼叫某个人的电话号码清单每个人的号码都列在上面编译期就完成了拨号准备类型擦除则是你手头只有一部电话和一个能接电话的人的名字运行期按名字找人找到谁就拨给谁。名字就是那个抽象接口人就是被擦除的具体类型。1.3 类型擦除的本质定义在 C 语境下类型擦除可以这样定义设计一个非模板的公共接口类再用一个模板包装类把任意具体类型适配到这个接口上最后用一个持有包装类的值语义外壳对外提供服务。运行期调用时通过虚函数或函数指针完成分发真实类型对调用方不可见。这个定义包含三层意思。第一擦除不是消灭类型信息而是把类型信息从编译期静态绑定变为运行期动态分发。第二擦除必定产生间接调用这是它的固有成本。第三擦除通常要求具体类型满足某些约束可拷贝、可移动、具备某种操作约束通过编译期模板实例化来检查。2. 手写一个最小类型擦除器理解三个角色的桥接结构2.1 概念类、模型类与擦除壳很多资料讲类型擦除时爱用Concept / Model / Any这套词其实就是三个角色一个抽象接口Concept一个模板适配器Model一个外层值语义壳Any。我直接用一个可绘制图形的例子把这个结构写出来#include memory #include utility // 角色一概念类Concept擦除后统一走这里 class DrawableConcept { public: virtual ~DrawableConcept() default; virtual void draw() const 0; }; // 角色二模型类Model模板负责桥接具体类型 template typename T class DrawableModel final : public DrawableConcept { T obj_; public: explicit DrawableModel(T obj) : obj_(std::move(obj)) {} void draw() const override { obj_.draw(); } }; // 角色三擦除壳ErasedDrawable对外提供值语义 class ErasedDrawable { std::unique_ptrDrawableConcept impl_; public: template typename T ErasedDrawable(T obj) : impl_(std::make_uniqueDrawableModelT(std::move(obj))) {} void draw() const { impl_-draw(); } };用法非常直观struct Circle { void draw() const { std::puts(draw circle); } }; struct CustomShape { void draw() const { std::puts(draw custom); } }; int main() { ErasedDrawable shapes[] { ErasedDrawable(Circle{}), ErasedDrawable(CustomShape{}) }; for (auto const s : shapes) s.draw(); }这里有一个非常关键的设计直觉模板构造函数 ErasedDrawable(T) 是类型擦除的入口。编译期当你传入Circle时编译器会自动实例化出DrawableModelCircle把这个派生类的指针存进unique_ptrDrawableConcept。之后你再也不关心Circle的存在调用draw()时只走虚函数表。擦除发生在构造那一刻擦除之后的世界里只有一个统一接口。2.2 拷贝与析构类型擦除最容易翻车的地方上面的代码足够跑通但它没有拷贝语义。unique_ptr是移动独占的这意味着ErasedDrawable不能被拷贝。现实里std::function、std::any都是值语义、可拷贝的所以我们要补拷贝控制。析构相对简单unique_ptr加上虚析构函数就完成了关键是拷贝DrawableConcept里的clone()必须是一个虚函数因为虚函数不能模板化而虚函数表里恰恰需要一条不知道具体类型也能复制的通道class DrawableConcept { public: virtual ~DrawableConcept() default; virtual void draw() const 0; virtual std::unique_ptrDrawableConcept clone() const 0; }; template typename T class DrawableModel final : public DrawableConcept { T obj_; public: explicit DrawableModel(T obj) : obj_(std::move(obj)) {} void draw() const override { obj_.draw(); } std::unique_ptrDrawableConcept clone() const override { return std::make_uniqueDrawableModelT(obj_); } }; class ErasedDrawable { std::unique_ptrDrawableConcept impl_; public: template typename T ErasedDrawable(T obj) : impl_(std::make_uniqueDrawableModelT(std::move(obj))) {} ErasedDrawable(const ErasedDrawable other) : impl_(other.impl_ ? other.impl_-clone() : nullptr) {} ErasedDrawable operator(const ErasedDrawable other) { if (this ! other) impl_ other.impl_-clone(); return *this; } ErasedDrawable(ErasedDrawable) noexcept default; ErasedDrawable operator(ErasedDrawable) noexcept default; void draw() const { impl_-draw(); } };这里有两个坑值得单独说。第一个坑是clone()的返回类型必须是unique_ptrDrawableConcept而不是DrawableConcept*用裸指针你很容易在拷贝构造的异常路径上泄漏内存用智能指针则天然安全。第二个坑是拷贝和移动的优先级有了自定义拷贝构造之后编译器不会隐式生成移动构造所以一定要显式 default移动操作否则你的擦除壳会退化成拷贝一切把本来可以高效率移动的对象也复制一遍。2.3 为什么这个模式是桥接而不是装饰把 Concept 理解为抽象协议Model 理解为翻译器Any 理解为门面你会发现这和设计模式里的桥接模式高度一致。区别在于模板的参与让 Model 可以无限扩展新类型只要实现了draw()就能被擦除不需要改任何既有代码。这就是开闭原则在运行期多态上的体现对扩展打开对修改关闭。不过要注意一个边界擦除壳的接口是固定的。你这会儿定义了draw()以后想要area()就得改DrawableConcept而这会破坏所有已有的 Model。这恰恰是后面第 5 节要讨论的进阶问题。3. 标准库里的现实样本std::function、std::any 与 shared_ptr 的擦除方式3.1 std::function擦除的是签名收纳方式而非签名本身std::functionR(Args...)擦除的是可调用对象的类型但保留了调用签名。你存进去一个int(int, int)的 Lambda和一个int(int, int)的函数指针从外面看它俩都是std::functionint(int,int)。调用时通过内部的调用器间接执行签名在模板参数里是确定的所以不存在运行时签名不匹配的问题。主流标准库实现对std::function通常不是简单地在里面放个 vtable而是采用函数指针表的方式对象里存一个调用器指针和一个管理器指针调用器负责执行管理器负责拷贝、销毁、移动。这种做法的好处是避免了虚函数的类布局开销也让小对象优化更容易实现——很多实现会把不超过某个字节数常见是 16 或 32 字节的可调用对象直接放进std::function内部缓存只有更大的对象才走堆分配。还有一个细节值得注意空的std::function调用会抛std::bad_function_call。也就是说擦除壳有一个空状态需要显式处理。我们在手写版本里用nullptr表示空但没加调用时的空检查真实代码里应该补上对这个状态的判断否则崩溃后排查起来会比较绕。3.2 std::any不用虚函数也能擦除std::any的野心更大它擦除任意可拷贝类型但没有draw()这样的公共接口它的接口就是你主动用any_castT把类型取回来。实现上它甚至不需要虚函数而是用一个函数指针表记录如何拷贝、如何销毁、这个类型是什么再配合std::typeid做类型比对。一个极简的迷你实现思路可以这样写class Any { void* data_ nullptr; // manager 是一组无类型函数指针 void (*destroy_)(void*) nullptr; void* (*clone_)(const void*) nullptr; const std::type_info* type_ nullptr; public: template typename T Any(T val) { using U std::decay_tT; data_ new U(std::forwardT(val)); destroy_ [](void* p) { delete static_castU*(p); }; clone_ [](const void* p) - void* { return new U(*static_castconst U*(p)); }; type_ typeid(U); } template typename T T cast() { using U std::decay_tT; if (type_ ! typeid(U)) throw std::bad_cast(); return *static_castU*(data_); } };当然真实标准库用内存池、SBO、异常安全等做了大量加固但核心逻辑就是这个样子。理解这一点对你有两个实际帮助第一以后你看std::any的汇编或性能报告不会发懵第二你自己写自定义擦除时可以灵活选择虚函数方案和函数指针表方案两者没有绝对优劣函数指针表在类型较多时缓存更友好虚函数方案代码更直白。3.3 std::shared_ptr只擦除删除器保留静态类型std::shared_ptr的类型擦除比较隐蔽。当你写std::shared_ptrBase p std::make_sharedDerived();的时候shared_ptr的静态类型是Base但控制块里的删除器知道真实类型是Derived。将来引用计数归零时控制块会调用那个被擦掉的删除器正确销毁Derived对象。这给我们的启发是类型擦除也可以只擦除生命周期管理信息而不是擦除所有接口。当你需要的只是统一管理不同派生类对象的生存期时不需要大动干戈设计 Concept/Modelshared_ptr已经替你擦掉了删除器。反过来如果你发现自己写的擦除壳本质上只是为了存一堆能析构的东西那很可能应该改用shared_ptrvoid或把具体对象直接放进变体容器而不是造一个轮子。4. 性能账本一次擦除调用到底花了多少钱4.1 手工加一个小对象优化SBO我们手写的ErasedDrawable每次构造都会make_unique一次哪怕Circle只有一个空指针的大小。高频构造时堆分配的代价肉眼可见。这时就该上小对象优化Small Buffer OptimizationSBO如果对象足够小就存在擦除壳内部超过阈值再走堆。思路不复杂用一块对齐的原始内存当缓冲区加一个布尔位标记当前是否在堆上#include cstddef #include memory #include new #include type_traits class ErasedDrawable { alignas(std::max_align_t) unsigned char buf_[32]; bool on_heap_ false; DrawableConcept* impl() { return reinterpret_castDrawableConcept*(buf_); } const DrawableConcept* impl() const { return reinterpret_castconst DrawableConcept*(buf_); } public: template typename T ErasedDrawable(T obj) : buf_{}, on_heap_(false) { using U std::decay_tT; static_assert(std::is_copy_constructible_vU); if (sizeof(DrawableModelU) sizeof(buf_)) { new (buf_) DrawableModelU(std::move(obj)); } else { on_heap_ true; new (buf_) DrawableConcept*(new DrawableModelU(std::move(obj))); } } // 析构、拷贝、移动都要同时处理栈上堆上两种情况 };这一段我只写了构造方向完整实现里析构需要根据on_heap_决定调用栈上析构还是delete堆指针拷贝和移动也要分别对两种存储形态处理。SBO 的实现细节相当烦琐所以我的建议是生产代码优先用标准库现成的擦除方案自己实现 SBO 主要用于学习或者确实有极致的性能要求。这里有个很关键的取舍SBO 的缓冲区大小设定为多少设得小大部分自定义类型都溢出到堆设得大ErasedDrawable自身体积膨胀存进std::vector时每个元素都占用大块内存缓存友好性反而下降。主流实现一般取 16~32 字节你可以按最常见的一两种类型能装下为标准来调整。4.2 调用开销的实际拆解一次类型擦除调用拆开看是这些成本环节成本来源典型量级间接调用虚函数表跳转或函数指针跳转几条指令但可能打断分支预测丢失内联被调函数无法在调用点展开内联可能 5~50 倍差距取决于函数体大小构造开销模板实例化 SBO 判断 / 堆分配小对象几纳秒大对象含 malloc拷贝开销clone 虚函数 递归拷贝取决于内部数据结构空状态检查额外分支通常可忽略其中丢失内联通常才是最伤的一刀。同样一个void draw()里只有一行puts直接调用可以被完全内联成一条函数调用经过擦除壳间接调用后编译器无法推测真实函数体只能老老实实跳转。如果你的绘制循环在一帧里调用几十万次draw()这部分差距会被放大。4.3 什么时候应该主动放弃类型擦除基于上面的成本账我列几条实战判断准则类型在编译期完全可枚举比如你的图形类型就固定三种优先用std::variant加std::visit没有间接调用没有堆分配性能接近手写 switch。调用发生在一个极热循环并且类型稳定用模板直接静态分发哪怕是靠继承基类引用也比擦除更可控因为至少虚函数调用比两重间接少一层。接口很大、方法很多擦除壳要维护的虚函数越多概念类越臃肿每添加一个接口都要同步改所有 Model。这种场景下考虑用具体接口拆分或者干脆用继承体系。类型擦除的价值在于调用方无需知道类型但又能获得运行期灵活性。如果这个灵活性根本不需要那它就是你浪费在路上的钱。5. 进阶形态多接口、类型恢复与设计选型5.1 多接口擦除与最小接口原则实际项目里只擦除一个draw()的情况很少。你可能同时需要draw()和area()这时有两个选择把两个接口都塞进同一个概念类或者拆成多个独立的擦除接口。塞进同一个概念类class ShapeConcept { public: virtual ~ShapeConcept() default; virtual void draw() const 0; virtual double area() const 0; };这看起来简单但有个隐患一个具体类型可能只实现了draw()不代表它一定算得出面积。如果你的擦除接口绑定了两个本质上独立的操作你就逼迫所有用户为不关心的接口提供实现哪怕只是抛个异常。这就是为什么我推崇最小接口原则每个擦除壳只擦除一个行为维度。需要多个行为时用组合而不是继承去满足。比如你既需要能绘制又需要可序列化那就让对象同时满足两个概念分别交给ErasedDrawable和ErasedSerializable两个壳管理而不是强行合并成一个万能接口。5.2 把擦除的类型再收回来typeid 与安全的反擦除有些场景下擦除之后你还需要在运行期拿到真实类型。最典型的例子是std::any配合any_cast。在自己的类型擦除器里加一个类型查询接口并不难给概念类加一个返回std::type_info的虚函数class DrawableConcept { public: virtual ~DrawableConcept() default; virtual void draw() const 0; virtual const std::type_info type() const noexcept 0; virtual std::unique_ptrDrawableConcept clone() const 0; }; template typename T class DrawableModel final : public DrawableConcept { T obj_; public: explicit DrawableModel(T obj) : obj_(std::move(obj)) {} void draw() const override { obj_.draw(); } const std::type_info type() const noexcept override { return typeid(T); } std::unique_ptrDrawableConcept clone() const override { return std::make_uniqueDrawableModelT(obj_); } };然后在外壳上做一个安全的转换接口template typename T T* as() noexcept { auto* p impl_.get(); return p p-type() typeid(T) ? static_castDrawableModelT*(p)-get() : nullptr; }注意这里不能用dynamic_cast因为DrawableModelT的类型在擦除壳的视角里是未知的dynamic_cast帮不上忙只能靠typeid比对。反擦除本质上是把类型信息重新注入所以它是有安全检查的这比 C 风格的void*强转安全得多代价是每次都要比对type_info。5.3 三种运行时多态方案怎么选继承、擦除还是模板到了选型环节我习惯把方案按类型是否运行期可变和是否需要用户继承两个维度来分。下面这张表是我在实际项目里反复用过的判断框架方案类型集合是否运行期可扩展是否强制用户继承调用开销类型恢复难度虚函数继承是是一次虚函数跳转用dynamic_cast类型擦除是否只需满足接口约束一次到两次间接调用用typeid模板/concepts否编译期固定否可内联零间接不需要std::variant否编译期固定集合否visit接近索引跳转天然保真我的经验是类型集合运行期可扩展时优先考虑类型擦除因为用户不需要继承你的基类只需让自己的类型满足某个菜谱比如有draw()成员函数这对库的侵入性最小。类型集合编译期固定时std::variant和std::visit往往比擦除更简单、更快。至于虚函数继承适合你完全控制整套类型体系、又需要长期稳定扩展的场景比如框架内部的抽象层。我自己在实际项目里的体会是类型擦除最怕的不是性能而是接口膨胀。每次往概念类里加一个虚函数本质上你都在扩大对用户的隐形要求。如果你把擦除壳当作一个产品的公共 API 来设计用最小接口 组合多个擦除壳的方式构建而不是做一个万能上帝对象整个设计会清爽非常多。最后再分享一个小技巧如果你发现自己总是写出一个壳 一堆转发函数不妨反过来想想是不是该直接用std::function或std::any组合出你需要的语义——标准库把这些细节打磨得很成熟在它够用的时候自己造轮子往往是拿维护成本换一点点微薄的自由度。类型擦除是一门知道什么时候不擦的手艺这比学会怎么擦更重要。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询