# C++设计模式:Type Erasure 模式——把类型藏起来,把能力留下来 ## 引言 在 C++ 项目里,我们经常会遇到这样一种需求:**调用方并不关心对象的具体类型,只关心它能不能完成某个动作**。 比如一个任务调度器,只希望接收一批“可执行任务”: ```cpp #include #include #include struct EmailTask { void run() const { std::cout << "发送邮件\n"; } }; struct ReportTask { void run() const { std::cout << "生成报表\n"; } }; int main() { std::vector tasks; tasks.push_back(EmailTask{}); // tasks.push_back(ReportTask{}); // 编译错误:vector 只能保存同一种具体类型 } ``` **问题 1:模板很灵活,但容器需要统一类型** 如果用模板函数,单个任务当然可以执行: ```cpp template void execute(const Task& task) { task.run(); } execute(EmailTask{}); execute(ReportTask{}); ``` 但模板函数解决的是“某一次调用”的泛型问题,不解决“把不同类型放进同一个容器”的问题。 ``` std::vector tasks; ``` 这里必须填一个统一类型,而 `EmailTask` 和 `ReportTask` 是两个完全不同的类型。 **问题 2:继承能统一类型,但会强迫所有类服从同一个基类** 传统面向对象会这样写: ```cpp class ITask { public: virtual void run() const = 0; virtual ~ITask() = default; }; class EmailTask : public ITask { public: void run() const override { std::cout << "发送邮件\n"; } }; ``` 这样当然能工作,但它带来了新的约束: - • 所有任务类都必须继承 `ITask` - • 第三方类型无法直接纳入体系 - • 类型被迫暴露在同一套继承结构中 - • 很多轻量函数对象、lambda、小对象不值得专门写一个派生类 **问题 3:`void\*` 很灵活,但彻底丢掉类型安全** 另一种极端做法是使用 `void*`: ```cpp void execute(void* task) { // 这里必须知道 task 原本是什么类型 // 一旦转换错,结果就是未定义行为 } ``` 这就像把所有快递都撕掉标签,统一塞进仓库。看起来仓库很通用,实际上一旦取件时记错内容,就会出大事故。 ### 生活类比 Type Erasure 可以理解为“统一插座”: - • 手机充电器、电脑电源、台灯插头的内部结构不同 - • 使用者并不关心里面是变压器、电路板还是电容 - • 使用者只关心:**它能不能插进这个接口,并提供电力** 也可以理解为“外卖平台”: - • 商家类型不同:面馆、咖啡店、烧烤店 - • 平台不关心商家的内部系统怎么实现 - • 平台只抽象出统一能力:接单、出餐、配送 ### 对比图 ``` 没有类型擦除: 调用方 --> EmailTask 调用方 --> ReportTask 调用方 --> BackupTask ↓ 每多一种类型,调用方就多知道一种具体实现 使用类型擦除: 调用方 --> AnyTask --> EmailTask ├──> ReportTask └──> BackupTask ↓ 调用方只认识 AnyTask,具体类型被藏在内部 ``` **Type Erasure 模式就是为解决这个问题而生的:把具体类型藏起来,只暴露稳定的行为接口。** ------ ## 模式详解 ### 模式定义 > **Type Erasure 模式(类型擦除模式)**,核心思想是:**通过一层统一包装,将不同具体类型的对象保存起来,并通过固定接口调用它们的能力,从而让调用方不依赖具体类型。** 这里的“擦除”不是指类型真的消失了。对象在运行时仍然有真实类型,只是调用方看不到它。 更准确地说,Type Erasure 擦除的是**静态接口中的具体类型信息**,保留的是**运行时调用所需的行为能力**。 ### 核心角色 ``` ┌─────────────────────┐ │ AnyTask │ 对外统一包装类型 │ run() │ └──────────┬──────────┘ │ 持有 ▼ ┌─────────────────────┐ │ Concept │ 抽象能力接口 │ virtual run() │ │ virtual clone() │ └──────────┬──────────┘ │ 继承 ▼ ┌─────────────────────┐ │ Model │ 适配具体类型 T │ T object │ │ run() -> object.run() └─────────────────────┘ ``` 1. **Wrapper(包装类)** 对外暴露统一类型,例如 `AnyTask`、`std::function`、`std::any`。 2. **Concept(概念接口)** 定义被擦除对象必须具备的能力,例如 `run()`、`clone()`、`draw()`。 3. **Model(模型适配器)** 模板类,保存真实对象,并把统一接口转发给真实对象。 4. **Concrete Type(具体类型)** 真实业务对象,可以是普通类、函数对象、lambda、第三方类型。 Type Erasure 的关键就在于:**包装类不是直接保存 T,而是保存一个指向 Concept 的指针;具体 T 被放进 Model 里。** ### 基本实现(经典版本) 先看一个不用类型擦除时的任务执行系统: ```cpp #include #include #include struct EmailTask { std::string address; void run() const { std::cout << "发送邮件给:" << address << "\n"; } }; struct ReportTask { std::string name; void run() const { std::cout << "生成报表:" << name << "\n"; } }; int main() { std::vector tasks; tasks.push_back({"dev@example.com"}); // tasks.push_back(ReportTask{"daily"}); // 编译错误 for (constauto& task : tasks) { task.run(); } } ``` 这个版本的问题很明显:`std::vector` 只能保存 `EmailTask`。 如果改成继承版本,需要所有类型都继承同一个接口: ```cpp #include #include #include #include class ITask { public: virtual void run() const = 0; virtual ~ITask() = default; }; class EmailTask : public ITask { public: explicit EmailTask(std::string address) : address_(std::move(address)) {} void run() const override { std::cout << "发送邮件给:" << address_ << "\n"; } private: std::string address_; }; class ReportTask : public ITask { public: explicit ReportTask(std::string name) : name_(std::move(name)) {} void run() const override { std::cout << "生成报表:" << name_ << "\n"; } private: std::string name_; }; int main() { std::vector> tasks; tasks.push_back(std::make_unique("dev@example.com")); tasks.push_back(std::make_unique("daily")); for (constauto& task : tasks) { task->run(); } } ``` 输出: ``` 发送邮件给:dev@example.com 生成报表:daily ``` 继承版本能工作,但它要求业务类型主动进入 `ITask` 继承体系。对于第三方类型、lambda、临时函数对象,这个要求太重。 接下来用 Type Erasure 写一个 `AnyTask`: ```cpp #include #include #include #include #include #include class AnyTask { private: struct Concept { virtual void run() const = 0; virtual std::unique_ptr clone() const = 0; virtual ~Concept() = default; }; template struct Model final : Concept { T object; explicit Model(T obj) : object(std::move(obj)) {} void run() const override { object.run(); } std::unique_ptr clone() const override { return std::make_unique>(object); } }; public: template < typename T, typename Decayed = std::decay_t, typename = std::enable_if_t> > AnyTask(T&& object) : self_(std::make_unique>(std::forward(object))) {} AnyTask(const AnyTask& other) : self_(other.self_ ? other.self_->clone() : nullptr) {} AnyTask& operator=(const AnyTask& other) { if (this != &other) { self_ = other.self_ ? other.self_->clone() : nullptr; } return *this; } AnyTask(AnyTask&&) noexcept = default; AnyTask& operator=(AnyTask&&) noexcept = default; void run() const { self_->run(); } private: std::unique_ptr self_; }; struct EmailTask { std::string address; void run() const { std::cout << "发送邮件给:" << address << "\n"; } }; struct ReportTask { std::string name; void run() const { std::cout << "生成报表:" << name << "\n"; } }; struct CleanupTask { void run() const { std::cout << "清理临时文件\n"; } }; int main() { std::vector tasks; tasks.emplace_back(EmailTask{"dev@example.com"}); tasks.emplace_back(ReportTask{"daily"}); tasks.emplace_back(CleanupTask{}); for (constauto& task : tasks) { task.run(); } } ``` 输出: ``` 发送邮件给:dev@example.com 生成报表:daily 清理临时文件 ``` 这里的关键点是: - • `AnyTask` 是调用方看到的统一类型 - • `Concept` 是内部的抽象接口 - • `Model` 保存真实类型,并把 `run()` 转发给真实对象 - • 调用方不需要知道 `EmailTask`、`ReportTask`、`CleanupTask` 的具体类型 - • 具体类型不需要继承任何基类,只要提供 `run()` 方法即可 ### 经典版本的问题 这个版本已经很好用,但仍有几个问题: 1. **每个对象都要堆分配** `std::make_unique>` 会产生一次动态分配,小对象也不例外。 2. **错误信息不够友好** 如果传入的类型没有 `run()`,错误会在模板实例化深处爆出来。 3. **接口固定** `AnyTask` 只能表达“可执行任务”,如果要表达“可绘制对象”“可序列化对象”,需要重新写包装类。 4. **拷贝语义需要显式设计** 如果要支持值语义,就必须提供 `clone()`;如果只支持移动,则可以简化实现。 ------ ## 关键技术专题:std::function 背后的类型擦除 很多人第一次实际使用 Type Erasure,其实是在使用 `std::function`: ```cpp #include #include void hello() { std::cout << "普通函数\n"; } struct Functor { void operator()() const { std::cout << "函数对象\n"; } }; int main() { std::function f1 = hello; std::function f2 = Functor{}; std::function f3 = [] { std::cout << "lambda\n"; }; f1(); f2(); f3(); } ``` 输出: ``` 普通函数 函数对象 lambda ``` `hello` 是函数指针,`Functor{}` 是函数对象,lambda 又是编译器生成的匿名类型。它们的具体类型完全不同,却都能放进 `std::function`。 这就是类型擦除。 ### `std::function` 擦除了什么 `std::function` 表达的是: ``` 我不关心你原来是什么类型。 我只关心你能不能像这样被调用: callable(int) 并且返回 void。 ``` 它擦除的是 callable 的具体类型,保留的是调用签名。 ``` 函数指针 void(*)(int) 函数对象 Logger lambda 编译器生成的匿名闭包类型 成员函数适配器 std::bind 结果类型 │ ▼ std::function │ ▼ 统一使用 operator()(int) ``` ### 一个简化版 Function 下面实现一个极简版 `Function`,帮助理解 `std::function` 的核心机制: ```cpp #include #include #include #include class Function { private: struct Concept { virtual void call() = 0; virtual std::unique_ptr clone() const = 0; virtual ~Concept() = default; }; template struct Model final : Concept { Callable callable; explicit Model(Callable c) : callable(std::move(c)) {} void call() override { callable(); } std::unique_ptr clone() const override { return std::make_unique>(callable); } }; public: Function() = default; template Function(Callable&& callable) : self_(std::make_unique>>( std::forward(callable))) {} Function(const Function& other) : self_(other.self_ ? other.self_->clone() : nullptr) {} Function& operator=(const Function& other) { if (this != &other) { self_ = other.self_ ? other.self_->clone() : nullptr; } return *this; } Function(Function&&) noexcept = default; Function& operator=(Function&&) noexcept = default; void operator()() { if (!self_) { throw std::bad_function_call{}; } self_->call(); } private: std::unique_ptr self_; }; int main() { int count = 0; Function f1 = [] { std::cout << "无状态 lambda\n"; }; Function f2 = [&count] { ++count; std::cout << "有状态 lambda,count = " << count << "\n"; }; f1(); f2(); f2(); } ``` 输出: ``` 无状态 lambda 有状态 lambda,count = 1 有状态 lambda,count = 2 ``` 这个简化版少了很多标准库细节,比如参数、返回值、小对象优化、allocator 支持、异常规范,但核心结构已经很清楚: ``` Function └── unique_ptr └── Model<具体 Callable> └── callable ``` ### 值语义与 clone 如果 Type Erasure 包装类希望像普通对象一样拷贝,就必须处理“如何拷贝一个未知类型对象”的问题。 `Concept` 不知道真实类型,不能直接写: ``` std::make_unique(*other.self_); // 错误:Concept 是抽象类 ``` 所以需要把拷贝动作下放给 `Model`: ``` std::unique_ptr clone() const override { return std::make_unique>(object); } ``` 这其实是一个虚构造函数(virtual constructor)的变体:通过虚函数在运行时复制真实对象。 如果对象不可拷贝,比如 `std::unique_ptr` 或只移动任务,可以把包装类设计成 move-only: ``` AnyTask(const AnyTask&) = delete; AnyTask& operator=(const AnyTask&) = delete; AnyTask(AnyTask&&) noexcept = default; AnyTask& operator=(AnyTask&&) noexcept = default; ``` 不要为了“看起来方便”强行支持拷贝。值语义是好东西,但前提是语义真的成立。 ### 小对象优化 经典 Type Erasure 通常有一次堆分配: ``` AnyTask 对象本身 └── 指针 --> 堆上的 Model ``` 对于很小的 callable,比如无捕获 lambda,这个堆分配显得很浪费。 标准库实现通常会使用**小对象优化(Small Object Optimization, SOO)**: ``` 小对象: std::function 内部缓冲区直接存放 callable 大对象: std::function 内部保存指针,callable 放在堆上 ``` 示意: ``` 普通实现: Function [ pointer ] ----> heap Model 小对象优化: Function [ inline buffer: Model ] ``` 小对象优化能减少堆分配,但实现复杂度明显上升,需要处理对象对齐、placement new、手动析构、移动构造等细节。工程中除非在写基础库,否则不建议轻易自己实现完整 SOO。 ------ ## 现代 C++ 实现 现代 C++ 中,Type Erasure 不一定都要手写 `Concept + Model`。很多场景可以直接使用标准库组件。 ### 使用 `std::function` 表达行为 如果你只需要保存“可调用对象”,优先使用 `std::function`: ```cpp #include #include #include #include #include class TaskScheduler { public: using Task = std::function; void add(Task task) { tasks_.push_back(std::move(task)); } void runAll() { for (auto& task : tasks_) { task(); } } private: std::vector tasks_; }; struct ReportGenerator { void generate(const std::string& name) const { std::cout << "生成报表:" << name << "\n"; } }; int main() { TaskScheduler scheduler; ReportGenerator generator; scheduler.add([] { std::cout << "清理缓存\n"; }); scheduler.add([generator] { generator.generate("weekly"); }); scheduler.add([] { std::cout << "发送通知\n"; }); scheduler.runAll(); } ``` 输出: ``` 清理缓存 生成报表:weekly 发送通知 ``` 这个版本比手写 `AnyTask` 更简单,也更符合现代 C++ 的日常实践。 但要注意:`std::function` 适合表达“一个调用签名”。如果对象有多个方法,比如 `draw()`、`resize()`、`hitTest()`,只用 `std::function` 会变得零散,这时手写包装类更清晰。 ### 使用 `std::any` 保存任意值 `std::any` 也是类型擦除: ```cpp #include #include #include #include int main() { std::vector values; values.emplace_back(42); values.emplace_back(std::string{"hello"}); values.emplace_back(3.14); for (constauto& value : values) { if (value.type() == typeid(int)) { std::cout << "int: " << std::any_cast(value) << "\n"; } elseif (value.type() == typeid(std::string)) { std::cout << "string: " << std::any_cast(value) << "\n"; } elseif (value.type() == typeid(double)) { std::cout << "double: " << std::any_cast(value) << "\n"; } } } ``` 输出: ``` int: 42 string: hello double: 3.14 ``` `std::any` 擦除的是“值的具体类型”,但不擦除行为。你可以把任何可拷贝值放进去,但取出来时必须知道原始类型。 因此: - `std::function`:擦除 callable 类型,保留调用能力 - `std::any`:擦除值类型,保留类型查询和安全取回能力 - 自定义 Type Erasure:擦除具体业务类型,保留你设计的业务能力 ### 使用 C++20 Concepts 改善错误信息 传统 `AnyTask` 如果传入没有 `run()` 的类型,错误信息可能很长。C++20 可以用 Concepts 提前约束: ```cpp #include #include #include #include template concept Runnable = requires(const T& object) { { object.run() } -> std::same_as; }; class AnyTask { private: structConcept { virtual void run() const = 0; virtual ~Concept() = default; }; template structModelfinal : Concept { T object; explicit Model(T obj) : object(std::move(obj)) {} void run() const override { object.run(); } }; public: template explicit AnyTask(T object) : self_(std::make_unique>(std::move(object))) {} void run() const { self_->run(); } private: std::unique_ptr self_; }; struct CleanupTask { void run() const { std::cout << "清理缓存\n"; } }; int main() { AnyTask task{CleanupTask{}}; task.run(); // AnyTask invalid{42}; // 编译期直接提示 int 不满足 Runnable } ``` 输出: ``` 清理缓存 ``` Concepts 没有替代 Type Erasure。它解决的是“传入类型是否满足要求”的编译期约束;Type Erasure 解决的是“满足要求的不同类型如何统一存储和调用”的运行时抽象。 ### 经典版本与现代方案对比 | 方案 | 适合场景 | 优点 | 代价 | | ----------------- | ---------------------------- | -------------------- | ---------------------- | | 虚基类继承 | 类型体系由你控制 | 直观,传统,调试方便 | 侵入式,需要继承 | | 手写 Type Erasure | 多方法业务接口、非侵入式抽象 | 灵活,封装性强 | 实现复杂,需要管理语义 | | `std::function` | 单一调用行为 | 简洁,标准库支持 | 只适合 callable | | `std::any` | 任意值容器 | 能保存任意可拷贝值 | 使用时需要类型判断 | | `std::variant` | 类型集合已知 | 无虚函数,类型安全 | 类型集合必须提前列出 | ------ ## 何时使用 ### 适用场景 1. **需要异构容器** - 不同任务对象放进同一个队列 - 不同渲染对象放进同一个列表 - 不同序列化对象统一导出 2. **需要非侵入式抽象** - 第三方类型不能修改 - 不希望业务类继承框架基类 - 希望通过“满足能力”而不是“继承关系”接入系统 3. **需要运行时扩展** - 插件系统 - 回调注册 - 命令队列 - 事件处理器集合 4. **希望暴露稳定 ABI 或简洁 API** - 对外库接口隐藏复杂模板类型 - 避免把巨大模板类型暴露给用户 - 减少调用方对实现类型的依赖 ### 不适用场景 1. **类型集合固定且很少变化** - 优先考虑 `std::variant` - 编译期可穷尽,性能也更可控 2. **性能极度敏感** - 热循环中的大量小对象调用 - 高频实时计算 - 需要严格控制分配和内存布局 3. **简单模板就能解决** - 单次泛型调用 - 不需要统一存储 - 不需要运行时多态 4. **继承关系本身有业务意义** - 图形对象天然是 `Shape` - 控件天然是 `Widget` - 协议处理器天然实现某个框架接口 ### 决策流程图 ``` 需要统一处理不同类型? ├─ 否 → 普通模板/函数重载即可 │ └─ 是 → 类型集合是否固定? ├─ 是 → 优先考虑 std::variant │ └─ 否 → 是否只需要调用能力? ├─ 是 → std::function 或自定义 Type Erasure └─ 否 → 是否只是保存值? ├─ 是 → std::any └─ 否 → 重新审视抽象边界 ``` ------ ## 写在最后 Type Erasure 是 C++ 中非常有代表性的高级抽象技术。它一头连接模板泛型编程,一头连接运行时多态,让我们可以在“不强迫继承”的前提下,把不同具体类型统一成一个稳定接口。 它的价值不在于“让类型消失”,而在于**让调用方只依赖真正需要的能力**。当你理解了 `Concept + Model + Wrapper` 这三个角色,再去看 `std::function`、`std::any`、很多现代 C++ 库的 API 设计,就会发现它们背后的思路高度一致。 **实现演进路线**: ``` 继承多态 → 手写 Type Erasure → std::function/std::any → Concepts + Type Erasure ↓ ↓ ↓ ↓ 侵入式接口 非侵入式包装 标准库通用方案 更清晰的类型约束 ``` **记住这 3 句话**: > **Type Erasure 擦除的是具体类型,不是对象能力。** > **当你想统一“能做什么”,而不是统一“属于谁”时,类型擦除非常合适。** > **能用 `std::function` 和 `std::any` 解决的问题,不要急着手写复杂包装。** **系列衔接**:RAII 解决资源生命周期,CRTP 代表编译期多态,Type Erasure 则代表运行时非侵入式抽象。