Day 33:类型擦除(Type Erasure)

昨天我们用「虚接口 + 引用」实现了运行期多态,代价是每个消费类都要为一条契约写一套接口代码(一个 ILogger 只能服务日志)。今天我们学相反方向的技术:如何用一个类型装下任意实现——std::function、std::any、Qt 的 QVariant 与信号槽参数传递,底层都是这件事。

目录

  1. 今日主题
  2. 为什么需要这个模式
  3. 核心思想
  4. UML / 角色关系
  5. 最小 C++ 示例
  6. Qt 实战示例
  7. 代码执行流程
  8. 为什么这样设计
  9. 不使用会怎样
  10. 何时使用
  11. 与其他模式区别
  12. Qt 源码中的体现
  13. 面试常见问题
  14. 今日练习
  15. 今日总结
①

今日主题:一句话定义

类型擦除(Type Erasure)是一种把「具体类型」从对外接口上抹掉、只保留「行为契约」,同时让外壳类型拥有值语义(可拷贝、可赋值、可放进容器、可作为返回值)的设计技术。它是 C++ 里外部多态(external polymorphism)的标准实现方式,也是 std::function、std::any、QVariant、QMetaObject::invokeMethod 背后的共同机制。

一句话抓核心:普通继承(内部多态)是「让实现者长出一张虚函数表」;类型擦除是「让外壳自己捧着一张虚函数表」,于是被包装的实现者不必继承任何东西,甚至不必知道你存在。

昨天(Day 32 依赖注入)我们解决了「依赖从哪里来」,今天解决的是「怎么把五花八门的实现装进同一个统一的、可拷贝的、可反射的容器里」。这两件事合起来,就是一套可插拔架构的地基。

②

为什么需要这个模式:先看坏代码

假设你要写一个渲染器,需要在一份「场景」里遍历绘制所有图元。你手上有 Circle、Rect、Text 三种互不相干的类型。方案无非两条老路,都有硬伤。

坏代码 A:模板泛型 —— 异构容器装不下,头文件泄漏实现

// ❌ 反例 A:模板把「具体类型」钉死在调用点上
#include <vector>
#include <string>

struct Circle { void render() const; };
struct Rect   { void render() const; };

template <class T>
void renderAll(const std::vector<T>& items) {   // 每个 T 生成一份独立代码
    for (const auto& it : items) it.render();
}

void demo() {
    std::vector<Circle> circles;
    std::vector<Rect>   rects;
    renderAll(circles);      // 编译期就固定了 T
    renderAll(rects);        // 又实例化一份
    // std::vector<???> scene;  // ← 想把 Circle 和 Rect 放进同一个容器?办不到
}

问题清单:① 异构容器无解——你无法声明一个既能装 Circle 又能装 Rect 的 std::vector;② 头文件泄漏——renderAll 必须被使用者可见(模板定义要在头文件里),库的内部实现被拖进每个编译单元;③ 代码膨胀与编译变慢——N 个类型 × M 个泛型函数就是 N×M 份实例化代码;④ ABI 不稳定——模板实例无法安全地跨 DLL/so 边界传递,插件化架构直接崩掉。

坏代码 B:侵入式基类 —— 逼所有人继承你

// ❌ 反例 B:为了统一,强制所有类型继承你自己的基类
class IDrawable {
public:
    virtual ~IDrawable() = default;
    virtual void render() const = 0;
};

class Circle : public IDrawable {           // 我自己的类:能改,勉强忍
public:
    void render() const override;
};

// 现在你想渲染一张第三方库的图片,或者一段 std::string:
//   class QImage / class std::string  —— 你能让它们继承 IDrawable 吗?不能。
// 于是只能写壳子类,壳子又要管生命周期,越写越脏:
class ImageAdapter : public IDrawable {
public:
    explicit ImageAdapter(QImage img) : m_img(std::move(img)) {}
    void render() const override { /* 转发 */ }
private:
    QImage m_img;
};

问题清单:① 侵入性——被包装类型必须改造自身(继承、加基类成员、多一个 vptr),第三方类型根本改不了;② 能力维度爆炸——同一个类如果既要「可渲染」又要「可序列化」还要「可克隆」,就得挂上三张基类,接口继承树迅速变成蛛网;③ 测试替身被迫继承——写个 Mock 也得继承你的生产基类,测试与实现强绑定。

SOLID 视角的违背:反例 A 违背 OCP/DIP(调用方被具体类型钉死,编译期就决定了行为);反例 B 违背 OCP(每加一种能力都要改所有类型的继承列表)与 ISP(把不相关能力塞进一个基类,实现者被迫写空函数)。

类型擦除给出的答案:被包装类型 零侵入(不继承、不改一行代码,只要有同名成员函数),外壳提供 值语义(可拷贝、可装进 std::vector),实现细节通过 编译期模板 一次性生成、不暴露在头文件里。

③

核心思想:把虚函数表搬到外壳身上

通俗解释。你写 virtual void render() 时,编译器偷偷做了一件事:给这个类生成一张函数指针表(vtable),每个对象里塞一个指针(vptr)指向它。多态性「长在实现者身上」。

类型擦除做的事,是把这张表手工写到外壳身上:外壳内部持有一个 Concept*(纯虚接口,等价于那张表),而 Model<T> 是编译器为每个 T 自动生成的「适配器」,把 T::render() 接到 Concept::render() 上。于是——

  • 对调用方而言,他只知道外壳(AnyDrawable / std::function / QVariant),看不到 T;
  • 对实现者(Circle、一个 lambda、你自己的类)而言,它不知道自己被包装了;
  • 唯一的知情者是那个模板构造函数,它同时看得见两者,负责「接线」。

生活类比一:出租车公司。乘客只认「这是一辆能载客的车」这个契约,不关心是油车、电车还是混动。签约那一刻(构造外壳那一刻),公司把「怎么启动、怎么计价、怎么开门」登记成一张任务表(vtable)挂在车(外壳)上。乘客对着「车」发号施令,具体动作由登记表决定。

生活类比二:USB 接口。USB 口 = 外壳(固定尺寸、固定插法);U 盘/键鼠/网卡 = 具体类型(彼此毫无关系);USB 协议 = Concept(只规定供电、枚举、传输这几件事);插头里的控制芯片 = Model<T>(唯一知道「我是键盘,我的报文长这样」的角色)。

