虚函数和虚继承

ZaynPei Lv6

虚函数

静态类型与动态类型

为了理解虚函数中的动态绑定,首先必须区分两种“类型”。

  • 静态类型 (Static Type): 一个对象或指针在 声明时 的类型,它在 编译期间 就已经完全确定,并且永远不会改变 。
    • Derive derive; 中,derive 的静态类型是 Derive。
    • Base* pbase = new Derive(); 中,不管 pbase 实际指向什么,它在声明时是 Base,所以它的 静态类型 就是 Base
  • 动态类型 (Dynamic Type): 通常指一个 指针引用 在 程序运行时 实际所指向的对象的类型 。这个类型是在运行时决定的,并且可以改变 。
    • Base* pbase = new Derive(); 中,pbase 的 动态类型 是 Derive。
    • 如果后续代码执行 pbase = new Derive2();,那么 pbase 的动态类型就会变为 Derive2 。
    • 普通对象(如 derive)和未指向任何对象的指针(如 Base* pbase;)没有动态类型的说法 。

与之对应的, 函数调用如何确定最终执行哪个函数版本,取决于它的绑定方式。

  • 静态绑定 (Static Binding): 函数调用在 编译期 就决定了。编译器会根据调用者(对象或指针)的 静态类型 来选择要调用的函数 。普通成员函数函数参数的默认值 都是静态绑定的 。
    • 函数参数的默认值是静态绑定的, 这会引发一个问题:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      class Base {
      public:
      virtual void func(int x = 1) { // x 的默认值是 1
      std::cout << "Base::func, x = " << x << std::endl;
      }
      };

      class Derive : public Base {
      public:
      void func(int x = 2) override { // x 的默认值是 2
      std::cout << "Derive::func, x = " << x << std::endl;
      }
      };

      int main() {
      Base* pbase = new Derive();
      pbase->func(); // 调用的是 Derive::func,但传入的 x 是 1
      delete pbase;
      return 0;
      }
      如上, 虽然 pbase 指向一个 Derive 对象,调用的确实是 Derive::func,但传入的 x 却是 Base::func 中定义的默认值 1 。这是因为默认参数值在编译期就已经确定了,取决于指针的静态类型 Base* 。因此, 为了避免这种混乱,不要在子类中重新定义虚函数的默认参数
  • 动态绑定 (Dynamic Binding): 函数调用在 运行时 才决定。系统会根据指针或引用所指向对象的 动态类型 来选择要调用的函数 。虚函数 (当通过指针引用调用时) 是动态绑定的 。

虚函数返回类型协变

返回类型协变 (Covariant Return Types) 是指在一个派生类中重写基类的虚函数时,可以返回一个比基类虚函数返回类型更具体的派生类型的指针或引用。

换句话说,如果基类的一个虚函数返回 Base(基类指针),那么派生类重写这个函数时,可以合法地返回 Derived(派生类指针),只要 Derived 是 Base 的子类。

虚函数底层实现

为了实现动态绑定,编译器通常会使用 虚函数表 (Virtual Function Table, vtable) 和 虚函数指针 (Virtual Function Pointer, vptr)。

  • 虚函数表 (vtable): 是一个静态函数指针数组,它在编译期就已经创建好, 属于 而不是对象。每个包含虚函数(或继承自包含虚函数的类)的类都有一个自己的 vtable, 存储在只读数据段(.rodata)中。

    • 这个表中存储了类中所有虚函数的地址。表中的条目顺序由函数在类中声明的顺序决定。

    • 如何构建:

      • 基类的 vtable:包含了基类中所有虚函数的地址。
      • 派生类的 vtable:首先,它会复制基类的 vtable。如果派生类 重写 (override) 了某个基类的虚函数,那么派生类 vtable 中对应位置的函数指针会被替换为派生类重写的函数地址。如果派生类定义了新的虚函数,这些新虚函数的地址会被添加到 vtable 的末尾
  • 虚函数指针 (vptr): 是一个隐藏的成员指针,一般存在对象内存布局的开头部分, 在运行期创建。它属于对象。当一个类拥有虚函数时,编译器会自动为该类的每个对象添加一个 vptr(具体实现是在构造函数中初始化)。

    • 这个指针指向该对象所属类的 vtable。

    • 如何工作:当创建一个对象时(例如 new Derived()),对象的构造函数会被调用。在构造函数的初始化阶段对象的 vptr 会被设置为指向该类的 vtable。当调用虚函数时,程序会通过对象的 vptr 找到对应的 vtable,然后根据函数的偏移位置找到正确的函数地址并调用它。

vcall thunk 和 Adjustor thunk

