C++ 继承与多态

C++ 继承与多态

十二月 12, 2025 次阅读

继承(Inheritance)

在 C++ 中,继承是一种通过已有类型构建新类型的机制,用来表达“一个类是另一个类的一种特殊形式”。它是面向对象编程(OOP)的核心概念之一,主要用于代码复用、语义抽象和多态支持

从本质上讲,继承并不是简单的代码复制,而是一种类型关系的建立


继承解决了什么问题?

在没有继承的情况下,如果多个类拥有相同的数据成员或成员函数,通常只能通过复制代码的方式实现,这会带来:

  • 重复代码多,维护成本高
  • 修改公共逻辑时容易遗漏
  • 类型之间缺乏统一抽象

继承的出现,允许我们将公共部分抽象到一个基类中,由派生类复用并扩展,从而解决这些问题。


基类与派生类

继承关系中涉及两类角色:

  • 基类(Base Class)
    提供公共接口和通用实现,表示“更抽象、更通用”的概念。

  • 派生类(Derived Class)
    在基类的基础上进行扩展,表示“更具体、更特化”的概念。

派生类在语义上应满足:

派生类 is-a 基类

例如:

  • Student is a Person
  • Circle is a Shape
  • Dog is an Animal

如果不满足 is-a 关系,就不应该使用继承。


继承的基本定义形式

在 C++ 中,继承通过在类定义中指定基类来实现:

class Derived : public Base {
    // 派生类定义
};

这里的含义是:

  • Derived 继承自 Base
  • Derived 拥有 Base 的成员(受访问控制影响)
  • Derived 是一个新的类型,而不是 Base 的别名

继承的本质:建立类型层次

继承在语言层面建立了一种类型层次结构(type hierarchy)

  Base
   ↑
Derived

这种层次结构使得:

  • 派生类对象可以被当作基类对象来使用(向上转换)
  • 可以通过统一的基类接口操作不同的派生类对象
  • 为多态(polymorphism)提供基础

这也是 C++ 支持运行时多态的前提条件。


继承与“拥有”的区别

继承表达的是 “是什么(is-a)”,而不是 “有什么(has-a)”

对比:

class Engine {};

class Car {
    Engine e;   // has-a(组合)
};
class Student : public Person {
    // is-a
};

错误使用继承往往会导致:

  • 类型语义混乱
  • 接口暴露不合理
  • 破坏可维护性

因此在设计中常见的一条原则是:

优先使用组合,而不是继承

只有当关系明确是 is-a 时,才选择继承。


继承与接口抽象

在 C++ 中,继承不仅用于复用实现,也常用于接口抽象

class Shape {
public:
    virtual double area() const = 0;
};
class Circle : public Shape {
public:
    double area() const override {
        return 3.14 * r * r;
    }
};

这里:

  • Shape 提供统一接口
  • 不关心具体实现
  • 通过基类指针/引用实现多态调用

总的来说:

  • 继承是一种 类型关系,而不是代码复制手段
  • 派生类是基类的一种特殊形式(is-a)
  • 继承用于代码复用、语义抽象和多态支持
  • 继承通过 class Derived : public Base 定义
  • 不满足 is-a 关系的设计应避免使用继承
  • 继承是 C++ 多态机制和类型转换规则的基础

派生类的默认成员函数

在 C++ 中,派生类同样遵循“默认成员函数”的生成规则。理解派生类的默认成员函数,必须先明确一个前提:
派生类本身也是一个类,它也有一套“编译器可能自动生成的成员函数”。

不过,与普通类相比,派生类的默认成员函数是否被生成、以及它们的行为,都会受到“基类”的直接影响


一、回顾:类的默认成员函数(简要)

对于一个普通类,编译器可能自动生成以下 6 个默认成员函数

  • 默认构造函数
  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数(C++11)
  • 移动赋值运算符(C++11)

这些函数是否生成,取决于:

  • 是否被用户显式声明
  • 成员是否可拷贝 / 可移动 / 可析构

二、派生类的默认成员函数是什么?