三层结构,记住这三行就记住了实现:

① Concept(概念):一小组纯虚函数,只声明外壳要暴露的能力(render / clone / destroy)。
② Model<T>(模型):全工程里唯一「知道 T」的地方,把 T 的成员函数适配成 Concept 的虚函数,并负责 T 的构造、拷贝、析构。
③ Wrapper(外壳/Any):用户真正使用的类型,值语义,内部只持有一个 unique_ptr<Concept> 或一张函数指针表 + 存储区。

可选第四层:Storage(存储策略)——堆分配(简单)或内联小对象缓冲(SBO,把不超过阈值的小对象直接放进外壳里,省一次 new,std::function 正是这么干的)。

④

UML / 角色关系:谁依赖谁、谁拥有谁

以下是 AnyDrawable 的结构关系(文字版类图;箭头含义按 UML 约定:实线空心三角 = 继承,菱形 = 组合/拥有,虚线箭头 = 依赖):

        ┌──────────────────────────────────────────┐
        │            AnyDrawable  (Wrapper)         │   ← 用户只看到这个类型
        │  - unique_ptr<DrawableConcept> m_self     │      · 值语义(可拷贝/可移动)
        │  + AnyDrawable(T&&)        模板构造函数   │      · 恒定大小(64 位下 8 字节)
        │  + render() const                        │      · 非虚函数,直接调用
        └───────────────────┬──────────────────────┘
                            │ ◆ 组合(拥有,生命周期绑定)
                            ▼
        ┌──────────────────────────────────────────┐
        │        DrawableConcept  «interface»       │   ← 唯一的多态出口
        │  + render() const = 0                    │      只暴露 2 个能力,多的一个不给
        │  + clone() const -> unique_ptr<Concept>  │      (供深拷贝用)
        └───────────────────▲──────────────────────┘
                            │ △ 继承(唯一用到继承的地方)
              ┌─────────────┴─────────────┐
              │                           │
   ┌───────────────────────┐   ┌───────────────────────┐
   │  Model<Circle>        │   │  Model<Text>          │   ← 由编译器按需生成
   │  - Circle m_value     │   │  - Text   m_value     │      · 按值持有(零侵入)
   │  + render()→Circle::  │   │  + render()→Text::    │      · 唯一知道 T 的类
   └───────────┬───────────┘   └───────────┬───────────┘
               │ 依赖(用 T 的成员函数)        │
               ▼                           ▼
        ┌─────────────┐             ┌─────────────┐
        │  Circle     │             │  Text       │   ← 它们完全不知道 Wrapper 的存在
        │  render()   │             │  render()   │      不继承、不加成员、不改一行
        └─────────────┘             └─────────────┘
角色职责它的「不知道」
Wrapper(AnyDrawable / std::function / QVariant)对外提供稳定接口与值语义;拷贝时调用 clone();析构时经 Concept 释放 T不知道具体 T 是什么、有多大、怎么算
Concept(DrawableConcept)定义「外壳必须提供的能力集合」——这是全系统唯一需要谨慎设计的部分(能力越少越好)不知道有哪些实现,也不持有数据
Model<T>把 T 的成员函数适配成 Concept 虚函数;负责 T 的构造/拷贝/析构;由编译器按 T 实例化不知道调用方是谁,也不需要
被包装类型 T(Circle/Text/lambda)只提供自己的成员函数(鸭子类型)完全不知道 Wrapper、Concept 的存在(零侵入)
Storage(堆 / SBO 内联缓冲)决定 T 放在哪:new 到堆,或放进外壳内的 aligned_storage不知道 T 的语义,只关心大小与对齐

关键点:外壳与 Concept 之间是 组合(拥有),Concept 与 Model 之间是继承(全模式里唯一的一处继承),Model 与被包装类型之间也是组合(按值持有)。这就是「组合优于继承」的典型形态:继承只用来做一次「动作分发」,其余全是组合。

⑤

最小 C++17 示例:手写一个带值语义的 AnyDrawable

下面这份代码是完整可编译的单文件程序(本机 g++ 13.3 / C++17 已实测通过并运行)。它用 40 行核心代码把「内部多态」换成了「外部多态」。

// type_erasure_min.cpp —— 手写一个带值语义的类型擦除外壳(C++17)
// 编译:g++ -std=c++17 -Wall -Wextra type_erasure_min.cpp -o type_erasure_min
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

// ---------- 1) 概念层:外壳要暴露的全部能力,只写在这里 ----------
struct DrawableConcept {
    virtual ~DrawableConcept() = default;
    virtual void render() const = 0;                              // 行为契约
    virtual std::unique_ptr<DrawableConcept> clone() const = 0;    // 值语义的关键
};

// ---------- 2) 模型层:唯一“知道”T 存在的地方 ----------
template <class T>
class Model final : public DrawableConcept {
public:
    explicit Model(T value) : m_value(std::move(value)) {}
    void render() const override { m_value.render(); }             // 适配成虚函数
    std::unique_ptr<DrawableConcept> clone() const override {
        return std::make_unique<Model<T>>(m_value);                 // 要求 T 可拷贝
    }
private:
    T m_value;                                                     // 按值持有,零侵入
};

// ---------- 3) 外壳层:用户看到的类型,值语义 + 异构容器 ----------
class AnyDrawable {
public:
    template <class T,
              class = std::enable_if_t<!std::is_same_v<std::decay_t<T>, AnyDrawable>>>
    AnyDrawable(T&& value)                                         // 模板构造函数(类本身不是模板!)
        : m_self(std::make_unique<Model<std::decay_t<T>>>(std::forward<T>(value))) {}

    AnyDrawable(const AnyDrawable& other) : m_self(other.m_self->clone()) {}
    AnyDrawable(AnyDrawable&&) noexcept = default;
    AnyDrawable& operator=(AnyDrawable other) noexcept {           // copy-and-swap
        m_self.swap(other.m_self);
        return *this;
    }

    void render() const { m_self->render(); }                      // 非虚,走一次指针转发

private:
    std::unique_ptr<DrawableConcept> m_self;                       // 只有一个指针
};