通过前面的分析我们知道,虚函数的调用(通过指针或引用时)依赖于虚函数表。然而,当我们尝试验证这一点时,一个矛盾出现了:

  • 代码打印的地址:通过 printf(“地址 = %p”, &MYACLS::myvirfunc1); 这样的代码,我们可以打印出虚函数的地址
  • 调试器观察的地址:在调试程序时,我们可以观察到一个具体对象(例如 pmyobj)的内存布局,找到其虚函数表指针(vptr),并进一步查看虚函数表(vtable)中为 myvirfunc1 存储的真实函数地址 。

关键问题在于:这两者得到的地址有时会完全不同。代码打印出来的地址并不是虚函数表中真正存储的那个地址

实际上, 代码打印出的地址实际上是一个中间跳转站的地址。这个由编译器生成的、用于辅助虚函数调用的中间代码段,就被称为 vcall thunk 。而虚函数表中的地址, 也未必是直接指向虚函数的实现代码,取决于具体的继承结构。

如果 this 指针“天生正确”,虚函数表就存储真实地址。 如果 this 指针需要“后天修正”,虚函数表就存储 Adjustor thunk 地址,由 thunk 负责修正后再去调用真实地址。

vcall 是 “virtual call”(虚调用)的缩写。thunk 是一个计算机术语,通常指一小段用于辅助调用另一段子程序的代码。

而 Adjustor thunk 是 “adjustor thunk” 的缩写,指的是一种特殊类型的 thunk,用于调整传递给函数的 this 指针,以确保它指向正确的对象部分。

在实际多态执行的过程中, 这两类 thunk 的作用如下: - vcall thunk: 当通过基类指针或引用调用虚函数时,程序首先跳转到 vcall thunk。这个 thunk 的主要任务是从对象的 vptr 中获取正确的 vtable,然后根据虚函数在类中声明的顺序找到对应的函数地址。接着,thunk 会跳转到这个地址去执行实际的虚函数实现(这个虚函数可能是真实函数地址, 也可能是 Adjustor thunk 地址)。 - Adjustor thunk: 在多重继承虚继承的情况下, 由于对象的内存布局变得复杂,this 指针可能并不总是指向对象的起始位置。Adjustor thunk 的任务就是在调用实际的虚函数之前,调整 this 指针,使其指向正确的对象部分。调整完成后,thunk 再跳转到真正的虚函数实现。

下面分情况解释这个过程:

  1. 基础情况:单继承 (指针指向起始位置): 在最简单的单继承中,派生类对象的内存布局通常是这样的:
1
2
3
4
5
6
7
派生类对象 (Derive)
+-------------------------+
| 基类子对象 (Base) | <-- Base* 指针指向这里
| (包含 vptr在开头) |
+-------------------------+
| 派生类自己的成员变量 |
+-------------------------+

当执行 Base* pbase = new Derive(); 时,pbase 指针和 new Derive() 返回的指针值是完全相同的。它们都指向整个 Derive 对象的内存起始地址

在这种情况下,调用虚函数 pbase->virtual_func() 时,传递给函数的 this 指针就是对象的起始地址,不需要任何调整, 因此vptr直接指向 vtable 中的虚函数地址即可,调用过程非常直接, vcall thunk 直接跳转到虚函数实现。

  1. 复杂情况一:多重继承 (指针不再指向起始位置): 假设 Derive 同时继承自 Base 和 Base2 。

Derive 对象的内存布局会像这样:

1
2
3
4
5
6
7
8
9
10
Derive 对象的完整内存块
+-------------------------+ 0x1000 (起始地址)
| Base 子对象 | <-- Base* pbase 指向这里
| (包含 vptr1在开头) |
+-------------------------+ 0x1004 (假设 Base 大小为 4)
| Base2 子对象 | <-- Base2* pb2 指向这里
| (包含 vptr2在开头) |
+-------------------------+
| Derive 自己的成员 |
+-------------------------+
现在,关键来了:当执行 Base2* pb2 = new Derive(); 时,编译器知道 Base2 子对象并不是从 Derive 对象的起始位置开始的。为了让 pb2 能够正确地访问 Base2 的成员,编译器会自动将 new Derive() 返回的起始地址(0x1000)加上一个偏移量(sizeof(Base),即4字节),使得 pb2 的值变为 0x1004 。

