C++ 多态 Polymorphism

整理 C++ 中静态多态、动态多态、虚函数、类型擦除和原型委托的核心机制。

多态 是指同一类事物,可以具有多种形态的现象,在编程中常常表现为 同一套接口,背后可以有不同实现,调用点按 对象/类型 的实际形态选择实现,它让代码用统一方式操作 抽象,而把差异留给实现

多态可以分为 静态多态 和 动态多态,他们的 调用决议时机 不同,分别对应 静态派发 和 动态派发

静态多态 -> 静态派发 (编译期决议)

编译器在 编译期 就决定了要调用的具体函数,通常表现为 内联,不存在额外跳转,所以可以认为是运行时零开销

  • 模板特化
  • CRTP
  • 函数重载
  • std::variant + std::visit

动态多态 -> 动态派发 (运行期决议)

需要在 运行期 根据对象的具体 形态 来选择 实现,通常经由 虚表/函表 进行一次间接跳转,动态多态 的实现机制一般是 函数指针/回调 但并不代表用了它们就是多态,是否构成多态取决于是否在抽象同一接口的多实现

  • 虚函数 (子类型多态) 编译器在某些情况下(如 final、单一可见实现、跨层次可分析)可能进行 去虚化 devirtualization 优化,会变成 直接调用,但它的 语义 仍是 动态分派,当优化失败时仍经由 虚表 跳转

  • 类型擦除:虽然 函表 在 编译器 生成,但调用是在 运行期 经由表项跳转,因此属于 动态派发

    • std::function
    • 自定义 concept-model
    • entt::poly

静态多态

静态多态在 编译时 确定要调用的具体实现,没有运行时开销,有几种方法来实现它

CRTP

CRTP 是指 Curiously Recurring Template Pattern 奇异递归模版模式,它是一种让 派生类 将自己作为 模版参数 传递给 基类 的模版编程方法,它可以用于实现 静态多态

template <typename T>
struct game_unit
{
    void attack()
    {
        static_cast<T*>(this)->do_attack();
    }
};
 
struct knight : game_unit<knight>
{
    void do_attack() {...}
};
 
struct mage : game_unit<mage>
{
    void do_attack() {...}
};
 

可以发现 基类 game_unit 是一个模版,拥有一个模版参数 T,而 knight 和 mage 在继承 game_unit 时,将自身作为模版参数类型传入,然后 基类 game_unit 在统一接口 attack() 内部用 static_cast 将 this 指针 (一个 game_unit 指针) 转换为模版参数 T 类型的指针 T*,从而达到传入是 knight 则调用骑士的 do_attack() 方法,传入 mage 则调用法师的 do_attack()

如此一来,便满足了 多态 的要求:统一接口背后不同的行为;而由于选择不同实现的决策在 编译时 发生,所以它是 静态多态

由于在编译时明确知道最终调用的到底是谁的实现 (在我们的例子中就是知道调谁的 do_attack() 方法),所以通常可以执行内联优化,不需要仅由 指针 跳转,开销和调用普通函数一样,所以在性能上十分高效

这种方法的缺点也十分明显,接口 虽然被统一了,但是 类型 并不统一,比如 game_unit<knight> 和 game_unit<mage> 是两个完全不同的 类型,我们无法将它们塞入到同一个 容器 中进行管理,要想调用实现,我们必须这样做

game_unit<knight>->attack();
game_unit<mage>->attack();

可见为了获得极致的性能,需要付出的代价是在编译时产生多个不同的类型

Mixin 混入

Mixin 是另一种静态多态方法,它的主要思想是通过将 行为 拆分到多个相互 正交 的小类中,然后通过 组合 这些小类来实现最终想要的功能,可以发现功能的最终实现是 组合 而来,而非 继承 而来

// mixin 类,提供战斗策略
struct hit_and_run {void fight(){...}}
struct last_man_standing {void fight(){...}}
 
template <typename Strategy>
struct knight : public Strategy // 通过继承来进行混入
{
    void attack()
    {
        // attack logic...
        Strategy::fight(); // 调用混入的战斗策略
    }
}
 