// ---------- 4) 三个互不相干的类型:都没有继承任何基类 ----------
struct Circle { void render() const { std::cout << "  (o) Circle  r=2.0\n"; } };
struct Rect   { void render() const { std::cout << "  [ ] Rect    3x4\n"; } };
struct Text   { std::string body; void render() const { std::cout << "  (T) " << body << "\n"; } };

int main() {
    std::vector<AnyDrawable> scene;                                 // 异构容器:反例 A 做不到的事
    scene.emplace_back(Circle{});
    scene.emplace_back(Rect{});
    scene.emplace_back(Text{"Hello Type Erasure"});

    AnyDrawable copy = scene[2];                                    // 值语义:深拷贝成立
    scene.emplace_back(std::move(copy));

    std::cout << "scene.size() = " << scene.size() << "\n";
    for (const auto& item : scene) item.render();
    std::cout << "sizeof(AnyDrawable)          = " << sizeof(AnyDrawable) << " bytes\n";
    std::cout << "sizeof(std::function<void()>) = " << sizeof(std::function<void()>) << " bytes\n";
}

实测输出(本机 g++ 13.3.0 -std=c++17 -Wall -Wextra -O2,无警告):

scene.size() = 4
  (o) Circle  r=2.0
  [ ] Rect    3x4
  (T) Hello Type Erasure
  (T) Hello Type Erasure
sizeof(AnyDrawable)          = 8 bytes
sizeof(std::function<void()>) = 32 bytes

读代码的三个关键点:

1. template <class T> 出现在构造函数上,而不在类上——所以 AnyDrawable 本身是一个具体类型(不是模板),才能放进 std::vector、才能作为返回值、才能在头文件里前向声明。

2. clone() 是值语义的代价:unique_ptr 不可拷贝,所以外壳的拷贝构造必须经由 Concept::clone() 虚分派,由 Model<T> 实现「拷贝一份 T」。这也意味着拷贝一个 Type-Erasure 对象 = 一次堆分配(想做零分配就得上 SBO 或改用共享语义的 shared_ptr)。

3. AnyDrawable 只有 8 字节(一个指针),而 std::function<void()> 是 32 字节——多出来的 24 字节就是 SBO 内联缓冲区(16 字节)+ 调用器函数指针(8 字节,此处已含在 16+8×2 布局中)。这就是「要不要为省一次 new 付出对象变胖的代价」的工程取舍,标准库选了「胖一点但常用小 lambda 不分配」。

⑥

Qt 实战:类型擦除的「动作总线」——std::function + QVariant

真实项目里最实用的形式不是手写 Model<T>,而是用两个已有的类型擦除容器组合出业务能力:用 std::function 擦除「怎么执行」,用 QVariantMap 擦除「参数类型」。这样任何一个服务类(不必继承任何基类)都能把自己的成员方法注册成一条可被统一调度、可被禁用、可被反射列表的动作。

文件 1:actionbus.h

// actionbus.h —— 一个类型擦除的动作总线
#pragma once

#include <QHash>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QVariantMap>
#include <functional>

// 一个“动作”= 两段被擦除了类型的可调用对象 + 纯元数据
// 它自己不是虚基类,任何有对应操作的实现都可以被装进来(零侵入)
struct Action {
    QString id;                                    // 稳定标识,如 "view.zoom"
    QString title;                                 // 展示名(菜单/快捷键面板用)
    std::function<bool()> enabled;                  // 守卫:可为空 = 永远可用
    std::function<void(const QVariantMap&)> run;    // 参数类型被 QVariantMap 擦除

    bool isEnabled() const { return !enabled || enabled(); }
    void operator()(const QVariantMap& args) const { if (isEnabled()) run(args); }
};

class ActionBus : public QObject {
    Q_OBJECT
public:
    using ActionMap = QHash<QString, Action>;

    explicit ActionBus(QObject* parent = nullptr) : QObject(parent) {}

    void       registerAction(const Action& action);
    bool       contains(const QString& id) const { return m_actions.contains(id); }
    QStringList ids() const;                                        // 供菜单/命令面板枚举
    int        count() const { return m_actions.size(); }

    bool trigger(const QString& id, const QVariantMap& args = {});

signals:
    void actionTriggered(const QString& id, const QVariantMap& args);
    void actionRejected(const QString& id, const QString& reason);

private:
    ActionMap m_actions;
};

文件 2:actionbus.cpp

// actionbus.cpp
#include "actionbus.h"

#include <QtGlobal>

void ActionBus::registerAction(const Action& action)
{
    Q_ASSERT(action.run);                       // run 是唯一的必填项
    m_actions.insert(action.id, action);        // Action 是值类型,直接拷贝进哈希表
}

QStringList ActionBus::ids() const
{
    QStringList keys = m_actions.keys();
    keys.sort();                                // 稳定顺序,便于 UI 与测试断言
    return keys;
}

bool ActionBus::trigger(const QString& id, const QVariantMap& args)
{
    const auto it = m_actions.constFind(id);
    if (it == m_actions.constEnd()) {
        emit actionRejected(id, QStringLiteral("未注册的动作"));
        return false;
    }
    if (!it->isEnabled()) {                     // 守卫在调用前统一生效
        emit actionRejected(id, QStringLiteral("动作当前不可用"));
        return false;
    }
    (*it)(args);                                // ① 调用 std::function(类型已擦除)
    emit actionTriggered(id, args);             // ② 通知外部(可接日志、撤销栈、遥测)
    return true;
}

文件 3:main.cpp —— 三个互不相干的「服务」,谁都没继承任何基类

// main.cpp
#include <QCoreApplication>
#include <QTextStream>
#include "actionbus.h"

// 服务 1:文档(自有类)
class Document {
public:
    void save(const QString& path) { m_path = path; ++m_saves; }
    int  saves() const { return m_saves; }
    QString path() const { return m_path; }
private:
    QString m_path;
    int     m_saves = 0;
};

// 服务 2:主题(聚合类型,甚至没有一个方法叫 save/switchTo 之外的东西)
struct ThemeService {
    QString name = QStringLiteral("dark");
    void switchTo(const QString& n) { name = n; }
};