派生类拥有与普通类完全相同的一组默认成员函数

  • Derived()
  • ~Derived()
  • Derived(const Derived&)
  • Derived& operator=(const Derived&)
  • Derived(Derived&&)
  • Derived& operator=(Derived&&)

不同之处在于:

派生类的这些默认成员函数,会隐式地调用“基类对应的成员函数”。


三、派生类默认构造函数的行为

如果派生类没有显式定义构造函数,编译器会尝试生成默认构造函数:

class Base {
public:
    Base() {}
};

class Derived : public Base {
    int x;
};

等价行为(概念上):

Derived::Derived()
    : Base(), x()
{}

注意点

  • 基类必须可默认构造
  • 如果基类没有默认构造函数,派生类将无法自动生成默认构造函数
class Base {
public:
    Base(int);
};

class Derived : public Base {
};
// ❌ 编译错误:Base 无默认构造

四、派生类的默认析构函数

派生类的默认析构函数行为是固定的

~Derived()

析构顺序:

  1. 先析构派生类自身成员
  2. 再调用基类析构函数

这一步是 自动完成的,无法被绕过

多态场景的关键点

如果存在多态:

Base* p = new Derived;
delete p;

那么 Base 的析构函数必须是 virtual,否则会导致派生类析构函数不被调用。


五、派生类的默认拷贝构造函数

当派生类的拷贝构造函数被隐式生成时,其行为是:

Derived::Derived(const Derived& other)
    : Base(other),   // 拷贝基类子对象
      member(other.member)
{}

也就是说:

  • 先拷贝 基类子对象
  • 再拷贝 派生类自己的成员

关键限制

  • 基类必须 可拷贝构造
  • 否则派生类的拷贝构造函数会被 删除(= delete)

六、派生类的默认拷贝赋值运算符

默认生成的拷贝赋值行为:

Derived& Derived::operator=(const Derived& rhs) {
    Base::operator=(rhs);   // 赋值基类子对象
    member = rhs.member;    // 赋值派生类成员
    return *this;
}

同样:

  • 基类必须可拷贝赋值
  • 否则派生类的拷贝赋值会被删除

七、派生类的默认移动构造与移动赋值

C++11 之后,派生类也支持隐式生成移动操作。

默认移动构造行为:

Derived::Derived(Derived&& other)
    : Base(std::move(other)),
      member(std::move(other.member))
{}

默认移动赋值行为:

Derived& Derived::operator=(Derived&& rhs) {
    Base::operator=(std::move(rhs));
    member = std::move(rhs.member);
    return *this;
}

重要规则

  • 基类必须支持对应的移动操作

  • 如果基类只有拷贝,没有移动:

    • 派生类的移动操作可能被删除
    • 或退化为拷贝(取决于具体情形)

八、派生类默认成员函数的生成规则总结

派生类的默认成员函数是否生成,取决于 三方面

  1. 派生类自身是否显式声明了这些函数
  2. 派生类成员是否满足拷贝 / 移动 / 析构要求
  3. 基类是否支持对应操作

一句话总结:

派生类能否“默认生成某个成员函数”,
完全取决于“基类 + 成员 + 自身声明”的综合结果。


九、一个非常容易忽略的点

只要基类的某个特殊成员函数被删除或不可访问,
派生类对应的默认成员函数也会被隐式删除。

这是很多“为什么派生类不能拷贝 / 不能移动”的根本原因。


  • 派生类与普通类一样,拥有完整的一套默认成员函数
  • 派生类默认成员函数会 自动调用基类的对应成员函数
  • 基类是否支持构造 / 析构 / 拷贝 / 移动,直接决定派生类是否可用
  • 构造顺序:先基类,后派生类
  • 析构顺序:先派生类,后基类
  • 理解派生类默认成员函数,是理解切片、多态、对象模型的基础

继承中的菱形继承(Diamond Inheritance)

菱形继承是 C++ 多重继承中最经典、也最容易出问题的一种结构。理解它,基本就理解了 虚拟继承存在的根本原因

我们一步一步来:
先看问题 → 再看为什么错 → 最后看虚拟继承如何在底层解决。


一、什么是菱形继承?