knight<hit_and_run> k; // 模版参数确定战斗策略
// 这种方法也可以进行嵌套
// 例如:knight<lone_warrior<aggressive_style>> 具有侵略性风格的孤狼骑士
// 当然模版嵌套一旦出错,将产生大量不友好的编译错误

注意上面虽然在模版中使用 继承 来实现 混入,但是和 传统继承 是不同的

  • 绑定时机与调度方式

    • mixin 在 编译期 决定具体策略类型,例如 knight<hit_and_run> 调用是静态绑定(编译器决议),无虚表、可内联,零额外分派开销

    • 而 传统继承 则依赖运行时的虚函数动态分派(vtable),有一次间接调用开销

  • 关系语义

    • has-a/can-do:Mixin 在语义上是 “具备某种能力/行为”(has-capability),在用继承语法“把能力拌进来”,它并不打算承诺 knight<Strategy> 是一个 Strategy

    • is-a:传统继承则强调 子类型关系(is-a) 与 里氏替换(LSP) 派生类对象可以在任何需要基类的地方替换使用

  • 接口约束与错误时机

    • Mixin 靠 “鸭子类型/概念”(只要 Strategy 有 fight() 即可),不需要 抽象基类 或 虚函数,若不满足约束,编译期报错
    • 传统继承 则通过抽象基类与 virtual 接口约束,错误更多在运行期显现(例如误用多态删除、纯虚未实现等)

由于 C++ 的语法确实会让 knight<Strategy> 可转换为 Strategy&,但这只是实现细节上的 “继承”,并非设计语义,实际工程里常用 私有继承 或 成员组合 来避免暴露 “是一个 Strategy” 的错觉

用约束把策略的需求说清楚,并避免暴露 is-a 关系(用私有继承或成员)

template<class S> // FightStrategy 必须有 fight 方法
concept FightStrategy = requires(S s){ s.fight(); };
 
template<FightStrategy S>
struct knight : private S {                // 私有继承:表达“以之实现”,而非 is-a
    using S::fight;                        // 需要时公开某些成员
    void attack() {
        // attack logic...
        fight();                           // 静态绑定,可内联
    }
};
// 或者成员组合(没有 EBO (空基类优化) 就选这个更语义化)
template<FightStrategy S>
struct knight2 {
    S strategy;
    void attack(){ strategy.fight(); }
};
 

Mixin 用 “继承语法” 做 “组合语义”,实现的是编译期静态多态;传统继承则是运行期子类型多态。前者强调把正交能力拼装成新类型并获得零开销分派,后者强调is-a 与可替换性、适合跨二进制扩展

标签派发

另一种常见的静态派发方法是通过在函数参数中写不同类型的 标签,这些 标签 一般是空结构体,然后就可以利用编译时函数重载决议派发执行不同实现

我们知道不同容器具有的访问特性不同,有些容器支持随机访问,例如 std::vector,它的迭代器 it 向前移动 n 步只需要执行一次 it += n 计算的时间复杂度是 O(1) 的;但是对于 std::list 来说就不一样了,它并非可随机访问的容器,迭代器 it 向前移动 n 步需要执行 n 次的 ++it,时间复杂度是 O(n)

如果我们想实现一个通用的将迭代器向前推进 n 步的函数 advance 该怎么办呢?

我们可以利用标签派发如下

namespace details {
    // 可随机访问迭代器的重载
    void advance (
        Iter& it,
        Distance n,
        std::random_access_iterator_tag
    ) {
        it += n; // O(1)
    }
 
    // 不可随机访问迭代器的重载
    void advance(
        Iter& it,
        Distance n,
        std::bidirectional_iterator_tag
    ) {
        while (n--) ++it; // O(n)
    }
}
 
// 主分发函数
template <typename Iter, typename Distance>
void advance(Iter& it, Distance n)
{
    details::advance(
        it, 
        n, 
        typename std::iterator_traits<Iter>::iterator_category{} // 利用函数重载决议选择不同的实际 advance 实现
    );
}