所以,此时 pb2 指针并不指向 Derive 对象的内存起始位置。当我们通过 pb2 调用一个被 Derive 重写的虚析构函数时,比如 delete pb2; 会发生什么?

  • 调用目标:delete 操作需要调用 Derive 的析构函数 ~Derive()。
  • this 指针的期望:Derive 的析构函数期望接收到的 this 指针是整个 Derive 对象的起始地址(0x1000),这样它才能正确地访问和析构所有成员(包括来自 Base 的部分)。
  • 实际传入的指针:但我们现在拥有的指针是 pb2,它的值是 0x1004。如果直接把 0x1004 当作 this 指针传给 ~Derive()(任何非静态成员函数的调用都需要传入this指针),函数就会错误地认为对象是从 0x1004 开始的,从而导致访问越界、内存损坏和程序崩溃 。

这个问题的解决方案就是 vcall thunk 和 Adjustor thunk :Derive 对象中与 Base2 对应的虚函数表(vtbl2)里,析构函数那一项存储的不是 ~Derive() 的直接地址, 它存储的是一个 thunk 代码块的地址

delete pb2; 触发析构函数调用时,首先跳转到 vcall thunk, 接着 vcall thunk 跳转到虚函数表找到对应函数地址, 此时是 adjustor thunk。这个 thunk 的代码非常简单,其核心操作就是调整指针。减去4字节后,就得到了正确的 Derive 对象起始地址 0x1000。调整完毕后,thunk 再跳转到真正的 ~Derive() 函数去执行,此时传递的 this 指针已经是正确的了 。

同样,当通过派生类指针调用基类函数 pd2->hBase2(); 时,也需要调整。pd2 指向对象开头,但 hBase2 需要一个指向 Base2 子对象的 this 指针,所以此时 thunk 会将指针向后调整 。

  1. 复杂情况二:虚继承: 在虚继承中,为了共享同一个基类子对象,内存布局变得更加复杂。通常,共享的虚基类会被放在派生类对象的末尾,并通过一个虚基类表指针(vbptr)来定位。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Derive 对象的完整内存块
    +-------------------------+ 0x2000 (起始地址)
    | vbptr (虚基类表指针) | <-- Derive* pderive 指向这里
    +-------------------------+
    | Derive 自己的成员 |
    +-------------------------+
    | |
    | ... (内存对齐填充) ... |
    | |
    +-------------------------+ 0x2008 (某个偏移后)
    | 共享的 Base 子对象 | <-- Base* pbase 指向这里
    | (包含 vptr) |
    +-------------------------+
    当执行 Base* pbase = new Derive(); 时,pbase 必须指向 Base 子对象,所以它的值会是 0x2008。显然,这个 pbase 指针也不指向 Derive 对象的内存起始位置 。

当通过 pbase 调用虚函数时,首先会跳转到 vcall thunk。vcall thunk 会通过 pbase 的 vptr 找到 Base 的虚函数表,然后找到对应的虚函数地址。 但这个地址可能是一个 Adjustor thunk,因为 this 指针需要调整。Adjustor thunk 会通过 Derive 对象的 vbptr 找到共享的 Base 子对象在整个 Derive 对象内存中的偏移位置,然后调整 this 指针,使其指向整个 Derive 对象的起始地址。调整完成后,thunk 再跳转到真正的虚函数实现。

总之, 在多重继承和虚继承中,为了保证每个基类指针都能正确工作,它们被调整为指向各自子对象在完整派生类对象内存中的偏移位置,而不是整个对象的起始地址。

vcall thunk 就像一个中间人,负责找到正确的虚函数地址。而 Adjustor thunk 则像一个翻译官,确保传递给函数的 this 指针是正确的。通过这种机制,C++ 实现了强大的多态性,同时保证了内存访问的安全和正确。

为什么析构函数需要虚函数

首先, 我们假设这样一个情境( ): 当 Derive 同时继承 Base 和 Base2 时,其对象的内存布局大致如下:

1
2
3
4
5
6
7
8
9
10
Derive 对象
+------------------+ <-- Derive* 和 Base* 指针指向这里
| Base 子对象 |
| (含 vptr1) |
+------------------+
| Base2 子对象 | <-- Base2* 指针指向这里
| (含 vptr2) |
+------------------+
| Derive 成员 |
+------------------+

我们已经知道, 当用一个第二基类(Base2)的指针指向一个 Derive 对象时(Base2* pb2 = new Derive();),pb2 指针为了能正确访问 Base2 的成员,其指向的地址会被编译器自动偏移,指向 Base2 子对象的起始位置,而不是整个 Derive 对象的起始位置 。

而如果此时 Base2 的析构函数不是虚函数,那么执行 delete pb2; 将会引发一场灾难:

  • 静态绑定:由于析构函数非虚,编译器会进行静态绑定,直接决定调用 Base2::~Base2() 。
  • 资源泄露:Derive 的析构函数和 Base 的析构函数完全不会被调用,如果 Derive 在其构造函数中分配了任何资源(如动态内存),这些资源将永久丢失,造成严重的内存泄露。
  • 内存损坏与程序崩溃:更致命的是,系统会尝试从 pb2 指针的位置(对象的中间部分)开始释放内存。这与 new 操作分配的内存块的起始地址不符,会破坏堆的结构,极有可能导致程序立即或在未来的某个时刻崩溃 。