static QTextStream out(stdout);

int main(int argc, char** argv)
{
    QCoreApplication app(argc, argv);
    ActionBus bus;

    QObject::connect(&bus, &ActionBus::actionRejected,
                     [](const QString& id, const QString& why) {
                         out << "rejected: " << id << " (" << why << ")\n";
                     });

    Document     doc;                 // 服务 3:一个裸的 int,代表缩放级别
    ThemeService theme;
    int          zoom = 100;

    // ---- 注册:lambda 各自捕获需要的东西,动作总线对它们一无所知 ----
    bus.registerAction({
        QStringLiteral("file.save"), QStringLiteral("保存文档"),
        nullptr,                                            // 无守卫 → 永远可用
        [&doc](const QVariantMap& args) {
            doc.save(args.value(QStringLiteral("path"), QStringLiteral("untitled.txt")).toString());
        }
    });

    bus.registerAction({
        QStringLiteral("view.theme"), QStringLiteral("切换主题"),
        nullptr,
        [&theme](const QVariantMap& args) {
            theme.switchTo(args.value(QStringLiteral("name"), QStringLiteral("light")).toString());
        }
    });

    bus.registerAction({
        QStringLiteral("view.zoom"), QStringLiteral("缩放"),
        [&zoom] { return zoom < 400; },                     // 守卫:到上限就禁用
        [&zoom](const QVariantMap& args) {
            zoom = qBound(25, zoom + args.value(QStringLiteral("delta"), 10).toInt(), 400);
        }
    });

    // ---- 调用:参数类型被 QVariantMap 擦除 ----
    bus.trigger(QStringLiteral("file.save"), {{QStringLiteral("path"), QStringLiteral("report.md")}});
    bus.trigger(QStringLiteral("view.theme"), {{QStringLiteral("name"), QStringLiteral("solarized")}});
    for (int i = 0; i < 3; ++i)
        bus.trigger(QStringLiteral("view.zoom"), {{QStringLiteral("delta"), 150}});  // 100→250→400→被拒
    bus.trigger(QStringLiteral("view.zoom"), {{QStringLiteral("delta"), 150}});      // 已到上限,仍被拒
    bus.trigger(QStringLiteral("unknown.action"));

    out << "doc.saves = " << doc.saves() << "\n"
        << "doc.path  = " << doc.path()  << "\n"
        << "theme     = " << theme.name  << "\n"
        << "zoom      = " << zoom        << "\n"
        << "actions   = " << bus.ids().join(QStringLiteral(", ")) << "\n";
    return 0;
}

文件 4:CMakeLists.txt

cmake_minimum_required(VERSION 3.19)
project(actionbus LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)                 # actionbus.h 里有 Q_OBJECT,需要 moc

find_package(Qt6 REQUIRED COMPONENTS Core)

qt_add_executable(actionbus
    actionbus.h
    actionbus.cpp
    main.cpp
)
target_link_libraries(actionbus PRIVATE Qt6::Core)

# 构建:cmake -B build && cmake --build build && ./build/actionbus

诚实标注:本机(Ubuntu 24.04 服务器)未安装 Qt6 与 cmake,上面这份 Qt 工程没有在本机实际编译运行;代码按 Qt 6.5+ API 编写(qt_add_executable / QHash::constFind / Qt6 的 functor connect),请在有 Qt6 的环境自行 cmake -B build && cmake --build build 验证。下面的输出是按代码逻辑推导的预期值,不是实测结果。

预期输出(推导):

rejected: view.zoom (动作当前不可用)
rejected: view.zoom (动作当前不可用)
rejected: unknown.action (未注册的动作)
doc.saves = 1
doc.path  = report.md
theme     = solarized
zoom      = 400
actions   = file.save, view.theme, view.zoom

这个例子里有两层类型擦除叠在一起,值得仔细看:

  • std::function<void(const QVariantMap&)> 擦除了行为的类型:Document::save 的一个闭包、ThemeService::switchTo 的一个闭包、一段操作裸 int 的闭包,进到同一个 QHash 里都变成同一个类型 Action。
  • QVariantMap(底层 QVariant)擦除了数据的类型:path 是 QString、delta 是 int,到了调用点统一是 QVariant,取用时 toInt()/toString() 还原。
  • 结果是:新增一个功能 = 注册一条动作,不改 ActionBus 一行代码(OCP 达成);而如果用虚接口 class IAction { virtual void run() = 0; },每加一个功能就要新增一个类(哪怕只是一行调用),这在「几十个菜单项 + 快捷键 + 命令面板 + 撤销栈都指向同一动作」的场景就是灾难。
⑦

代码执行流程:一次调用到底走了几步

A. 最小示例:AnyDrawable d = Circle{}; 到 d.render()

  1. 类型推导与接线。编译器用 T = Circle 实例化模板构造函数 → 生成 Model<Circle> 类 + 它的 vtable(render 槽指向 Circle::render,clone 槽指向 Model<Circle>::clone)。这一步发生在编译期、每个 T 一次。
  2. 堆上安置。std::make_unique<Model<Circle>>(Circle{}) 分配内存,把 Circle 移动进 m_value,再把 Model<Circle>* 隐式向上转型为 DrawableConcept* 存进 m_self。此刻外壳里只有一个指针,sizeof == 8。
  3. 拷贝分派。AnyDrawable copy = scene[2]; 调用拷贝构造 → m_self->clone() 虚分派 → 落到 Model<Text>::clone() → make_unique<Model<Text>>(m_value) 分配一块新内存并拷贝 Text。拷贝 = 深拷贝 + 一次堆分配,两个对象此后互不影响(值语义成立)。
  4. 调用与析构。循环里 item.render() 是非虚的直接调用(编译器可内联进 m_self->render() 这一行),再经 vtable 分派到 Circle::render —— 比裸虚调用多一次「指针跟随」,代价是一次间接跳转。作用域结束时 unique_ptr 调用 virtual ~DrawableConcept() → Model<Circle> 析构 → Circle 析构 → 释放内存,全程 RAII,无一处 delete。