动态多态

动态多态在 运行时 确定要调用的具体实现,一般需要支付 动态派发 所需的开销

虚函数 (子类型多态)

通过虚函数实现多态可能是最为经典的方法,一般的实现方式是

  • 将一些具有 相同行为 的对象抽象为一个 类 class
  • 在运行时构造 类 class 的 实例 来得到实际可用的 对象

所共有的 行为 被表述为 成员函数/方法 存储于 类 中,而 数据/状态 则被存储于类的实例—— 对象 中,这样就避免了 成员函数 的实现被重复存储在每一个 对象 中,并且可以提供了实现 动态多态 的可能,具体来说,是通过在希望实现 多态 的 成员函数 上标注 virtual 关键字,使之成为 虚函数

  • 当一个 类 包含 虚函数 时,该类生成 对象 时将会向其中隐含的加入一个不可见的 指针,称为 vptr (虚指针),这个 vptr 指向该类对应的 虚函数表 (vtable),一般被存储于程序的 静态存储区,每个 类 都只有一个各自的 vtable,但该 类 的每个 对象 都将含有一个指向各自所属 类 的 vtable 的 vptr

    • 具体来说,是在 对象 初始化时 vptr 被初始化指向对应 类 的 vtable
  • 派生类 通过 继承 来获得 基类 的 虚函数 (复用 基类 行为),同时覆盖某些 基类 的 虚函数 以提供自己的独特行为

    C++ Virtual Dispatch

    Base* 定 slot;vptr 选 vtable

    C++ Virtual DispatchBase pointer p points at a Derived object. The static type chooses the virtual function slot, then runtime dispatch loads the object vptr, reads the Derived vtable slot, and calls Derived::f with this bound to the object.调用现场Base* p = &d;p->f();静态类型 Base* 只决定 slot[f] = 0动态类型: DerivedBase subobjecthidden vptr&Derived vtableBase fieldsx, flagsDerived fieldscache, id读取 slot[0]slot 0 f()&Derived::fslot 1 g()未覆盖&Base::gslot 2 id()未覆盖&Base::idslot 编号在调用点已知表由 vptr 在运行期选择Derived::f()this = &d读取 vptr同一行调用语法保持 Base*;最终实现由对象的 vptr 指向哪张 vtable 决定。
    虚调用先用静态类型确定固定槽位,再在运行期从对象读取 vptr,进入动态类型的 vtable,最后跳到对应 slot 里的覆盖函数。

    具体表现为,基类 和 派生类 都将有自己各自的 虚函数表,而 派生类 会自己提供某个 虚函数 实现,然后找到自己 虚函数表 中对应被覆盖的那一项,将它修改为自己函数实现的 地址,而其余不覆盖的 虚函数 则依旧指向 基类 的实现地址,这一切都发生在 编译期,在 运行时 该类的 实例对象 所含的 vptr 将会指向类中被修改的 vtable 从而实现多态调用

    虚表的具体存放和生成属于实现细节,并未在标准中进行规定

  • 最终,在 运行时,通过 引用语义 即可触发多态调用 (当然,需要调用 虚函数),同时允许 派生类 对象绑定到 基类 的 指针/引用

    • 动态分派 条件

      1. 被调用的是 virtual 成员函数
      2. 调用表达式的 静态类型 是一个(多态 的)类类型
      3. 实际指向的对象类型将决定 动态类型
        • 指向 Base 对象本身 → 动态类型是 Base → 调用到 Base::f()
        • 指向 Derived 对象里的 Base 子对象 → 动态类型是 Derived → 调用到 Derived::f()(必要时经 thunk 调整 this)
    • 按值(不使用 引用语义)不会触发多态:会发生 对象切片

    • 作用域限定(p->Base::f())会绕开虚分派

    • 构造/析构 期间的虚调用按当前正在 构造/析构 的类分派

      • 在常规(对象完成构造之后)的虚函数调用中,分派会向下“落到”最派生覆盖者(final overrider)
      • 构造/析构期间,虚调用被限制:只按当前正在执行其构造/析构函数的那个类来分派,不会向下探到更派生的覆盖者
      #include <iostream>
      struct B {
      B() { f(); }                 // 构造中调用
      virtual ~B() { f(); }        // 析构中调用
      virtual void f(){ std::cout << "B\n"; }
      };
       
      struct D : B {
      D() { /* 这里调用 f() 会到 D::f(此时“当前类”是 D)*/ }
      ~D() override {}
      void f() override { std::cout << "D\n"; }
      };
       
      int main(){
      D d;
      }

      实际输出

      B   // 在 B::B() 里调用 f(),禁止向下 → B::f
      B   // 在 B::~B() 里调用 f(),禁止向下 → B::f
    • 不必须是基类指针/引用
      不使用 Base 类型的 指针/引用,用 Derived& / Derived* 调虚函数也会“走虚机制”,但这时 静态类型 已是 最派生,编译器通常直接可以在 编译期 确定目标(可去虚拟化/直接内联),没有“跨类型替换”的意义,除非 Derive 本身还被其它类继承 (也就是说它不是 最派生)

      但唯有以“基类视角”调用 (也就是使用 Base&/Base*),才实现我们通常说的“面向基类的运行时多态”,否则只是“对已知派生类型的虚函数调用”,多态价值不大

    struct Base { virtual void f(){ /*B*/ } void g(){ /*B*/ } };
    struct Derived : Base { void f() override { /*D*/ } void g(){ /*D*/ } };
     
    Derived d;
     
    // ✅ 引用/指针:多态生效
    Base& r = d;   r.f(); // 调到 Derived::f
    Base* p = &d;  p->f(); // 调到 Derived::f
     
    // ❌ 按值:发生切片
    void call(Base b){ b.f(); } 
    call(d);         // 这里 b 里只有 Base 子对象 → 调到 Base::f
     
    // ❌ 非虚:静态绑定
    p->g();          // 静态按 Base 的接口解析 → Base::g
     
    // ❌ 强制限定:绕开虚分派
    p->Base::f();    // 明确要 Base::f
     
    // ✅ 智能指针同理
    std::unique_ptr<Base> up = std::make_unique<Derived>();
    up->f();         // Derived::f
     
    // 以派生视角:虚机制存在,但目标已知(常被内联)
    Derived& rd = d; 
    rd.f();   // 调到 D::f(多态意义不大)
     
    // 以基类视角:真正的“运行期多态”
    Base& rb = d; 
    rb.f(); // 调到 D::f(经 vptr/vtable)