若将 Base2 的析构函数声明为 virtual,就能完美解决这个问题。delete pb2; 的执行流程变为:

  • 动态绑定:delete 操作会触发动态绑定,通过 pb2 的 vptr2 查找到 Derive 对象中对应的虚函数表(vtbl2)。
  • thunk 的介入:在多继承下,vtbl2 中析构函数的位置存放的并不是 Derive 析构函数的直接地址,而是一个被称为 thunk 的一小段特殊汇编代码的地址。
  • this 指针的调整:这个 thunk 执行两项关键任务:调整 this 指针, 它会将 pb2 的指针值减去 Base 子对象的大小,使其回退到整个 Derive 对象的起始地址。接着调用真实析构函数, 用这个调整好的、正确的 this 指针去调用 Derive 的虚析构函数 Derive::~Derive() 。

所以正确的析构链是: Derive::~Derive() 首先执行自己的析构代码(派生类析构函数只负责销毁派生类自己的成员,它不会自动销毁基类的成员)。然后,它会自动、反向地调用其所有基类的析构函数,即 Base2::~Base2() 和 Base::~Base() (每个基类析构函数只负责销毁自己那一部分的成员)。最后,系统使用由 thunk 调整过的、指向对象真正起始位置的指针来调用 operator delete,安全地释放整块内存 。

因此, 任何时候,当你打算通过一个基类指针来 delete 一个派生类对象时(这是多态的常见用法),你必须将该基类的析构函数声明为 virtual。这保证了无论指针是什么类型,都能通过动态绑定和 thunk 机制正确地调用到最深层派生类的析构函数,从而启动一个完整的、自下而上的析构链,确保所有资源被释放,内存被安全回收。

这部分内容不同编译器实现细节可能有所不同,但核心原理和机制在所有主流 C++ 编译器中都是类似的。

RTTI (运行时类型识别)

RTTI (Run-Time Type Information) 是 C++ 提供的一种机制,它允许程序在运行时查询一个对象的真实类型。这在处理多态(Polymorphism)时尤其有用,当你通过基类指针或引用操作派生类对象时,RTTI 能帮助你揭示这个指针或引用“背后”的实际类型。

为什么需要 RTTI?设想这样一个场景:你的程序是一个动物园,里面有各种动物。你有一个统一的管理手册,上面写着“所有动物都需要喂食”,这对应于一个基类 Animal 和一个虚函数 feed()。

1
2
3
4
5
6
7
8
9
class Animal {
public:
virtual void feed() { /* 通用喂食方法 */ }
virtual ~Animal() {}
};

class Monkey : public Animal { /* ... */ };
class Lion : public Animal { /* ... */ };
class Elephant : public Animal { /* ... */ };
你可以用一个 Animal* 指针指向任何动物,并调用 feed(),多态会确保调用正确的喂食方法。这非常优雅。

但是,现在有一个特殊需求:只有当眼前的动物是猴子时,你才能给它一根香蕉。这个 giveBanana() 方法是 Monkey 类特有的,Animal 基类并不知道。

1
2
3
4
5
6
7
8
class Monkey : public Animal {
public:
void giveBanana() { /* 给香蕉 */ }
};

Animal* some_animal = get_random_animal(); // 可能返回 Monkey* 或 Lion*
// 如何安全地调用 giveBanana() ?
// some_animal->giveBanana(); // 编译错误!Animal 没有这个方法
当你只有一个 Animal* 指针时,你怎么知道它指向的是不是一只猴子呢?RTTI 就是解决这个问题的工具。它允许你在运行时“询问”some_animal:“嘿,你的真实身份到底是不是 Monkey?”

C++ RTTI 的两大核心工具

C++ 通过两个主要的操作符来实现 RTTI:dynamic_cast 和 typeid。

type_traits(如 std::is_same、std::is_base_of 等)只能在编译期判断类型关系,不能用于运行时类型识别。

dynamic_cast:安全地进行向下转型

dynamic_cast 是 RTTI 中最常用也最重要的工具。它的作用是在运行时尝试将一个基类指针或引用安全地转换成派生类的指针或引用。