B. Qt 示例:bus.trigger("view.zoom", {{"delta", 150}})

  1. 查找。m_actions.constFind(id) —— 一次字符串哈希查找,命中则拿到 const Action&。
  2. 守卫求值。isEnabled() 调用被擦除的 std::function<bool()>(这里是捕获 &zoom 的 lambda),返回 zoom < 400。返回 false 就直接 emit actionRejected 且不执行 run。
  3. 参数还原与执行。Action::operator() 调用 run(args) → std::function 内部的调用器把 QVariantMap 透传给闭包 → args.value("delta").toInt() 把 QVariant 还原成 int → qBound 夹紧后写回 zoom。
  4. 通知外部。emit actionTriggered(...) → Qt 的信号槽机制(内部同样是被擦除的槽对象,见 ⑫ 的 QSlotObjectBase)把事件分发给所有接收者:日志、撤销栈、遥测都可以在这里挂上去,而 ActionBus 完全不知道它们存在。

把两条流程并排看:类型擦除的「接线时刻」永远在构造/注册时(编译期模板实例化 or 运行期哈希插入),而「调用时刻」只剩一次间接跳转 + 一次数据还原。换句话说——类型信息在编译期被消化掉,代价从「每个调用点」前移到了「每个类型一次」,这就是它适合被反复调用的框架代码的原因。

⑧

为什么这样设计:解耦点、扩展点与代价

1)解耦点在哪里。调用方只依赖 Concept(稳定、能力最小),实现方只依赖自己的成员函数名(鸭子类型)。两者之间不共享基类、不共享头文件、不共享 ABI。第三方类型(std::string、QImage、别人的 SDK 类)可以被直接包装,这是继承方案永远做不到的事。

2)扩展点是「实现维度」而不是「能力维度」。新增一种实现(Hexagon)零改动:只要它有 render(),Model<Hexagon> 由编译器自动生成,Concept 一个字都不用改——这正是 OCP。反过来说,新增一种能力(比如「求面积」)就必须改 Concept,所有 Model<T> 都会重新实例化并强制实现该能力。这是类型擦除的真实边界,不是缺点而是必须知道的约束:类型擦除对「实现」开放、对「能力」封闭。

3)DIP 与「组合优于继承」。外壳依赖抽象 Concept(高层不依赖低层细节);外壳组合 Concept、Model组合 T,继承只出现一次且只用于「把动作分发出去」。对比反例 B,被包装类型身上不背任何来自框架的基类,一个类可以同时被三种不同的擦除外壳包装(可渲染、可序列化、可撤销),互不干扰。

4)可测试性与工程性。写完的 AnyDrawable 可以被 return、可以放进 std::vector/QHash/QList、可以作为类的成员变量——因为它有值语义。测试时不必为 Mock 继承任何生产基类,只要它有同名成员函数即可「注入」;也没人需要 mock AnyDrawable 本身。

5)编译防火墙与 ABI 稳定。外壳的尺寸与布局固定(std::function 32 字节、QVariant 固定 sizeof),头文件不必包含实现类型的定义。库的公开 API 里只出现外壳类型时,内部换实现不需要重新编译使用者——这正是插件化、DLL 边界的刚需(也是 Qt 为什么造 QVariant、为什么把信号槽参数做元类型注册的原因)。

代价要诚实列清楚(面试也爱问):

  • 一次间接调用:调用方 → Concept::render(虚)→ T::render,比模板的完全内联慢;无法被编译器去虚拟化(除非 LTO 且类型流单一)。
  • 一次堆分配(若不用 SBO):拷贝构造要走 clone(),频繁拷贝的场景要小心;改用共享语义(shared_ptr 持有 Concept)可省拷贝但失去值语义。
  • Concept 的能力集要手写全:至少是 render + clone(若需拷贝)+ 虚析构;漏了 clone 就不能拷贝,漏了虚析构就会内存泄漏(这一类错误很容易发生,务必让 Concept 只有虚析构 + 少量纯虚函数,最好加 final 与 override 让编译器帮你查)。
  • 调试体验略差:调用栈多一层,错误信息里出现 Model<T> 这种模板名;异常类型不能跨擦除边界(std::function 包装的闭包抛什么就抛什么,但外壳无法声明 noexcept)。
⑨

不使用会怎样:三种典型爆炸

爆炸 A:if-else / 枚举分派地狱(RTTI 版)——「我不知道类型,就用运行期判断吧」:

// ❌ 每新增一种图元,所有分派点都要改;第三方类型更无解
enum class Kind { Circle, Rect, Text };

struct Shape {              // 上帝结构体:所有图元字段堆在一起
    Kind kind;
    double w, h, r;
    std::string text;
};

void render(const Shape& s) {
    switch (s.kind) {       // ← 这个 switch 会在 20 个文件里重复出现
    case Kind::Circle: /* 画圆 */ break;
    case Kind::Rect:   /* 画矩形 */ break;
    case Kind::Text:   /* 画文字 */ break;
    }
}

后果:① 加一种形状 → 改 enum + 改所有 switch(OCP 全面沦陷);② Shape 成了「什么字段都得有」的胖结构体,包不住第三方类型(QImage 的字段你写不进这个 struct);③ 用 dynamic_cast/typeid 代替 enum 也只是把 switch 换成 if 链,且强制类型必须继承基类,仍然零侵入失败。

爆炸 B:类膨胀 / 适配器洪水——用继承方案包装 N 个第三方类型,就需要 N 个手写适配器类,每个都要管生命周期、拷贝、异常;如果这些第三方类型还要被包装进「可序列化」体系,就是 2N 个适配器。代码量随「类型数 × 能力数」平方增长。

爆炸 C:接口洪水与跨边界崩溃——为每个能力造一个虚接口(IRenderable、ISerializable、ICloneable),实现类继承一堆基类;想在 DLL 边界传递时又发现虚接口需要两端一致的类布局与 vtable 约定,容易出现「跨模块 delete」问题(不同堆、不同 CRT)。此时通常被迫退回「用 void* + 手工函数指针表」——那其实就是手工劣化版的类型擦除,因为 void* 丢掉了类型安全与值语义。

对比一下:类型擦除并没有消灭「分发」这件事,它只是把分发改成一次、编译期生成、写在某个模板里,于是分派点从「N 处 switch」收敛成「1 个 Model<T>」,并且类型安全(编译期检查 T 有没有那个成员函数)、值安全(RAII 保证析构)。这就是它值得被学一遍的理由。