以下是一个实际的例子

struct game_unit
{
    virtual void attack() = 0;
};
 
struct knight : game_unit {
    void attack() override {
        std::cout << "draw sword\n";
    }
};
 
struct mage : game_unit {
    void attack() override {
        std::cout << "spell magic curse\n";
    }
};
 
void fight(std::vector<game_unit*> const & units)
{
    for (auto unit: units)
    {
        unit->attack() // 运行时多态决议
    }
}

这种方法相对 静态多态 多态最大的优点是它可以将不同类型的对象放入同一个 基类容器 中进行统一管理 (这是因为 子类型多态 实际上是 is-a 关系)

std::vector<game_unit*>
  • 注意要使用 引用语义,值语义 会发生 切片 从而丢失多态性

但缺点就是它拥有运行时开销,每一次的 虚函数 调用都需要一次间接内存访问来查找 虚表,并且每个对象实例都需要多存一个指向 虚表 的 指针

虚指针 之所以指向 虚表 而不是直接指向具体的实现函数是因为每个类通常有不止一个虚函数,若对象直接存 “实现函数指针”,那要么只能对应某一个虚函数,要么对象里必须为每个虚函数各放一根指针,对象体积随虚函数数目线性膨胀

而 vtable 把 “这一类/这一子对象的所有虚函数入口” 集中起来,实例只需一根 vptr,所有同类实例共享同一张表,内存更省