所谓“菱形继承”,指的是类继承关系形成一个菱形结构:

graph TB
    A[Base]
    B[Derived1]
    C[Derived2]
    D[MostDerived]

    A --> B
    A --> C
    B --> D
    C --> D

代码形式如下:

struct A {
    int x;
};

struct B : public A {};
struct C : public A {};
struct D : public B, public C {};

二、菱形继承带来的核心问题

1️⃣ 数据二义性(最直观的问题)

D d;
d.x; // ❌ 编译错误:不明确

原因是:

  • D存在两份 A 子对象
  • 一份来自 B
  • 一份来自 C

编译器不知道你要的是:

d.B::x  // A via B
d.C::x  // A via C

2️⃣ 内存冗余(隐藏但更严重)

我们来看 没有虚拟继承时的对象内存布局

❌ 非虚继承下的内存结构

graph LR
    subgraph D_object["D 对象内存布局(非虚继承)"]
        subgraph B_part["B 子对象"]
            A1["A 子对象 (via B)"]
        end
        subgraph C_part["C 子对象"]
            A2["A 子对象 (via C)"]
        end
    end

等价内存示意(线性):

| A(B) | B | A(C) | C | D |

结果:

  • A重复存储两次
  • 状态可能不一致
  • 构造 / 析构 A 两次(非常危险)

三、为什么这是一个“设计层面的问题”?

菱形继承本质上在语义上通常是这样的:

B 是一种 A
C 是一种 A
D 既是 B,又是 C
👉 那 D 应该“只有一个 A”

这时:

  • 逻辑上:A 应该唯一
  • 语言默认行为:A 被复制了两份

➡️ 语言默认行为不符合语义需求


虚拟继承(Virtual Inheritance)—— 解决方案

四、什么是虚拟继承?

虚拟继承通过 virtual 关键字告诉编译器:

“这个基类在整个继承体系中只保留一份实例”

改写代码:

struct A {
    int x;
};

struct B : virtual public A {};
struct C : virtual public A {};
struct D : public B, public C {};

五、虚拟继承解决了什么问题?

✔ 只有一份 A 子对象

✔ 不再有成员二义性

✔ 构造 / 析构顺序可控


六、虚拟继承下的内存布局(核心)

✅ 虚拟继承后的对象结构

graph LR
    subgraph D_object["D 对象内存布局(虚拟继承)"]
        subgraph B_part["B 子对象"]
            vbptrB["虚基指针 vbptr"]
        end
        subgraph C_part["C 子对象"]
            vbptrC["虚基指针 vbptr"]
        end
        A1["唯一的 A 虚基类子对象"]
    end

线性理解:

| B(vbptr) | C(vbptr) | A | D |

关键变化:

  • A 不再嵌入在 B / C 中
  • A 被提升为 “虚基类子对象”
  • BC 只保存一个 虚基指针(vbptr)

七、虚基表(Virtual Base Table,vbtable)的作用

1️⃣ vbptr 是什么?

  • vbptr:指向虚基表的指针
  • 每个虚继承路径都有一个 vbptr

2️⃣ vbtable 存什么?

vbtable 中记录的是:

  • 从当前子对象
  • 虚基类子对象 A 的偏移量

示意:

graph TB
    vbptr --> vbtable
    vbtable --> offsetA["offset to A"]

访问 A::x 时:

// 伪代码
A* a = (char*)this + vbtable[offset_to_A];

八、对照:非虚继承 vs 虚拟继承(重点对比)

❌ 非虚继承

graph TB
    D --> B --> A1
    D --> C --> A2
  • A 被复制两份
  • 没有 indirection
  • 访问快,但语义错误

✅ 虚拟继承

graph TB
    D --> B
    D --> C
    B -->|vbptr| A
    C -->|vbptr| A
  • A 只有一份
  • 多一次间接寻址
  • 正确表达“共享基类”

九、构造与析构顺序的变化(重要)

非虚继承构造顺序

A(B) → B → A(C) → C → D

虚拟继承构造顺序

A → B → C → D

虚基类永远由“最派生类”负责构造

析构顺序完全相反。