⑩

何时使用:5 个信号 + 4 条禁令

该用的信号(满足任意一条就值得考虑):

  1. 需要异构容器。场景/命令队列/任务池/事件列表里要装不同实现,且必须保有值语义(可拷贝、可排序、可 erase)。
  2. 要包装第三方类型。类型不可改(QImage、std::string、SDK 里的 Client),但你需要它满足你定义的契约。
  3. 库/框架的公开 API 边界。不想让使用者的头文件里出现你的实现类型,也不想让模板实例跨 DLL;接口需要 ABI 稳定。
  4. 回调存储(最日常的用法)。「存一个待会儿要执行的函数」——std::function 是它的标准答案,把捕获了各种上下文的 lambda 统一成一个类型放进 QHash/QList。
  5. 属性系统 / 反射 / 配置 / 序列化。类型在运行期才知道(JSON 配置、插件参数、属性面板、拖拽数据),这正是 QVariant + QMetaType 存在的理由。

不该用(会被虚接口或模板更划算):

  1. 实现类型编译期已知且单一。直接虚接口传引用(不拥有、零分配),或直接调具体类——别为了「将来可能扩展」先引入一层擦除。
  2. 热路径里的百万次调用。每帧 10 万次 std::function 调用 + QVariant 解包是性能陷阱;这里应该用模板/概念/C++20 concepts 让编译器完全内联,或者把还原后的强类型指针缓存下来。
  3. 「能力」维度会持续膨胀。如果你频繁需要给同一个外壳加新能力(render → hitTest → serialize → undo…),每次都要改 Concept 并波及所有 Model<T>:此时多接口(ISP 拆分)+ 动态查询(qvariant_cast 风格)或访问者模式更合适。
  4. 对象生命周期天然共享/不可拷贝。如果外壳内部是 shared_ptr 语义、又要频繁拷贝,那它已经是「共享的接口指针」,直接传 std::shared_ptr<IConcept> 更直白——擦除只承诺「值语义」,不承诺「廉价拷贝」。

过度设计提醒:类型擦除的判据只有一条——「同一段代码需要用统一方式处理一组无法共享基类的类型」。如果那组类型本来就可以共享一个基类、且你不需要值语义与异构容器,那么一层虚接口 + 引用参数比手写 Concept/Model/Wrapper 三层更短、更快、更好读。先问「我真的需要把类型藏起来吗」,再问「我要用哪种藏法」。

⑪

与其他模式的区别:最容易混淆的四个

维度类型擦除策略模式 Strategy适配器 Adapter桥接 Bridge
多态在哪发生外部(外壳上的 vtable)内部(被包装类继承 Strategy)内部(Adapter 继承 Target)内部(Abstraction 持有 Implementor 基类)
被包装者是否需继承不需要(鸭子类型)需要继承 Strategy 基类需要(Adapter 自己继承 Target)Implementor 需要继承
是否值语义有(可拷贝/可入容器)通常没有(外部持有指针/引用)视实现,通常没有通常没有
数量关系N 种实现 → 1 个外壳(N:1)1 组可替换算法1 个不兼容接口 → 1 个期望接口(1:1)2 个维度各自独立扩展(M×N)
典型代表std::function、std::any、QVariant、QMetaObject::invokeMethodQTextCodec、排序比较器、QStyleQSqlDriver 包装底层驱动、QImageReader 适配格式插件Qt 的 QPainter(Abstraction)↔ QPaintEngine(Implementor)

与策略模式的关系最容易说混。策略模式关心「运行时换算法」,通常用指针/引用持有、生命周期由外部管;类型擦除关心「把类型与值语义一起装进外壳」,它常常是策略模式更好的存储载体:把策略从 std::unique_ptr<IStrategy> 换成值语义的擦除外壳,就同时拿到了可拷贝、可放入容器、零侵入三件套(代价是拷贝会走 clone())。

与适配器模式的差别是「语义」。适配器做的是接口语义转换(把 QImageReader::read() 改成你期望的 load(),1 个适配器对 1 个目标);类型擦除不改变语义,它只是把「同一契约」从继承体系里搬到外壳上,用于一次性收纳 N 种实现。换言之,Model<T> 长得像适配器,但它是由编译器批量生成的、且只为「接线」而存在。

与桥接模式的差别是「维度」。桥接显式建模两个独立变化的继承层次(抽象 vs 实现),设计者主动写出两层类;类型擦除只有一个契约维度,被包装者对结构完全无感。桥接适合「同一套操作要在多种平台上实现」(QPainter/QPaintEngine),擦除适合「同一套操作要作用于一堆既成类型」。

别忘了最大的对手:模板/C++20 概念。如果所有类型在编译期都已知、且可以接受头文件里出现实现,那么 template <Renderable T> void renderAll(const std::vector<T>&) 是零开销最优解(完全内联、无堆分配、编译期报错清晰)。模板与类型擦除不是取代关系,而是「编译期多态 ↔ 运行期多态」的分工:容器/边界/配置驱动的场合用擦除,算法/算法内部的泛化用模板。事实上标准库自己就是两者叠加——std::function 是模板构造(编译期接线)+ 虚调用(运行期分派)。

⑫

Qt 源码中的体现:Qt 到处都在用这套手法

1)QVariant + QMetaType —— Qt 最著名的类型擦除。这套设计几乎可以逐行对应今天手写的三层:

  • QMetaType = 运行期的 Concept 表:它给每个已注册类型保存一份「函数指针表」,含 construct / copy / move / destruct / compare / convert / debugStream,外加 size 与 alignment。本质上就是编译器帮你生成的那张 vtable,只不过 Qt 用运行期注册的方式把它做成了可查询的表。
  • QVariant = Wrapper:固定大小、值语义(可拷贝)、能放进 QVariantList/QModelIndex 里;QVariant::Private 里有一个 Data union 做内联存储(int/double/bool/QString/QPoint 等小类型直接放在 QVariant 内部,指针/大对象走堆)——这就是 Qt 版的 SBO,所以 QVariant 在不同实现里往往是 16~24 字节级别的固定尺寸。
  • QVariant::fromValue<T>() ≈ Model<T> 的生成:它用 QMetaType::fromType<T>() 拿到 T 的函数指针表并实例化(Q_DECLARE_METATYPE 就是让这个实例化可见的显式注册;Qt6 里许多场合已自动注册,但类型仍需可拷贝、可默认构造)。
  • QVariant::value<T>()/toInt()/toString() ≈ 类型还原:擦除不是丢失,而是把类型信息挪到运行期,取用时再问一次「你其实是 int 吗」,代价是运行期检查(返回默认值而非抛异常)。