在 编译期 对每个 “虚调用点” 确定一个固定槽位 索引(按声明顺序和 ABI 规则),运行时只做三步

load vptr      // 从对象取表指针
load entry[k]  // 取第 k 个槽位的函数地址(可能是 thunk)
call           // 调用

这样生成的机器码短小、可预测,并且不同编译单元/动态库之间能按统一 ABI 契约配合

而在 多重/虚继承 下,派生类覆盖基类虚函数时,调用常常需要先把 this 从 “当前子对象” 的地址调整到 “实现函数所期望的子对象” 地址,vtable 槽位里可以直接放“调整 + 跳转”的 thunk,把 this 修正后再跳到真正实现

不只是函数地址,vtable 还承载元数据,按照常见 ABI(如 Itanium),vtable 前部/邻接区域还放

  • 偏移量(offset-to-top)、虚基偏移表:支持 dynamic_cast、交叉基类转换;
  • RTTI 指针(type_info):支持 typeid/dynamic_cast;
  • 析构/“删除析构”(deleting destructor)等特殊槽位

这些都不是 “直接指向实现函数” 能表达的

并且同一动态类型的所有实例共享同一张 vtable,这可以节省大量重复的函数指针存储,链接时把派生类覆盖填进表即可,实例布局无需改变,编译器还能在能确定动态类型时做去虚化(直接静态调用),但布局仍保持通用

最终我们可以对 继承多态 (virtual) 总结如下

  • 绑定方式
    通过语言级 “子类型关系” 把接口和实现绑死在类层级里;基类含有虚函数,派生类覆盖
  • 对象布局
    具体对象内存里嵌着一个或多个 “vptr” (指向 vtable 的指针)。vptr 通常放在对象的开头(单继承时几乎都这样;多/虚继承时会更复杂,可能有多个 vptr/调整 thunk)
  • 表是谁造的
    编译器按 ABI 约定生成 vtable
  • ABI/二进制边界
    强依赖编译器 ABI 和构建选项;跨 DLL/so 需要同一 ABI,否则危险
  • 扩展性方向
    易加类型、难加方法,加一个新的派生类很容易;但想在基类新增虚函数,就会影响所有派生类和 ABI;类型维度开放、方法维度封闭(Open for types, closed for methods)

类型擦除

另一种实现动态多态的方法是类型擦除,以 std::function、std::any、entt::poly、boost::type_erasure 为代表

  • 绑定方式
    把 接口 做成一个外部的 协议/概念;运行时把任意满足该 协议 的对象 “塞进去”,对外只暴露 协议 里那几项操作

  • 对象布局
    典型的 类型擦除包装器 里有两样东西

    • 指向被包对象的指针 void* obj(或小对象内嵌 SBO)
    • 指向一张“手工/模板生成的函数指针表”的指针 const ops* table(有时叫 ops/table/vtable,但这是库层面的表,不是编译器的 vtable)
  • 表是谁造的
    由 库/模板 在每个具体 T 上静态生成一张表(每个操作是一条函数指针,如 void(*)(void*)),或直接内联为静态 constexpr 结构

  • ABI/二进制边界
    因为表是库层创建的 “C 风格函数指针集合”,通常比语言级 vtable 更可控;很多实现不需要导出“类层级符号”,跨边界更容易做稳定封装

  • 扩展性方向
    易加类型,也易加 “概念的不同实现”;但想给现有概念再加新操作,需要重新编译使用处(因为表的布局变了)

可以发现,不同于编译器自动根据 ABI 约定生成的 vtable,类型擦除的 表 由 库/我们自己 生成,它的存放位置通常是 静态存储期 的 constexpr/static 变量(只读数据段),或模板实例化产生的 内部链接 对象,且该对象一般是一个 包装,其中含有指向被包装对象 void* obj 的指针(或小对象缓冲区内直接存放),以及指向函数表的指针 const ops* table,而该表的结构是 我们/库 自定义的,最常见是每个操作一条 函数指针(必要时带 this 调整/捕获类型)