十、虚拟继承的代价

虚拟继承并不是免费的:

  • 对象体积增大(vbptr)
  • 成员访问多一次间接寻址
  • 构造 / 析构更复杂
  • 对 ABI 和布局要求更高

所以:

只有在“确实需要共享同一基类实例”时,才使用虚拟继承


菱形继承会导致基类子对象被重复继承,引发成员二义性和内存冗余问题。
C++ 通过虚拟继承机制,使虚基类在整个继承体系中只保留一份实例。
虚拟继承的底层实现依赖虚基指针(vbptr)和虚基表(vbtable),
通过间接寻址定位唯一的虚基类子对象,从而解决菱形继承问题。


接下来,我们看一下下面这段代码:

#include <iostream>
using namespace std;

class Base {
public:
int a;
};

class Derived1 : virtual public Base {
public:
};

class Derived2: virtual public Base {
public:
};

class Derived : public Derived1, public Derived2 {
public:
};

int main() {
    Derived d;
    d.Derived1::a = 10;
    std::cout << d.Derived1::a << " " << d.Derived2::a << " " << d.a << std::endl;
    d.Derived2::a = 20;
    std::cout << d.Derived1::a << " " << d.Derived2::a << " " << d.a << std::endl;
    d.a = 30;
    std::cout << d.Derived1::a << " " << d.Derived2::a << " " << d.a << std::endl;
    std::cout << &(d.Derived1::a) << " " << &(d.Derived2::a) << " " << &(d.a) << std::endl;
    return 0;
}

运行结果为:

10 10 10
20 20 20
30 30 30
0xe715ffe20 0xe715ffe20 0xe715ffe20

这样一来,我们的上述论述就得到了验证:无论是通过 Derived1 还是 Derived2 访问 Base 的成员 a,实际上访问的都是同一份数据。


多态(Polymorphism)

在 C++ 中,多态指的是:

同一接口,在不同对象类型上表现出不同行为的能力。

它解决的不是“代码能不能运行”的问题,而是:

如何在不修改调用方代码的前提下,让程序在运行期选择正确的行为。


一、从直觉理解多态

先看一个最简单、最直观的例子:

class Animal {
public:
    virtual void speak() {
        std::cout << "Animal speaks\n";
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Dog barks\n";
    }
};

class Cat : public Animal {
public:
    void speak() override {
        std::cout << "Cat meows\n";
    }
};

调用代码:

Animal* a1 = new Dog();
Animal* a2 = new Cat();

a1->speak();  // Dog barks
a2->speak();  // Cat meows

这里:

  • 调用表达式完全一样a->speak()
  • 实际执行的函数不同
  • 决定权在 运行期(runtime)

这就是多态。


二、多态解决的核心问题

如果没有多态,你只能这样写:

if (type == DOG) { ... }
else if (type == CAT) { ... }

问题在于:

  • 每新增一个派生类
  • 就要修改所有 if / switch
  • 代码 违反开闭原则(Open–Closed Principle)

多态的目标是:

新增行为,而不修改已有代码


三、多态成立的三个必要条件(非常重要)

在 C++ 中,要实现运行时多态,必须同时满足:

1️⃣ 存在继承关系

2️⃣ 基类中有 virtual 函数

3️⃣ 使用基类指针或基类引用指向派生类对象

缺一不可。

Base* p = new Derived();   // ✔
Base& r = derived;        // ✔
Base obj = derived;       // ❌(对象切片)

四、静态绑定 vs 动态绑定

静态绑定(非多态)

class Base {
public:
    void foo() {
        std::cout << "Base\n";
    }
};

class Derived : public Base {
public:
    void foo() {
        std::cout << "Derived\n";
    }
};

Base* p = new Derived();
p->foo();  // Base::foo

原因:

  • foo 不是虚函数
  • 调用在编译期就确定
  • 实现的本质上是函数重定义(函数隐藏),而不是重写(Override)

动态绑定(多态)

class Base {
public:
    virtual void foo() {
        std::cout << "Base\n";
    }
};

class Derived : public Base {
public:
    void foo() override {
        std::cout << "Derived\n";
    }
};