核心特性:安全。如果转换是非法的(比如你试图将一个指向 Lion 对象的 Animal* 转换成 Monkey*),它不会导致程序崩溃,而是会给你一个明确的失败信号。

  • 对于指针:如果转换成功,它返回一个指向派生类对象的有效指针;如果转换失败,它返回 nullptr。
  • 对于引用:如果转换成功,它返回一个指向派生类对象的有效引用;如果转换失败,它会抛出一个 std::bad_cast 异常。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    #include <iostream>

    // (Animal, Monkey, Lion 类的定义如上)

    void try_to_give_banana(Animal* animal_ptr) {
    if (!animal_ptr) return;

    std::cout << "正在检查一只动物..." << std::endl;

    // 步骤说明:使用 dynamic_cast 尝试进行向下转型。
    // 这是 RTTI 的核心应用。我们询问:“animal_ptr 指向的对象,
    // 其真实类型是 Monkey 或 Monkey 的子类吗?”
    Monkey* monkey_ptr = dynamic_cast<Monkey*>(animal_ptr);

    // 步骤说明:检查 dynamic_cast 的结果。
    // 这是保证类型安全的关键。
    if (monkey_ptr != nullptr) {
    // 转换成功!现在可以安全地调用 Monkey 的特有方法。
    std::cout << "哦,这是一只猴子!给它一根香蕉。" << std::endl;
    monkey_ptr->giveBanana();
    } else {
    // 转换失败,说明它不是猴子。
    std::cout << "这不是猴子,不能给香蕉。" << std::endl;
    }
    }
    不过dynamic_cast 只能用于具有虚函数的类(即多态类)。因为编译器只为这种类生成 RTTI 所需的类型信息(通常存储在虚函数表 vtable 中)。如果基类没有虚函数,使用 dynamic_cast 会导致编译错误

typeid:获取对象的类型信息

typeid 操作符返回一个对 std::type_info 对象的常量引用,这个对象包含了特定类型的元信息。主要用途是:

  • 比较类型:判断两个对象是否为完全相同的类型。
  • 获取类型名称:通过 .name() 方法获取一个表示类型名称的字符串(注意:这个字符串的格式没有跨编译器的标准,可能是“美化”过的,也可能是“混淆”过的)。

注意, typeid 也只能用于多态类的指针或引用,以确保获取的是对象的动态类型信息。如果对非多态类使用 typeid,得到的将是静态类型信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <typeinfo> // 需要包含这个头文件

// (Animal, Monkey, Lion 类的定义如上)

void check_exact_type(Animal* animal_ptr) {
if (!animal_ptr) return;

// 步骤说明:使用 typeid 获取指针指向对象的真实类型信息。
// 注意,要对指针解引用 *animal_ptr 才能获取动态类型。
const std::type_info& info = typeid(*animal_ptr);

std::cout << "检查类型: " << info.name() << std::endl;

// 步骤说明:将运行时类型与已知的编译时类型进行比较。
if (info == typeid(Monkey)) {
std::cout << "这正是一只猴子,不多不少。" << std::endl;
} else if (info == typeid(Lion)) {
std::cout << "这正是一只狮子。" << std::endl;
}
}
dynamic_cast 与 typeid 的区别在于:

  • dynamic_cast 检查的是“是否可以安全地视为”某种类型(Is-a relationship, 包括子类)。
  • typeid 检查的是“是否完全就是”某种类型(Exact type)。

如何看待 RTTI

当然, 天下没有免费的午餐。RTTI 功能是有成本的:

  • 内存开销:编译器(因此 RTTI 信息是编译器确定的)需要为每个多态类生成额外的类型信息,并将其存储在程序的某个地方(通常存储在 vtable 的某个固定偏移处), 这会稍微增加程序的大小。
    • 在 Visual Studio 中,它通常位于虚函数表起始地址的前一个位置(vptr - 1)。程序正是通过这个入口点来获取对象的运行时类型信息。
  • 性能开销:dynamic_cast 和 typeid 的操作是在运行时进行的。尤其是 dynamic_cast,它需要在类的继承体系中进行查找,可能会比一次普通的函数调用慢

因此,在性能极其敏感的代码中,开发者可能会选择禁用 RTTI(通过编译器选项,如 GCC/Clang 的 -fno-rtti 或 MSVC 的 /GR-)。

并且, 一个合理的设计原则是: 优先使用虚函数,而不是 RTTI。

如果你的代码里充斥着 if/else if 结构,用 dynamic_cast 来判断对象类型,然后调用不同的函数,这通常是一个糟糕设计的信号。

1
2
3
4
5
6
7
void process_animal(Animal* p) {
if (dynamic_cast<Monkey*>(p)) {
// ... do monkey stuff
} else if (dynamic_cast<Lion*>(p)) {
// ... do lion stuff
} // ... 每增加一种动物,就要修改这里
}
更好的做法是利用多态,让每个类自己处理自己的行为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 在每个动物类中实现自己的 process 方法
class Animal {
public:
virtual void process() = 0; // 纯虚函数
// ...
};

