
前言类型擦除type erasure指的是这样一件事对外暴露一个统一的、不依赖具体类型的接口而把到底是什么类型藏到内部等到真正调用时再通过一层间接跳转找回去。std::function、std::any、std::shared_ptr的删除器底层都是这个思路。关于它常见的误解有两个类型擦除就是虚函数。虚函数是实现手段之一但类型擦除的核心是用模板把具体类型收进来再用一层间接调用把它藏起来。少了模板这一半std::function就没法接受任意可调用对象。用了类型擦除就没有开销。恰恰相反类型擦除换来的是运行期的灵活性代价是每一次调用都变成间接调用编译器再也无法内联通常还会附带一次堆分配。本文用四个章节把这件事讲透为什么需要它、经典的继承 模板适配器怎么写、标准库提供了哪些现成的、以及代价到底花在哪里。示例以 C17 为基准在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译。一、类型擦除要解决的矛盾C 是静态类型语言容器是同质homogeneous的std::vectorint只能装int。但现实需求经常是异质heterogeneous的struct Circle { double r; }; struct Square { double side; }; struct Triangle { double a, b; }; std::vector??? shapes; // 该填什么Circle、Square、Triangle之间没有继承关系也不可能用std::variant表达未来别人新加的图形——std::variant是封闭集合加一个类型就得改所有用到它的地方。类型擦除给出的答案是定义一个什么都装得下的统一外壳比如叫AnyShape然后std::vectorAnyShape shapes;而AnyShape内部的实现套路是固定的三步用一个模板构造函数把任意具体类型 T 收进来用一个内部模板类ModelT把它保存起来让ModelT继承一个非模板的抽象基类Concept把这个类型支持哪些操作用虚函数声明出来。模板负责进来时不丢类型信息虚函数负责出去时不需要知道类型。这两者缺一不可——只有虚函数就是普通的多态只有模板就是编译期展开。二、经典实现继承 模板适配器// any_shape.cpp —— g -stdc17 -Wall -Wextra any_shape.cpp -o any_shape #include iostream #include memory #include type_traits #include utility #include vector // ---------- 被擦除的具体类型以及它们各自的接口 ---------- // 这两个 draw 必须声明在 AnyShape 之前ModelT::render_ 里的调用 // 是依赖型调用定义点可见的重载集会参与解析。 struct Circle { double r; }; void draw(const Circle c, std::ostream os) { os Circle(r c.r )\n; } struct Square { double side; }; void draw(const Square s, std::ostream os) { os Square(side s.side )\n; } // ---------- 类型擦除的万能外壳 ---------- class AnyShape { public: // 把任意 T 收进来用 enable_if 把 AnyShape 自身排除掉 // 否则 AnyShape 的拷贝会被这个模板抢走导致无限递归 template typename T, typename std::enable_if_t !std::is_same_vstd::decay_tT, AnyShape AnyShape(T x) : self_(std::make_sharedModelstd::decay_tT(std::forwardT(x))) {} void render(std::ostream os) const { self_-render_(os); } private: struct Concept { virtual ~Concept() default; virtual void render_(std::ostream) const 0; }; template typename T struct Model final : Concept { explicit Model(T x) : data_(std::move(x)) {} void render_(std::ostream os) const override { draw(data_, os); } T data_; }; std::shared_ptrconst Concept self_; }; int main() { std::vectorAnyShape shapes; shapes.emplace_back(Circle{1.0}); shapes.emplace_back(Square{2.0}); shapes.emplace_back(Circle{3.5}); for (const AnyShape s : shapes) { s.render(std::cout); } }注意上面render_里调用的是自由函数draw而外壳自己的成员叫render。这个命名不是随意的——见常见坑点第 2 条。这段代码里有几个必须逐行核对的设计点Model必须是模板。它是唯一知道T是什么的地方也是唯一能把T和Concept这对互不相识的类型粘起来的地方。self_是std::shared_ptrconst Concept。用const是因为render是 const 成员函数用shared_ptr是因为AnyShape要被放进std::vector需要可拷贝。代价是拷贝AnyShape得到的两个对象共享同一份内部数据改一个会看到另一个也变——这是引用语义不是值语义。enable_if那一行不能省。模板构造函数是贪心的如果不排除AnyShape自身AnyShape b a;a是非 const 左值会优先匹配T AnyShape的模板版本而不是拷贝构造函数于是ModelAnyShape里又存了一个AnyShape递归下去直到编译器报错。这属于标准的重载决议规则不是编译器 bug。std::decay_tT而不是T。T可能是Circle或const Circle用decay_t统一成按值的Circle否则std::vectorconst Circle这类非法类型会被实例化出来。如果确实需要值语义拷贝之后互不影响把shared_ptr换成unique_ptr并在Concept里加一个clone_虚函数virtual std::unique_ptrConcept clone_() const 0;ModelT里返回std::make_uniqueModelT(*this)这要求 T 可拷贝构造。此时AnyShape要自己写拷贝构造和拷贝赋值因为unique_ptr成员把默认版本删掉了。三、标准库已经提供了三种现成的类型擦除不用每次都手写。标准库里有三个设施擦除的东西各不相同设施擦除了什么保留了什么引入版本std::functionSig可调用对象的具体类型调用签名SigC11std::any值的具体类型什么都不保留取值时按类型校验C17std::shared_ptrvoid删除器所知道的类型什么接口都不保留只能靠static_pointer_cast找回C11std::variant不在这里面——它是封闭集合的联合体不是类型擦除。判断标准很简单能不能在不动用它的代码的前提下加一个新类型类型擦除可以std::variant不行。// stdlib_erasure.cpp —— g -stdc17 -Wall -Wextra stdlib_erasure.cpp -o stdlib_erasure #include any #include functional #include iostream #include string #include vector int main() { // ---- std::function擦除可调用对象的类型保留 int(int) 这个签名 ---- std::vectorstd::functionint(int) ops; ops.push_back([](int x) { return x 1; }); ops.push_back([](int x) { return x * 2; }); const int v 3; ops.push_back([v](int x) { return x v; }); // 带捕获的 lambda 也能存 int sum 0; for (const std::functionint(int) f : ops) { sum f(10); // 11 20 13 } std::cout sum \n; // 44 // ---- std::any擦除值的类型取出时必须给出正确的类型 ---- std::any a std::string(hello); a 42; std::cout std::any_castint(a) \n; // 42 if (const std::string* p std::any_caststd::string(a)) { std::cout *p \n; } else { std::cout 当前不是 string\n; } try { std::cout std::any_caststd::string(a) \n; } catch (const std::bad_any_cast e) { std::cout bad_any_cast: e.what() \n; } }std::any_cast有两套重载行为完全不同这一点必须分清形式返回类型不匹配时std::any_castT(a)T按值/引用抛std::bad_any_caststd::any_castT(a)const T*/T*返回空指针不抛异常四、代价花在哪里类型擦除不是免费的。把它和模板直接展开对比一下就清楚了维度模板编译期多态类型擦除运行期多态具体类型在何时确定编译期运行期调用形式直接调用可内联虚函数 / 函数指针不可内联能否放进同质容器❌ 每个类型一个容器✅二进制体积每种类型一份实例化每种类型一份ModelT但调用点只有一份目标文件里能否只留声明不行模板要可见可以接口部分完全隐藏典型开销零运行时开销一次间接跳转可能有一次堆分配std::function还有一个容易忽略的成本小对象优化small buffer optimization, SBO。各家 STL 都内置了一个小的本地缓冲区装得下就直接放在std::function对象内部装不下才new一块。以 libstdc 为例这个缓冲是 16 字节——一个捕获了两个double的 lambda 放得下一个捕获了四个double的就放不下。这个阈值是实现细节标准没有任何规定所以不要写依赖它的代码同一个 lambda 在 GCC 和 MSVC 上是否触发堆分配可能不一样。std::any同理它一定需要一次堆分配来放void*指向的值除非值小到能放进内部缓冲同样是实现细节。常见坑点#❌ 错误写法✅ 正确写法1模板构造函数不加约束template typename T AnyShape(T x);加std::enable_if_t!std::is_same_vstd::decay_tT, AnyShape否则拷贝会被模板抢走并无限递归2外壳的成员函数与被擦除类型的自由函数同名void draw(std::ostream)配自由函数draw外层类作用域的名字会隐藏全局名字改成不同的名字如外壳用render、自由函数用draw让 ADL 正常工作3把ModelT的定义写在.cpp里头文件只有ConceptModel是模板必须和AnyShape一起放在头文件里否则实例化不出来链接期报 undefined4用std::function存 move-only 的可调用对象例如捕获了std::unique_ptr的 lambdastd::function要求可调用对象可拷贝构造用std::shared_ptr包一层或者等 C23 的std::move_only_function5std::shared_ptrvoid p(new int(42));默认删除器会对void*做delete这是 UB先auto pi std::make_sharedint(42);再std::shared_ptrvoid p pi;控制块里保存的才是正确的删除器6认为类型擦除只是多了一次虚函数调用每次调用都不可内联std::function/std::any还可能有一次堆分配SBO 阈值是实现细节7std::any a hello;然后std::any_caststd::string(a)存进去的类型是const char*而不是std::string取std::string会抛bad_any_cast显式写std::any a std::string(hello);8拷贝AnyShape之后以为得到了一份独立的数据内部是shared_ptr拷贝是共享要值语义就把Concept换成unique_ptr加clone_虚函数第 4 条值得单独展开一下因为它是最常见的编译不过来源#include functional #include memory int main() { // ❌ 编译错误std::function 要求目标可拷贝构造 // std::functionvoid() f [p std::make_uniqueint(1)]() { (void)p; }; // ✅ 用 shared_ptr 把不可拷贝变成可拷贝 std::functionvoid() g [p std::make_sharedint(1)]() { (void)*p; }; g(); }原因是std::function的构造函数对可调用对象有CopyConstructible的要求见标准 [func.wrap.func.con]因为它必须能复制自己内部的这份目标对象。shared_ptr是可拷贝的包一层就绕过去了——代价是引用计数带来的原子操作开销。总结场景推荐手段理由需要逆序调用、反转控制且调用签名固定std::function现成、签名参数化、支持带捕获的 lambda需要在容器里放任意类型的值取出时才判断类型std::anyany_cast现成类型不匹配有明确的失败信号备选类型集合固定、且希望编译期检查穷尽std::variantstd::visit不是类型擦除但更安全忘写分支会编译报错需要对外隐藏实现、又不想暴露虚函数表手写继承 模板适配器头文件只暴露外壳二进制接口稳定只需要擦除怎么释放这件事std::shared_ptrvoid极轻量但不保留任何接口类型擦除的本质是用一层间接调用把编译期已知的具体类型兑换成运行期才行确定的多态行为。手写实现时记牢三步模板构造函数收进来、ModelT存下来、抽象Concept暴露接口而最常踩的两颗雷一个是模板构造函数不加enable_if约束另一个是外壳成员函数名把被擦除类型的自由函数名给隐藏了。能选中标准库现成设施的时候就不要手写——它们的边界情况异常安全、SBO、空目标状态比想象中多得多。