所以,类型擦除 下的 “表” 完全由你掌控:布局、是否带类型信息、是否记录析构/移动/拷贝、是否 SSO,全部可设计

  • 性能开销
    与 继承多态 相比,两者在调用点都表现为一次指针间接 + 跳转(分支预测友好时很接近),而 继承多态 的虚调用通常不可内联(除非编译器能“去虚化”——LTO、final、单态分析…);不过 类型擦除 通过表的调用同样是 运行时,也不可内联;但如果在外层写的是模板并在静态已知 T 的地方直接调用(不经过表),就能内联——这已经不是 “运行期多态” 了

  • 内存/分配
    虽然 继承多态 本身并不要求 对象 必须是 堆分配 的,但是 工程实践 往往要求我们这么做,这是因为 传统继承 存在 按值切片 问题,把 Derived 以值传给 Base 会被 截断 为 Base

    例如,我们需要一个 异质容器 用于存放不同派生类,此时我们不能用 std::vector<Base>,会发生切片,通常使用 std::vector<std::unique_ptr<Base>>

    又或者,需要 工厂 返回 多态对象,此时 按值返回 也会导致 切片,通常需要返回 指针 类型 std::unique_ptr<Base>/std::shared_ptr<Base>

    所以,实际上 继承多态 调用必须通过 指针 或 引用;按值会 切片,丢失 动态类型

    而 类型擦除 没有这个问题,按值传的是 包装器 本身(如 std::function, 自己写的 AnyDrawable 等),里面保存 “对象指针/小对象缓冲 + 自己的函数指针表”,复制/移动包装器不会把动态对象“截断”,多态性照样保留;同时,类型擦除 既可以不分配内存(非拥有型 view,如 entt::poly 的引用用法),也可以用小对象优化(SBO)避免堆分配,对象小于缓冲就就地存放,太大才堆分配(std::function、很多 any/poly_value 实现都这样)

    维度继承多态(virtual)类型擦除(ops/dispatch 表)
    按值传递会切片(危险)传的是包装器值, 不切片
    指针/引用要求需要(避免切片)不需要(值语义 很常见)
    是否必须堆否(但工程上常用)否(SBO/非拥有 型均可),大对象才可能堆
    调用开销1 次间接(vtable)1 次间接(自建 表)
    所有权通常用 unique_ptr/shared_ptr可选:拥有(box)/非 拥有(view)

一种实现 类型擦除 的方法

// C++20 概念:要求有 void attack()
template <class T>
concept Attackable = requires(T& t) {
    { t.attack() } -> std::same_as<void>;
};
 
struct unit
{
    // 用 Attackable 约束类型必须有 attack 方法
    template <Attackable T>
    explicit 
    unit(T&& obj) 
    // 避免模板构造函数“吞掉”拷贝/移动构造,unit 自己也有 attack(),因此它本身也满足 Attackable,没有额外约束时,unit u2{u1}; 可能匹配到你的模板构造而不是拷贝构造,所以补一条下面的排除约束
    requires (!std::same_as<std::decay_t<T>, unit>)
    : unit_(
        std::make_shared<
            unit_model<
                std::decay_t<T> // 模板实参 T 可能被推导成引用类型(比如 X&), 需要进行衰减才能让 unit_model 实例化正确的模板类型
            >
        >(
            std::forward<T>(obj) // 传入用于构造的对象不需要衰减类型,走完美转发
        )
    ) {} // 完美转发构造函数 + decay
 
    // 统一接口,将调用转发给内部的适配器
    void attack() {unit_->attack();}
 
    // 抽象基类,定义了所有被 装箱/包裹 的对象都必须拥有 attack() 方法,也就是定义了必须满足的接口
    struct unit_concept {
        // 抽象基类要有虚析构函数,否则通过基类指针销毁会 UB
        virtual ~unit_concept() = default;
        virtual void attack() = 0;
    };
 