Base* p = new Derived();
p->foo();  // Derived::foo

原因:

  • 函数调用通过 虚函数表(vtable)
  • 决策延迟到运行期
  • 实现的本质是函数重写(Override),底层会基于虚函数表进行动态分发

五、多态与“接口”的关系

在 C++ 中:

  • 抽象基类 ≈ 接口
  • 通过 纯虚函数表达“能力”
class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

派生类只需:

class Circle : public Shape {
public:
    double area() const override {
        return 3.14 * r * r;
    }
};

调用方只依赖 Shape

void printArea(const Shape& s) {
    std::cout << s.area() << std::endl;
}

六、多态与对象切片(Slice)

多态一定不能这样用:

Base b = Derived();  // 对象切片
b.foo();             // Base::foo

原因:

  • 只拷贝了 Base 子对象
  • 派生类部分被丢弃
  • vptr 指向 Base 的 vtable,导致调用的实际上是基类版本

👉 多态在此直接失效


七、多态在内存层面的本质(一句话)

多态本质上是:
通过基类指针访问对象时,借助对象内部的 vptr,在运行期选择正确的函数入口。

你后面讲的:

  • vptr
  • vtable
  • RTTI
  • dynamic_cast

都会围绕这一点展开。


八、多态的典型使用场景

  • 框架 / 库设计
  • 插件系统
  • 回调接口
  • 状态机
  • GUI / 游戏 / 渲染引擎
  • 面向接口编程

多态是面向对象的核心机制之一,它通过虚函数和继承,使得程序能够在运行期根据对象的实际类型选择正确的行为,从而实现高扩展、低耦合的设计。


虚函数表(Virtual Function Table,vtable)

虚函数表是 C++ 运行时多态的核心实现机制
如果说“多态是什么”是概念层面的理解,那么 vtable 决定了多态是如何真正发生的


一、为什么需要虚函数表?

先看一个问题:

Base* p = new Derived();
p->func();

在编译期:

  • 编译器只知道 p静态类型Base*
  • 但实际对象类型是 Derived

👉 编译期无法确定该调用哪个函数实现

解决办法只有一个:

把函数选择的决定权,延迟到运行期

虚函数表正是为此而生。


二、vtable 的基本概念

1️⃣ vtable 是什么?

虚函数表是一张函数指针表
表中存放的是:
👉 虚函数在当前类中的最终实现地址

2️⃣ 谁拥有 vtable?

  • 每一个“含有虚函数的类”
  • 都对应 一张 vtable
  • 不是每个对象一张表

3️⃣ 对象里存的是什么?

对象内部存的是一个 vptr(虚函数指针)

graph LR
    Object --> vptr --> vtable
    vtable --> f1["virtual func1"]
    vtable --> f2["virtual func2"]

三、一个最小可运行模型

class Base {
public:
    virtual void f() {
        std::cout << "Base::f\n";
    }
};

class Derived : public Base {
public:
    void f() override {
        std::cout << "Derived::f\n";
    }
};
Base* p = new Derived();
p->f();

四、内存层面的真实结构(关键)

1️⃣ 对象布局(简化)

graph TB
    subgraph Derived对象
        vptr["vptr"]
        data["普通成员"]
    end

2️⃣ vtable 内容

graph TB
    subgraph Derived_vtable["Derived 的 vtable"]
        f0["Derived::f"]
    end

p->f() 的执行过程等价于:

(*(p->vptr)[0])(p);

五、vtable 如何支持“覆盖(override)”?

派生类在生成自己的 vtable 时

  • 如果没有 override:

    • 继承基类的函数指针
  • 如果 override:

    • 用派生类函数地址 替换对应槽位

对照示意

graph LR
    subgraph Base_vt["Base vtable"]
        Bf["Base::f"]
    end

    subgraph Derived_vt["Derived vtable"]
        Df["Derived::f"]
    end

槽位顺序 保持一致,只是内容不同。


六、多个虚函数的情况

class Base {
public:
    virtual void f1();
    virtual void f2();
};