class Monkey : public Animal {
public:
void process() override { /* do monkey stuff */ }
};

class Lion : public Animal {
public:
void process() override { /* do lion stuff */ }
};

// 客户端代码变得极其简单和稳定
void process_animal(Animal* p) {
p->process(); // 不需要知道具体类型,直接调用即可
}
## 虚继承

当我们谈论虚继承时,首先必须理解它要解决的问题——菱形继承。

菱形继承的问题

菱形继承是一种继承结构,指一个派生类同时继承了两个基类,而这两个基类又共同继承自同一个更顶层的基类。这种结构在类图上看起来像一个菱形,因此得名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>

// 顶层基类
class Base {
public:
int m_base_data;
Base() : m_base_data(0) { std::cout << "Base constructor" << std::endl; }
};

// 中间派生类
class Derived1 : public Base {
public:
int m_derived1_data;
Derived1() : m_derived1_data(1) { std::cout << "Derived1 constructor" << std::endl; }
};

class Derived2 : public Base {
public:
int m_derived2_data;
Derived2() : m_derived2_data(2) { std::cout << "Derived2 constructor" << std::endl; }
};

// 底层派生类
class Diamond : public Derived1, public Derived2 {
public:
int m_diamond_data;
Diamond() : m_diamond_data(3) { std::cout << "Diamond constructor" << std::endl; }
};

int main() {
Diamond d;

// 问题1: 访问成员的二义性
// d.m_base_data = 100; // 编译错误! Ambiguous access

// 我们可以通过指定路径来解决二义性,但这暴露了底层问题
d.Derived1::m_base_data = 100;
d.Derived2::m_base_data = 200;

std::cout << "d.Derived1::m_base_data = " << d.Derived1::m_base_data << std::endl;
std::cout << "d.Derived2::m_base_data = " << d.Derived2::m_base_data << std::endl;

std::cout << "sizeof(Diamond) = " << sizeof(Diamond) << std::endl;

return 0;
}

没有虚继承的菱形继承会导致数据冗余二义性问题:

  • 数据冗余 (Data Redundancy):Diamond 类的对象 d 中包含了 两份 Base 类的子对象(成员变量 m_base_data)。一份来自 Derived1 的继承,另一份来自 Derived2 的继承。从 sizeof 的结果和可以分别对 d.Derived1::m_base_data 和 d.Derived2::m_base_data 赋值就可以看出这一点。这浪费了内存,也违背了我们的设计初衷(我们通常希望 Diamond 只有一个 Base 部分)。

  • 访问二义性 (Ambiguity):由于存在两份 m_base_data,当编译器遇到 d.m_base_data 这样的代码时,它不知道你想要访问的是 Derived1 路径下的那一份,还是 Derived2 路径下的那一份,因此会报编译错误。

非虚继承下的内存布局示意图: 一个 Diamond 对象在内存中看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+---------------------+
| Base subobject | (from Derived1)
| (m_base_data) |
+---------------------+
| Derived1's members |
| (m_derived1_data) |
+---------------------+
| Base subobject | (from Derived2)
| (m_base_data) |
+---------------------+
| Derived2's members |
| (m_derived2_data) |
+---------------------+
| Diamond's members |
| (m_diamond_data) |
+---------------------+

解决方案:虚继承 (Virtual Inheritance)

为了解决上述问题,C++ 引入了 虚继承。通过在继承方式前加上 virtual 关键字,我们可以告诉编译器,我希望这个基类在派生类的继承体系中 只保留一个共享的实例。

我们只需要修改中间派生类的继承方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 将继承方式改为 virtual public
class Derived1 : virtual public Base { /* ... */ };
class Derived2 : virtual public Base { /* ... */ };

// Diamond 类和 Base 类无需改动
class Diamond : public Derived1, public Derived2 { /* ... */ };

int main() {
Diamond d;

// 现在不再有二义性了!
d.m_base_data = 100; // 编译通过!

std::cout << "d.m_base_data = " << d.m_base_data << std::endl;

// 验证 Derived1 和 Derived2 访问的是同一个数据
d.Derived1::m_base_data = 200;
std::cout << "d.Derived2::m_base_data = " << d.Derived2::m_base_data << std::endl;

std::cout << "sizeof(Diamond) = " << sizeof(Diamond) << std::endl;
return 0;
}
此时, d.m_base_data 不再有歧义,因为 Diamond 对象中现在 只有一个 Base 子对象。

通过 d.Derived1::m_base_data 修改的值,可以通过 d.Derived2::m_base_data 读取出来,证明它们访问的是同一块内存。