    // 真正的适配器,它继承自 unit_concept 并持有一个满足 cocept 要求的对象,将调用转发给内部持有的实际对象
    template <typename U>
    // final 有助于编译器做去虚化等优化
    struct unit_model final : public unit_concept {
        template <typename V> // 完美转发而来保留值类型,调用对应的构造函数 (拷贝构造或者移动构造)
        explicit unit_model(V&& v) : t(std::forward<V>(v)) {}
        void attack() override {t.attack();}
    private:
        U t; // U 已经是 decayed 类型
    };
 
private:
    std::shared_ptr<unit_concept> unit_;
 
}

这里的关键发生在 unit 的 构造函数 中,当我们用 knight 或 mage 等拥有 attack() 方法 (满足 unit_concept 要求) 的类型的对象来构建一个 unit 时,会在 unit 的 构造函数 中创建一个 unit_model 的实例,然后该实例用 std::shared_ptr<unit_concept> 这样的 基类指针 存储,这样一来,unit 对象本身只知道内部持有的对象是一个 unit_concept 的指针(也就是满足 cocept 条件,拥有 attack() 接口方法),并不知道指针背后的实际对象是什么 类型,如此一来原始类型信息 knight 和 mage 就被擦除,只留下装入对象都满足 unit_concept 这个事实,于是可以将这些 unit 对象放到同一个容器中进行管理,比如 std::vector<unit>,然后统一调用它们的 attack() 方法,就像 继承多态 中做的那样

类型擦除 的优势在于 解耦,它可以让任何不相关的类型都能在运行时被统一处理,只要它们满足约定的语法,比如都有 attack() 方法,并且这种多态方式是非浸入性的,不需要去修改原始类型 knight 或 mage 的代码去让他们去 继承 任何东西

上面实现 类型擦除 使用了 继承多态 来实现,实际上 不一定要靠“继承+虚函数”。类型擦除的核心是:对外只暴露一组“操作”,把具体类型隐藏在实现细节里。这些“操作”可以通过很多方式实现,最常见有两大类:

  1. 继承式(之前示例的 concept/model 基类方案)
  2. 无继承的“手工 vtable/函数指针表”方案(std::function 基本就是这么做的)

一个无继承实现的极简教学版(堆上存对象 + 函数指针做派发):

#include <type_traits>
#include <utility>
 
template <class T>
concept Attackable = requires(T& t) {
    { t.attack() } -> std::same_as<void>;
};
 
struct unit {
    using attack_fn = void(*)(void*);
    using delete_fn = void(*)(void*);
 
    void*     p_        = nullptr;
    attack_fn do_attack = nullptr;
    delete_fn do_delete = nullptr;
 
    template <Attackable T>
    explicit unit(T&& x) {
        using U = std::decay_t<T>;
        p_ = new U(std::forward<T>(x));                     // 按值存到堆上
        do_attack = [](void* p) { static_cast<U*>(p)->attack(); };
        do_delete = [](void* p) { delete static_cast<U*>(p); };
    }
 
    unit(const unit&)            = delete;  // 教学版,简单起见
    unit& operator=(const unit&) = delete;
 
    unit(unit&& o) noexcept
        : p_(o.p_), do_attack(o.do_attack), do_delete(o.do_delete) { o.p_ = nullptr; }
 
    unit& operator=(unit&& o) noexcept {
        if (this != &o) {
            reset();
            p_ = o.p_; do_attack = o.do_attack; do_delete = o.do_delete;
            o.p_ = nullptr;
        }
        return *this;
    }
 
    ~unit() { reset(); }
 
    void attack() { do_attack(p_); }
 
private:
    void reset() { if (p_) do_delete(p_), p_ = nullptr; }
};

要点:

  • 没有任何继承/虚函数;靠函数指针表完成动态派发。
  • 用 new U(...) + delete_fn 管理生命周期;教学版为简洁禁了拷贝(可按需加拷贝/移动策略或改成小对象优化 SBO)。
  • 若想无堆分配,可把 void* + new/delete 换成固定大小缓冲区(SBO),再配套 copy/move/destroy 三个函数指针——这就是 std::function/std::any 的典型做法。

