【读书笔记】《C++ Software Design》第八章 The Type Erasure Design Pattern

发布于:2025-07-15 ⋅ 阅读:(14) ⋅ 点赞:(0)

《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::functionstd::any,再到如 std::span, function_ref, type_safe::variant 等库,类型擦除都是构建强大抽象能力的核心机制。


网站公告

今日签到

点亮在社区的每一天
去签到