sizeof(Diamond) 的大小会比之前非虚继承时要小(因为它少了一份 Base 的数据),但会比简单成员相加要大(因为它增加了额外的指针)。

虚继承的底层实现机制

那么,编译器是如何做到让 Derived1 和 Derived2 共享同一个 Base 子对象的呢?这背后是通过 虚基类指针 (virtual base pointer, vbptr) 和 虚基类表 (virtual base table, vbtable) 实现的。

对于 Derived1 来说,它无法在编译时确定其基类 Base 的成员 m_base_data 相对于自己的起始地址的偏移量。因为如果 Derived1 被 Diamond 继承,Base 子对象的位置由 Diamond 决定;如果 Derived1 被单独实例化,Base 子对象的位置又不一样。这个偏移量必须在运行时才能确定。

假如不使用虚基类表和虚基类指针

首先,我们看看没有 virtual 的情况,编译器是如何工作的。

1
2
class Base { public: int m_base_data; };
class Derived1 : public Base { public: int m_derived1_data; };
当编译器在编译 Derived1 类的成员函数时(比如一个叫 foo() 的函数),它需要生成访问 m_base_data 的机器码。而只要编译器看到 Derived1 的定义,它能 100% 确定 Derived1 对象的内存布局:Base 子对象总是在最前面, Derived1 的成员跟在后面。

内存布局如下:

1
2
3
4
5
6
7
+------------------+  <- Derived1 对象的起始地址 (this)
| Base's part |
| (m_base_data) |
+------------------+
| Derived1's part |
| (m_derived1_data)|
+------------------+

所以,当 Derived1::foo() 访问 m_base_data 时,编译器知道 m_base_data 相对于 Derived1 对象的起始地址(也就是 this 指针的值)的偏移量 永远是 0。这个 “0” 是一个 编译时常量。编译器可以直接生成高效的指令,比如 “从 this 指针指向的地址读取一个整数”,也就是说编译器可以直接确定 m_base_data 的位置

现在,我们加上 virtual 关键字:

1
2
class Base { public: int m_base_data; };
class Derived1 : virtual public Base { public: int m_derived1_data; };

现在,编译器再次开始编译 Derived1 的成员函数 foo()。它又要生成访问 m_base_data 的机器码。但是,编译器现在面临一个巨大的难题。它只看到了 Derived1 的定义,但它不知道 Derived1 将来会如何被使用。它遇到了两种完全可能且内存布局截然不同的情况:

情况 A:Derived1 被单独实例化Derived1 obj1;

在这种情况下,obj1 是最终的对象。编译器会为它生成一个内存布局。一种常见的实现是这样的:

1
2
3
4
5
6
7
8
+--------------------+  <- obj1 的起始地址 (this)
| vbptr_for_D1 | (指向一个表,表里说 Base 在下面 N 个字节处)
+--------------------+
| m_derived1_data |
+--------------------+
| Shared Base | <-- 共享的 Base 子对象
| (m_base_data) |
+--------------------+
在这个布局中,m_base_data 相对于 obj1 起始地址的偏移量可能是,比如说,16 字节(假设 vbptr 和 m_derived1_data 各占 8 字节)。所以,在情况 A 中,偏移量是 16。

情况 B:Derived1 作为 Diamond 的一部分被实例化

1
2
3
4
// 用户的代码里可能是这样写的:
class Derived2 : virtual public Base { /* ... */ };
class Diamond : public Derived1, public Derived2 { /* ... */ };
Diamond obj_diamond;

在这种情况下,Derived1 只是 Diamond 对象内部的一个组件。Diamond 作为“最远派生类”,它有权决定最终的内存布局,尤其是那个 唯一的、共享的 Base 子对象 应该放在哪里。

obj_diamond 的内存布局可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+--------------------+  <- obj_diamond 的起始地址
| Derived1's part: | <- 同时也是 obj_diamond 内部 Derived1 子对象的起始地址 (this)
| vbptr_for_D1 |
| m_derived1_data |
+--------------------+
| Derived2's part: |
| ... |
+--------------------+
| Diamond's part: |
| ... |
+--------------------+
| Shared Base | <-- 唯一的 Base 子对象被放在了最底下
| (m_base_data) |
+--------------------+
在这个布局中,m_base_data 相对于 obj_diamond 内部 Derived1 子对象起始地址的偏移量可能变成了,比如说,48 字节。所以,在情况 B 中,偏移量是 48。

现在,回到编译器正在编译 Derived1::foo() 的那个时刻。它需要为访问 m_base_data 生成机器码。它应该使用哪个偏移量呢?是 16 还是 48?答案是:它无法知道。