顺带对比一下几种手段:

  • 继承式:写起来直观,易讲解;多一次间接调用,常需堆分配(也可做 SBO)。
  • 手工 vtable(无继承):零基类,灵活可控,易做 SBO,std::function/std::any 都是它的亲戚。
  • std::variant + 访问器:不算严格意义上的“类型擦除”(类型集合封闭),但也能“无继承”实现统一调用。
  • 纯模板/CRTP:是静态多态,不是运行时的“类型擦除”。

结论:类型擦除 ≠ 必须继承。可以按场景选 “继承式” 或 “手工 vtable” 式,二者本质都是把“怎么调用具体类型”的细节藏在一个统一的壳里。

原型委托

面向对象这种范式存在一些基本的共同特征,即

  • 根据某个“设计蓝图”创建对象并共享 行为
  • 新对象里主要存 数据状态, 行为 代码则在对象之间共享(不随实例复制)

类式 的 “设计蓝图” 是 类型定义 Class,需要先定义类型,再实例化对象,对象实例的 类型 是固定且 名义化 nominal 的,典型的例子是 C++ 的传统面向对象和 Python 的面向对象

原型式 的 “设计蓝图” 则是 对象本身,也就是 Prototype 对象,类型更偏结构化/开放,最典型的例子是 JavaScript

在 类式 面向对象中 方法 归属于 类,对方法的调用要么在 编译期 直接决定 (非虚),要么经过 vtable/方法表 在运行时 动态派发 (虚函数/动态语言的类字典),共同行为的复用通过 继承 实现,对象 的最终行为由其类层次决定,一般会将其虚函数指向 最派生 的实现 (C++),或者查找方法字典 (Python)

而 原型式 面向对象中 方法 则直接归属到 对象 本身 (注意,是指语义上的归属,而非每个对象都拥有重复的实现代码,不会浪费内存),而共同行为通过 委托 获得 (而不是通过 继承),当调用 方法 时,将从对象出发一路沿 原型委托链 prototype delegation chain 查找对应实现;原型委托链 就是一串通过内部指针 [[Prototype]] 连接起来的 对象,读取一个 对象 的属性时,如果对象自己没有,就把查找向上“委托”给它的原型;还找不到就继续沿着原型的原型……一直到链尾 null 为止

可以发现,类式 的面向对象必须先有 类型 Class 定义,再实例化对象;类型是名义化的,实例行为由类层次决定;方法集对所有该类实例一致,若想给 “某一个实例” 改行为,通常要造新的子类

struct A { virtual void speak() { /*...*/ } };
A a1, a2;
// 想“只改 a1 的 speak”?做不到;必须定义子类并让 a1 成为那个子类的实例。

而 原型式 则先有 “对象”,新对象通过 委托/克隆 指向另一个对象(原型),原型本身就是一等对象 (不是类型),行为从实例出发沿 原型链 逐层委托,既可改原型(全体实例受影响),也可只给某个实例加/改方法(遮蔽原型),粒度更细

const proto = { speak(){ console.log(this.name); } };
// 无需预声明字段:JS 对象是字典,属性可随时加
const a = Object.create(proto); a.name = 'A';
const b = Object.create(proto); b.name = 'B';
 
// 若没给 a.name 赋值,也不报错,只会沿原型链再找;都没有就得到 undefined
// 也可以放在原型上: proto.name = 'P';
 
a.speak === b.speak;   // true  ← 共享同一函数对象
proto.speak = function(){ console.log('v2', this.name); };
a.speak(); b.speak();  // 两者都被“热更新”
 
// 只改某个实例(遮蔽原型)
a.speak = function(){ console.log('only A'); };
a.speak(); // only A
b.speak(); // v2 B
 

事实上,在 JavaScript 里,class 只是对原型机制的语法糖;“看起来像类”,运行时仍然是 原型委托