class Derived : public Base {
public:
    void f2() override;
};

vtable:

graph TB
    subgraph Derived_vtable
        slot0["Base::f1"]
        slot1["Derived::f2"]
    end

七、析构函数为什么必须是虚的?

Base* p = new Derived();
delete p;

如果析构函数不是虚的:

  • delete 调用的是 Base::~Base
  • Derived 资源泄漏

原因:

析构函数也是通过 vtable 调度的

正确写法:

virtual ~Base() = default;

八、vtable 是什么时候生成的?

  • 编译期生成

  • 放在:

    • .rodata
    • 或只读段
  • 所有对象共享

vptr:

  • 在构造函数中写入
  • 指向对应类的 vtable

九、构造 / 析构期间的特殊行为(非常容易踩坑)

class Base {
public:
    Base() { f(); }
    virtual void f() { std::cout << "Base\n"; }
};

class Derived : public Base {
public:
    void f() override { std::cout << "Derived\n"; }
};

输出:

Base

原因:

构造期间,vptr 只指向“当前正在构造的类”的 vtable
不会发生向下多态


十、vtable ≠ 标准保证(但事实标准)

重要说明:

  • C++ 标准 从未规定 vtable 必须存在

  • 但:

    • 几乎所有主流编译器都采用该模型
    • 已成为事实上的 ABI 标准

十一、vtable 与 vbtable 的区别(承上启下)

项目 vtable vbtable
目的 支持多态 支持虚继承
是否标准可观察 间接是
对象中 vptr vbptr
表内容 函数地址 偏移量

虚函数表是 C++ 实现运行时多态的核心机制。对象中通过虚函数指针指向类级别的虚函数表,函数调用在运行期通过该表间接完成,从而实现“基类接口、派生类行为”的多态效果。


多继承中的虚函数表(Multiple Inheritance vtable)

在单继承中,一个对象通常只有一个 vptr 和一张 vtable
而在多继承中,这个结论会被彻底打破:

多继承会引入多个 vptr、多张 vtable,以及 this 指针调整(this-adjustment)。

这一节是理解 C++ 对象模型的“分水岭”。


一、为什么多继承会让 vtable 变复杂?

先看一个最小示例:

class A {
public:
    virtual void fa();
};

class B {
public:
    virtual void fb();
};

class C : public A, public B {
public:
    void fa() override;
    void fb() override;
};

此时:

  • C 同时是 A 和 B
  • 通过 A*B* 访问 C
  • this 指针的起始位置 并不相同

二、C 对象的内存布局(核心)

graph TB
    subgraph C对象
        A_part["A 子对象<br/>vptr_A"]
        B_part["B 子对象<br/>vptr_B"]
        C_data["C 自身成员"]
    end

关键点:

  • A 子对象在偏移 0

  • B 子对象在偏移 ≠ 0

  • 每个“含虚函数的基类子对象”都需要:

    • 自己的 vptr

👉 所以 C 至少有两个 vptr


三、多张 vtable 是如何存在的?

每一个“基类视角”都需要一张 vtable:

graph LR
    vptr_A --> vtable_A_for_C
    vptr_B --> vtable_B_for_C

注意命名:

  • vtable_A_for_C
  • vtable_B_for_C

它们都是 为 C 量身定做的


四、vtable 内容不只是函数指针(重点)

在多继承下,vtable 中的函数入口通常不是“裸函数地址”,而是:

this-adjusting thunk(调整 this 的小函数)(将会在后面详细讲解thunk函数)

原因

B* pb = static_cast<C*>(obj);
pb->fb();

此时:

  • pb 指向的是 B 子对象起始地址

  • C::fb 期望的 this

    • C*B* + 偏移

五、this 指针调整机制(非常关键)

在单继承当中,基类和派生类的 this 指针是相同的;但在多继承中,不同基类视角下的 this 指针可能不同

Derived 类继承于 Base1Base2 时:

  • 通过 Base1* 访问 Derived 时,this 指向 Derived 对象的起始位置
  • 通过 Base2* 访问 Derived 时,this 需要调整到 Derived 对象中 Base2 子对象的起始位置

