《C++ Software Design》第八章 The Type Erasure Design Pattern
在现代 C++ 的设计实践中,我们面临一个关键选择:是使用继承实现多态,还是通过更灵活、更抽象的方式?
随着泛型编程的普及,Type Erasure(类型擦除) 成为构建零侵入、接口透明、可组合性强系统的关键机制。
Guideline 32:用 Type Erasure 替代继承层次结构
Type Erasure 的由来
类型擦除是 C++ 为了解决 运行时类型抽象 与 模板泛型编程 之间冲突而诞生的技术。
在早期的 C++ 中,运行时多态主要通过 继承+虚函数 实现。但继承体系有很多缺陷:
- 接口侵入(必须修改类定义)
- 类型层级复杂,难以组合
- 运行时效率依赖虚表查找
为解决这些问题,现代 C++ 设计中引入了“类型擦除”概念,用以 在不暴露原始类型的前提下,支持运行时多态行为。
Type Erasure 模式解释:抽象不暴露类型的接口
核心机制由三层构成:
template <typename T>
struct Model {
T data;
void operator()() const { data(); }
};
struct Concept {
virtual void operator()() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
virtual ~Concept() = default;
};
class FunctionWrapper {
std::unique_ptr<Concept> self;
public:
template <typename F>
FunctionWrapper(F f)
: self(std::make_unique<Model<F>>(std::move(f))) {}
void operator()() const { (*self)(); }
};
这个结构完全模拟了 std::function<void()>
的行为。
拥有式类型擦除(Owning Type Erasure)
std::function
是拥有式类型擦除的典型案例:
std::function<void()> task = [] { std::cout << "Hello\n"; };
task(); // 调用时不依赖原始类型
- 内部持有 lambda 或函数对象
- 构造时完成类型擦除
- 所有权由封装器掌控,安全封装生命周期
类型擦除的缺点
- 实现复杂(涉及 clone、虚表、模板封装)
- 性能可能低于静态派发(例如 CRTP、Concepts)
- 在高频路径中不易调优
不同类型擦除封装器的比较
封装器 | 类型擦除方式 | 拥有权 | 支持拷贝 | 使用场景 |
---|---|---|---|---|
std::function | 模板 + 虚函数 | 是 | 是 | 函数对象封装 |
std::any | 原始内存块 + RTTI | 是 | 是 | 类型无关的值封装 |
自定义 wrapper | 模板 + clone | 是/否 | 可控 | 自定义接口的泛型封装 |
接口分离(Interface Segregation)
你可以通过 拆分 Concept 接口 来支持多个接口共存的 Type Erasure。
struct Drawable {
virtual void draw() const = 0;
};
struct Moveable {
virtual void move(int dx, int dy) = 0;
};
这类似于将类型擦除与面向接口设计结合,形成“组合接口型多态”,可以在不同语义层进行解耦。
性能基准测试(书中总结)
- Type Erasure 的调用性能略低于虚函数(1.1~1.3x)
- 但在缓存友好、编译期复杂度更低的上下文中可能胜出
- 若启用 Small Buffer Optimization,性能可大幅提升
Guideline 33:类型擦除的优化潜力
小缓冲优化(Small Buffer Optimization, SBO)
避免动态分配内存是提升类型擦除封装器性能的关键策略。
constexpr size_t SBO_SIZE = 64;
class SmallFunction {
alignas(alignof(std::max_align_t)) char buffer[SBO_SIZE];
Concept* conceptPtr = nullptr;
// 若对象小于 64 字节,则构造在 buffer 中
};
- 对小对象使用栈内分配
- 减少 heap allocation 次数
- 显著提高吞吐量和缓存命中率
现代 std::function
实现中基本都启用了 SBO 技术。
手动函数分发优化
若你控制封装器结构,可使用函数指针数组(dispatch table)替代虚函数调用:
struct Dispatcher {
void (*invoke)(void*) = nullptr;
void* obj = nullptr;
};
template <typename T>
Dispatcher make_dispatcher(T* t) {
return { [](void* p) { (*static_cast<T*>(p))(); }, t };
}
手动分发表可用于构建高性能、零继承的运行时接口系统。
Guideline 34:拥有式与非拥有式类型擦除的权衡
拥有式封装的成本
以 std::function
为例,其代价包括:
- 类型包装开销(对象 -> Model)
- 动态内存分配(若未启用 SBO)
- 虚函数跳转或函数分发成本
- 拷贝和移动需构造/销毁内部状态
这在高频构造-销毁路径中容易造成严重性能瓶颈。
非拥有式类型擦除:轻量封装方案
template <typename T>
class NonOwningFunction {
T* obj;
void (*call)(T*);
public:
NonOwningFunction(T& o, void(*c)(T*)) : obj(&o), call(c) {}
void operator()() { call(obj); }
};
- 不持有对象,仅封装调用接口
- 零拷贝、零分配
- 可适用于性能关键代码路径
更强大的非拥有封装器
可以扩展成支持多操作:
template <typename T>
struct FunctionRef {
T* object;
void (*invoke)(T*);
void (*destroy)(T*) = nullptr;
};
类似 std::function_view
(C++23) 的思路,适合低延迟应用(音频处理、游戏逻辑等)。
总结对比
特性 | 拥有式类型擦除 | 非拥有式类型擦除 |
---|---|---|
所有权 | 封装器持有对象 | 外部持有 |
生命周期管理 | 自动析构 | 需外部管理 |
分配成本 | 有(除非 SBO) | 零分配 |
性能 | 略逊色(取决实现) | 高性能,接近裸函数调用 |
使用难度 | 容易(如 std::function) | 中等,需要手动维护调用接口 |
结语
C++ 中最强大也是最复杂的运行时抽象手段之一:类型擦除设计模式
- 替代传统继承结构实现多态
- 赋予泛型编程以运行时能力
- 实现可组合、非侵入、类型透明的组件设计
在现代库设计中,从 std::function
到 std::any
,再到如 std::span
, function_ref
, type_safe::variant
等库,类型擦除都是构建强大抽象能力的核心机制。