2)QObject::connect 的 functor 版本:Qt 自己写了一个 std::function。Qt5 起支持 connect(sender, &Sender::sig, context, [](){...}) 这样的「槽是任意可调用对象」。Qt 是怎么存这个 lambda 的?答案是 QtPrivate::QSlotObjectBase——一个手工实现的类型擦除外壳(源码见 qtbase/src/corelib/kernel/qobjectdefs_impl.h):它继承自 QSlotObjectBase(带引用计数),派生模板 QSlotObject<Func, Args, R> 就相当于今天的 Model<T>,而 QSlotObjectBase 里的 impl() 函数指针就是那张「vtable」的入口。Qt 没有直接用 std::function,因为它需要额外能力:引用计数复用、连接比较(同一槽对象去重)、以及不依赖 std::function 的异常/ABI 约束。这是「同一个模式因不同工程约束写出不同实现」的绝佳案例。

3)QMetaObject::invokeMethod 与信号槽的参数传递。跨线程调用、或按名字调用(invokeMethod(obj, "slotName", Q_ARG(int, 42)))时,参数被逐个装进 void* 数组,并带上对应的 QMetaType,由元系统在目标线程里用那张函数指针表拷贝/析构它们。这就是「按名字找方法、按类型还原参数」的运行期擦除式分发——也是 Qt 能实现 queued connection 的根基。

4)QSharedPointer<T> 的删除器。它接受任意删除器(QSharedPointer<T> p(new T, [](T* p){ /* 自定义释放 */ })),而 QSharedPointer<T> 的类型不因删除器不同而改变——删除器被擦除成一个存储起来的可调用对象,析构时通过它释放。这正是「一个类型装下任意行为」在智能指针上的应用。

5)QAbstractItemModel::data() 返回 QVariant。为什么不是返回某种基类指针?因为视图(QStyledItemDelegate、QTreeView)需要对模型的任意列做统一处理:文本列是 QString、进度列是 int、图标列是 QIcon、颜色列是 QBrush。用 QVariant 一把擦除,视图才能靠 itemDelegate 与 Qt::ItemDataRole 统一渲染——这是「类型擦除腾出框架抽象空间」的教科书式例子。

反例也要提一句:QStyle / QAbstractItemDelegate / QTextCodec 这些是普通虚接口(内部多态),不是类型擦除——因为它们的使用者是「继承并实现」的插件作者,且不需要值语义与异构容器。Qt 两种手法都用,选择依据正是 ⑩ 里的那几条判据。

⑬

面试常见问题

Q1:什么是类型擦除?「内部多态」和「外部多态」的区别是什么?

类型擦除是在对外接口上抹掉具体类型、只保留行为契约的技术,用于让一组无共同基类的类型被统一处理,同时外壳保持值语义。内部多态(intrinsic polymorphism)指多态性由被包装类型自己提供——编译器为它生成 vtable/vptr,前提是它必须继承;外部多态(external polymorphism)指多态性由外壳提供——外壳内部持有一张手工/模板生成的函数指针表(Concept + Model<T>),被包装类型只要「长得像」(有同名成员函数)就行,完全零侵入。区别要点:多态表在谁身上、被包装类型是否需继承、是否支持值语义与异构容器。

Q2:std::function 是如何保存任意可调用对象的?SBO 是什么?为什么它的 sizeof 是 32 字节(libstdc++)?

它把可调用对象存进一个类型擦除外壳:外壳里有 ① 一小块内联缓冲区(libstdc++ 为 16 字节,且要求对齐 8)② 一个「管理函数指针」(负责拷贝/移动/析构这块缓冲,相当于 Concept 的析构与 clone)③ 一个「调用器函数指针」(负责按目标签名调用,相当于 Concept::render)。若可调用对象尺寸不超过阈值且移动构造不抛异常(nothrow move),就内联放在缓冲区里(SBO / small buffer optimization),否则退化为堆分配。libstdc++ 里就是 16 字节缓冲 + 两个指针 = 32 字节(本机 sizeof(std::function<void()>) == 32 已实测)。SBO 的意义:最常见的「捕获 1~2 个指针的小 lambda」不产生 new,这是 std::function 能被用作热路径回调的前提。同理,标准要求 std::function 的移动构造不抛异常,因为内联缓冲的搬迁必须保证强异常安全。

Q3:std::function 回调和虚接口回调,哪个更快?怎么选?

两者都至少有一次间接跳转(std::function 走调用器函数指针;虚接口走 vtable),差别主要在三处:① 分配——小 lambda 被 std::function 内联,不分配;虚接口回调通常要 new 一个实现对象(或至少持有堆对象)。② 缓存局部性——std::function 把状态内联在值里,虚接口回调访问堆对象多一次解引用。③ 可内联性——模板回调能被完全内联、编译期消除;std::function 与虚接口都做不到(除非 LTO/去虚拟化)。所以:性能敏感 + 类型编译期已知 → 模板/概念;需要运行期装配、异构存储、跨模块传递 → std::function;需要多方法契约(一个接口多个操作)、需要继承语义 → 虚接口。

Q4:QVariant 怎么装下任意类型?Q_DECLARE_METATYPE 是干什么的?和 std::any 有何异同?