因为在编译 Derived1 这个“组件”的时候,编译器根本不知道这个组件将来是会被单独使用(情况 A),还是被组装进一个更大的 Diamond 对象里(情况 B)。

这就是 “无法在编译时确定其基类…的偏移量” 这句话的精确含义。这个偏移量不再是一个固定的编译时常量,它变成了一个变量,取决于 Derived1 在运行时到底是以何种形式存在的。

既然不能在编译时“写死”一个固定的偏移量,那就只能采用一种能在运行时查找”偏移量的方法。这就是 vbptr (虚基类指针) 登场的时刻。

编译器生成的机器码不再是:“从 this 地址偏移一个固定的 N 值”, 而是变成了一套更复杂的指令,其逻辑是:“找到 this 指针指向的对象内存里的 vbptr。” (这个 vbptr 的位置是固定的) –> “根据 vbptr 找到虚基类表 (vbtable)。” –> “从表中查出 Base 子对象的偏移量。” (这个表里的值是由最终对象的构造函数——Derived1() 或 Diamond()——在创建对象时填好的) –> “用 this 的地址加上刚刚查到的偏移量,得到 Base 子对象的实际地址。” –> “访问 m_base_data。”

为了解决这个运行时定位的问题,编译器引入了间接层来实现间接寻址

  • 虚基类指针 (vbptr): 编译器会给每一个虚继承的派生类对象(如 Derived1 和 Derived2 的对象)安插一个隐藏的指针,即 vbptr。这个指针指向一个虚基类表

  • 虚基类表 (vbtable): 这是一个静态的表,属于, 存放在数据段 (.data) 或只读数据段 (.rodata) 中。表中存放的是 偏移量 (offset)。这个偏移量指示了从当前 vbptr 的地址出发,需要移动多少字节才能找到共享的虚基类子对象的起始地址。

    • 例如,Derived1 的 vbtable 中会有一个条目,记录了 Base 子对象相对于 Derived1 对象起始地址的偏移量。
    • 这个偏移量是在 Diamond 对象编译时计算, 并在创建时,由 Diamond 的构造函数设置的(因为 Diamond 是最远派生类,负责最终的内存布局)。

当 Derived1 的成员函数要访问 m_base_data 时, 它通过 this 指针找到自身的 vbptr, 又通过 vbptr 找到对应的 vbtable, 从 vbtable 中读取到指向 Base 子对象的偏移量。

将当前对象的地址加上这个偏移量,就得到了共享的 Base 子对象的地址, 最后通过这个计算出的地址去访问 m_base_data。

在虚继承下一个 Diamond 对象的内存布局(一种常见的编译器实现方式)可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+---------------------+
| Derived1's part: |
| vbptr_for_D1 | --> vbtable for D1 (contains offset to Base)
| m_derived1_data |
+---------------------+
| Derived2's part: |
| vbptr_for_D2 | --> vbtable for D2 (contains offset to Base)
| m_derived2_data |
+---------------------+
| Diamond's part: |
| m_diamond_data |
+---------------------+
| Shared Base object: | <-- The single, shared instance
| m_base_data |
+---------------------+
- Base 子对象被放在了整个 Diamond 对象内存布局的某个位置(通常是末尾)。 - Derived1 和 Derived2 的子对象中都包含一个 vbptr。 - vbptr_for_D1 指向的表告诉程序如何从 Derived1 部分找到 Base 部分。 - vbptr_for_D2 指向的表告诉程序如何从 Derived2 部分找到 Base 部分。

虚继承的重要规则:构造函数

这里有一条重要规则:虚基类的构造函数由 最远派生类 (most-derived class) 的构造函数来调用,而中间派生类的构造函数对虚基类构造函数的调用在某些情况下会被忽略。

在我们的例子中,Diamond 是最远派生类。因此,Diamond 的构造函数负责初始化 Base。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base {
public:
Base(int i) { /* ... */ }
};

class Derived1 : virtual public Base {
public:
// 这个 : Base(10) 在创建 Diamond 对象时会被忽略
Derived1() : Base(10) { }
};

class Derived2 : virtual public Base {
public:
// 这个 : Base(20) 也会被忽略
Derived2() : Base(20) { }
};

class Diamond : public Derived1, public Derived2 {
public:
// 必须由 Diamond 显式调用 Base 的构造函数
// 如果不写,则会调用 Base 的默认构造函数(如果存在)
Diamond() : Base(30) { }
};
因为 Base 子对象只有一个,它的构造函数也必须只被调用一次。如果允许多个中间派生类都去调用,就会产生冲突。因此,C++ 规定这个责任由继承体系中最下层的那个类来承担。