问题就出在这里了,如果指向 Base2 子对象的 this 直接传给 Derived 的成员函数,会导致访问错误,我们需要保证 this 指向正确的位置。

此时,编译器会生成一个 this-adjusting thunk,它的职责就是:

  • 接收 Base2* 形式的 this
  • 调整 this 指针到 Derived 对象的正确位置

thunk 的职责:

this = this + offset_to_C;
call C::fb(this);

六、一个具体 vtable 示例(概念级)

A 视角 vtable(C)

vtable_A_for_C:
[0] &C::fa

B 视角 vtable(C)

vtable_B_for_C:
[0] &thunk_adjust_and_call_C_fb

七、派生类 override 如何反映到多张 vtable?

  • C::fa 覆盖 A::fa

    • 替换 A-vtable 中对应槽
  • C::fb 覆盖 B::fb

    • 替换 B-vtable 中对应槽
  • 两张表 互不干扰


八、为什么不能“只用一张 vtable”?

原因在于:

  • 不同基类视角
  • this 指向不同子对象起点
  • 同一个函数需要不同的 this 修正

👉 单 vtable 无法同时满足多个入口语义


九、菱形继承 + 虚函数 = 终极复杂形态

class A {
public:
    virtual void f();
};

class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {
public:
    void f() override;
};

此时对象中可能同时存在:

  • vptr(多态)
  • vbptr(虚继承)
  • vtable
  • vbtable
  • this-adjust thunk

示意:

graph TB
    D --> B --> vbptr
    D --> C --> vbptr
    D --> vptrs["多个 vptr"]
    vbptr --> vbtable
    vptrs --> vtable

十、性能与代价

项目 影响
vptr 数量 增加
vtable 数量 增加
调用路径 多一次 thunk
对象体积 增大
ABI 复杂度 很高

但这是 语义正确性换来的代价


十一、工程实践建议

  • ❌ 避免“非接口型”的多继承

  • ✅ 多继承主要用于:

    • 纯接口(无数据成员)
    • mixin
  • ❌ 数据 + 多继承 + 虚函数 = 高风险


在多继承中,每个含虚函数的基类子对象都会引入自己的虚函数指针和虚函数表。为了保证通过不同基类指针调用派生类函数的语义正确,编译器会在虚函数表中引入 this 指针调整机制,使得多态调用在复杂继承结构下仍然能够正确工作。


多继承举例

#include <iostream>

class Base1 {
public:
    virtual void func1() {std::cout << "Base func" << std::endl;}
    virtual void func3() {std::cout << "Base func3" << std::endl;}
    virtual ~Base1() {std::cout << "Base destructor" << std::endl;}
public:
    int a;
    int b;
    int c;
    static int d;
};

int Base1::d = 0;

class Base2 {
public:
    virtual void func2() {std::cout << "Base func" << std::endl;}
    virtual void func4() {std::cout << "Base func4" << std::endl;}
    virtual ~Base2() {std::cout << "Base destructor" << std::endl;}
public:
    int e;
    int f;
    int g;
    static int h;
};

int Base2::h = 0;

class Derived : public Base1, public Base2 {
public:
    void func1() override {std::cout << "Derived func1" << std::endl;}
    void func2() override {std::cout << "Derived func2" << std::endl;}
    void func5() {std::cout << "Derived func5" << std::endl;}
    ~Derived() {std::cout << "Derived destructor" << std::endl;}
public:
    int i;
    int j;
    int k;
};

该继承结构是一个标准的非虚多继承 + 虚函数覆盖

graph TB
    Base1
    Base2
    Derived

    Base1 --> Derived
    Base2 --> Derived

特点:

  • Base1 和 Base2 各自都有虚函数
  • Derived 分别 override 了 Base1::func1 和 Base2::func2
  • Base1 / Base2 都有虚析构
  • 无虚继承(没有 vbptr)

Derived 对象的整体内存布局

主流 ABI(MSVC / Itanium) 下,Derived 对象布局可抽象为:

graph TB
    subgraph Derived对象
        subgraph Base1_part["Base1 子对象"]
            vptr1["vptr(Base1视角)"]
            a
            b
            c
        end

        subgraph Base2_part["Base2 子对象"]
            vptr2["vptr(Base2视角)"]
            e
            f
            g
        end

        subgraph Derived_part["Derived 自身成员"]
            i
            j
            k
        end
    end

关键结论:

👉 Derived 对象中有两个 vptr

  • 一个属于 Base1 子对象
  • 一个属于 Base2 子对象

为什么必须有两张 vtable?

因为下面这两种调用是完全不同的语义入口

Base1* p1 = new Derived();
p1->func1();

Base2* p2 = new Derived();
p2->func2();
  • p1 指向 Derived 对象的起始地址
  • p2 指向 Derived 对象中 Base2 子对象的起始地址

👉 this 指针起点不同
👉 必须有 不同的 vtable 入口


Base1 视角的 vtable(Derived 版)

Base1 自身的虚函数声明顺序

virtual void func1();
virtual void func3();
virtual ~Base1();

Derived 对 Base1 的 override 情况

函数 是否 override
func1 ✅ 是
func3 ❌ 否
~Base1 ✅(通过虚析构链)

Base1 视角下的 vtable(Derived)

graph TB
    subgraph vtable_Base1_for_Derived
        slot0["Derived::func1"]
        slot1["Base1::func3"]
        slot2["Derived::~Derived (析构 thunk)"]
        slot3["Base1::~Base1"]
    end

说明:

  • func1 → 被 Derived 覆盖

  • func3 → 继承 Base1 实现

  • 析构函数:

    • 通过虚表保证 delete Base1* 正确析构 Derived

    • 实际实现通常是:

      • thunk → 调整 this → 调用 ~Derived

Base2 视角的 vtable(Derived 版)

Base2 的虚函数顺序

virtual void func2();
virtual void func4();
virtual ~Base2();

Derived override 情况

函数 是否 override
func2 ✅ 是
func4 ❌ 否
~Base2

Base2 视角下的 vtable(Derived)

⚠️ 这里是多继承的关键点

graph TB
    subgraph vtable_Base2_for_Derived
        slot0["thunk → Derived::func2"]
        slot1["Base2::func4"]
        slot2["thunk → Derived::~Derived"]
        slot3["Base2::~Base2"]
    end

为什么 Base2 的 vtable 里有 thunk?

因为:

Base2* p = new Derived();
p->func2();

此时:

  • p 指向的是 Base2 子对象

  • Derived::func2 期望的 this

    • Derived*
  • 二者地址 不相等

所以编译器生成:

this-adjust thunk:
    this = this - offset(Base2)
    call Derived::func2(this)

👉 thunk 是 vtable 中的“中转站”


Derived 自己新增的虚函数去哪了?

void func5();

⚠️ 注意:

func5 不是 virtual

所以:

  • ❌ 不进入任何 vtable
  • ✅ 只能通过 Derived* 静态绑定调用

完整 vtable 结构总览

graph LR
    vptr1["Derived.Base1::vptr"] --> vt1["vtable(Base1 for Derived)"]
    vptr2["Derived.Base2::vptr"] --> vt2["vtable(Base2 for Derived)"]

    vt1 --> f1["Derived::func1"]
    vt1 --> f3["Base1::func3"]
    vt1 --> d1["Derived::~Derived"]
    vt1 --> db1["Base1::~Base1"]

    vt2 --> t2["thunk → Derived::func2"]
    vt2 --> f4["Base2::func4"]
    vt2 --> d2["thunk → Derived::~Derived"]
    vt2 --> db2["Base2::~Base2"]

析构过程验证

Base1* p = new Derived();
delete p;

执行顺序一定是:

Derived destructor
Base2 destructor
Base1 destructor

原因:

  • 通过 Base1 vtable 进入 ~Derived
  • 再按对象布局逆序析构

在多继承中,每个含虚函数的基类子对象都会引入独立的虚函数指针和虚函数表。派生类会为每个基类视角生成对应的 vtable,并在其中通过 this 指针调整 thunk 保证虚函数调用和析构过程的语义正确性。