QMetaType 维护一张运行期类型表,每个已注册类型对应一组函数指针(构造/拷贝/移动/析构/比较/转换)与 size/align;QVariant 内部用 union 做小对象内联存储、大对象走堆,靠那张表完成拷贝与析构——和手写 Concept/Model<T>/Wrapper 一一对应。Q_DECLARE_METATYPE(T)(Qt6 中许多场景已自动注册)为自定义类型声明元类型信息,使其能被 QVariant 收纳、被信号槽队列连接传递、被 QMetaType 查询;类型通常需可拷贝、可默认构造。std::any 是标准库同类物:同样做类型擦除 + SBO,同样支持 any_cast 还原,但没有元类型注册表、没有运行期转换/比较/序列化,也不能被跨模块按名字调用(Qt 的元对象系统额外提供了「按字符串找方法/属性/枚举」的反射能力)。

Q5:什么情况下不该用类型擦除?它能不能表达「多个能力」?

不该用的情况:实现类型编译期已知且单一(直接用虚接口引用或具体类型更省更快);百万次调用的热路径(每帧 10 万次擦除调用 + QVariant 解包是性能陷阱,应改用模板/概念,或在初始化时一次性还原成强类型);「能力维度」会持续膨胀(每加一个能力都要改 Concept 并把所有 Model<T> 重新实例化);对象本来共享、拷贝不廉价(直接传共享指针更直白)。关于多能力:可以给 Concept 加更多纯虚函数(外壳能对外暴露的能力集合由 Concept 决定),但那是编译期固定的——你无法像 std::any_cast 那样在运行期按需取回「任意一种能力」。想要运行期动态查询能力,就得再叠一层(QMetaType::canConvert/convert、或「能力标识 + qvariant_cast」的注册式方案),复杂度随之上升。

⑭

今日练习(15–30 分钟)

需求:实现一个 AnyFormatter——只暴露一个能力「把记录格式化成字符串」。

  • 契约:std::string format(const Record& r) const;(Record 是随便一个含 name/std::string 与 value/double 的结构体)。
  • 约束 1:值语义——AnyFormatter 必须可拷贝(拷贝后的两个对象互不影响),能被放进 std::vector<AnyFormatter>,也能作为 Record 渲染器的成员变量。
  • 约束 2:零侵入——至少要有三个互不相干的实现类型:JsonFormatter(成员函数 format)、PlainFormatter、以及一个直接塞进去的 lambda(比如 [prefix](const Record& r){ ... })。它们都不能继承任何基类。
  • 约束 3:不能泄漏类型——使用方的头文件里不得出现这三个实现类的名字(想想怎么做:AnyFormatter 的接口里只出现 Record 与 std::string)。
  • 加分项:给外壳加一个 name() const,返回实现自己声明的名字(提示:再加一个 Concept 虚函数就够了)。
  • 进阶(选做):把实现改成 基于 std::function(std::function<std::string(const Record&)> 一个成员就够),对比你自己写的三层版本:代码行数、sizeof、拷贝开销、能否装 lambda、能否加第二种能力。写一句结论:什么场景下「结构体里放 std::function」比「手写 Concept/Model/Wrapper」更划算?

提示 1:先把三层拆开写——FormatterConcept(纯虚:format + clone + 虚析构)、Model<F>(模板,按值持有 F)、AnyFormatter(模板构造 + 拷贝走 clone() + operator= 用 copy-and-swap)。三个文件都不需要,一个 .cpp 里跑通即可。写完先自测一句:AnyFormatter a = JsonFormatter{}; AnyFormatter b = a; b 修改不影响 a 是否成立。

提示 2:不要一上来就写 SBO 内联缓冲。先用 std::unique_ptr<FormatterConcept> 把语义(拷贝、析构、异构容器)全部跑通,再考虑把不超过 32 字节的实现塞进 std::aligned_storage_t + placement new(记得手写 destroy 并在移动后置位标记,否则极易 UB)。先正确,后优化——这正是 std::function 内部也在做的事(只是它由标准库替你写好了)。

自检清单:① std::vector<AnyFormatter> 能否编译通过并正确析构(无 double free / 无泄漏,建议 -fsanitize=address 跑一遍);② 三个实现都不继承任何基类;③ 加第四个实现时,AnyFormatter 的头文件是否需要改动(答案应为「不需要」);④ 说得出「这个模式对实现开放、对能力封闭」这句话在你代码里的具体体现。

⑮

今日总结

一句话记忆:类型擦除 = 把虚函数表从被包装类型身上搬到外壳身上——用一次间接调用和一次分配,换取值语义、异构容器与零侵入,对「实现」开放、对「能力」封闭。

代码特征信号(看到这 5 个就知道是类型擦除):

  1. template <class T> class Model final : public Concept —— 模型层是模板、且只在类外/模板里知道 T;
  2. 外壳类里只有一个 std::unique_ptr<Concept>(或一张函数指针表 + 内联缓冲),sizeof 与 T 无关;
  3. 类不是模板,但构造函数是模板(这是「一个类型装下任意实现」的签名特征);
  4. 拷贝构造里出现 clone() 虚调用,Concept 里必然有 clone + 虚析构;
  5. 工程里出现 std::function / std::any / QVariant / QMetaObject::invokeMethod —— 都是在用别人写好的类型擦除。

三句话回顾:① 它的价值不是「多态」,而是「多态 + 值语义 + 零侵入」这三样同时成立;② 它的边界是「实现维度开放、能力维度封闭」,能力要动就得改 Concept;③ 它与模板不是替代关系,而是「运行期多态 ↔ 编译期多态」的分工,标准库自己就是两者叠加(模板接线 + 虚调用分派)。

明日预告 · Day 34:CRTP 与静态多态(Curiously Recurring Template Pattern)。今天我们花了一次间接调用、一次堆分配,才换来「运行期把类型藏起来」。明天要问的是相反的问题:如果类型本来就在编译期全都知道,能不能把多态做在编译期、把刚才那些代价全部榨干?我们会写 template <class Derived> class Base 这套「自我递归」的结构,看它如何做到零虚函数表、零间接调用、完全内联(用 static_cast<Derived*>(this) 在编译期把调用绑定好),以及它的三个经典应用:静态接口(mixin)、operator==/operator</++ 的样板消除、以及 Qt 里 Q_DECLARE_TYPEINFO 式的编译期元信息。最后把今天的类型擦除与明天的 CRTP 放在一张表里对照:什么时候该把类型藏起来(擦除),什么时候该把类型榨干(CRTP)——这是 C++ 多态设计的两端,也是面试里最能拉开差距的一题。