9、多继承下的虚函数:多继承的内存布局、多个vptr的处理、多继承下的虚函数调用、菱形继承问题

好,咱们今天聊一个让不少C++开发者头疼的话题——多继承下的虚函数。说实话,单继承的虚函数机制已经够绕了,多继承一掺和进来,内存布局、vptr数量、调用开销……全变了样。我在项目中见过不少因为多继承虚函数踩坑的案例,有的甚至导致线上崩溃。今天我就把这些坑一个个给你拆开讲清楚。

9.1 多继承的内存布局

先看一个最简单的多继承场景:

class Base1 {
public:
    virtual void f1() { cout << "Base1::f1" << endl; }
    virtual void g1() { cout << "Base1::g1" << endl; }
    int a;
};

class Base2 {
public:
    virtual void f2() { cout << "Base2::f2" << endl; }
    virtual void g2() { cout << "Base2::g2" << endl; }
    int b;
};

class Derived : public Base1, public Base2 {
public:
    virtual void f1() override { cout << "Derived::f1" << endl; }
    virtual void f2() override { cout << "Derived::f2" << endl; }
    virtual void h() { cout << "Derived::h" << endl; }
    int c;
};

这个Derived对象在内存里长什么样?我直接告诉你结论:它包含两个基类子对象,每个基类子对象都有自己的vptr

内存布局(64位系统,假设int占4字节,vptr占8字节):

  • 偏移0: vptr1(指向Derived的Base1虚函数表)
  • 偏移8: Base1::a
  • 偏移12: 填充(对齐到16字节)
  • 偏移16: vptr2(指向Derived的Base2虚函数表)
  • 偏移24: Base2::b
  • 偏移28: Derived::c
  • 偏移32: 对象总大小

为什么会这样?因为每个基类都需要独立管理自己的虚函数表。你想想看,如果只有一个vptr,那Base2*指针指向这个对象时,它怎么知道该用哪个表?

嗯,这里要注意:派生类新增的虚函数(比如h())会被追加到第一个基类的虚函数表中。这是编译器的一个约定,不是标准强制要求的,但主流编译器都这么干。

9.2 多个vptr的处理

多继承下,每个对象可能有多个vptr。那编译器怎么知道该用哪个?

说白了,指针的静态类型决定了使用哪个vptr。看个例子:

Derived d;
Base1* p1 = &d;
Base2* p2 = &d;

p1->f1();  // 通过vptr1查表,调用Derived::f1
p2->f2();  // 通过vptr2查表,调用Derived::f2

这里有个关键点:p2指向的地址并不是d的起始地址,而是偏移了16字节(指向Base2子对象的起始位置)。这就是所谓的指针调整

我个人的习惯:在调试多继承问题时,我经常用reinterpret_cast<uintptr_t>打印指针地址,看看编译器做了哪些偏移调整。这招帮我抓出过好几个隐蔽的bug。

那派生类自己的虚函数呢?比如h()

Derived* p = &d;
p->h();  // 通过vptr1查表(因为h被追加到Base1的虚函数表中)

你看,同一个对象,不同的指针类型,走的vptr路径完全不同。这就是多继承下虚函数调用的核心机制。

9.3 多继承下的虚函数调用

虚函数调用本质上就是查虚函数表。多继承下,查表过程稍微复杂一点,但原理不变。

我画个图帮你理解:

Derived对象内存 vptr1 → Base1虚表 Base1::a (int) 填充 (4字节) vptr2 → Base2虚表 Base2::b (int) Base1虚函数表 0: Derived::f1() 1: Base1::g1() 2: Derived::h() Base2虚函数表 0: Derived::f2() 1: Base2::g2()

调用流程其实很简单:

  1. 通过对象的vptr找到对应的虚函数表
  2. 在表中根据函数索引找到函数指针
  3. 跳转执行

但多继承下有个隐藏开销:thunk函数。当通过Base2*调用Derived::f2()时,编译器需要调整this指针,让它指向Derived对象的起始地址。这个调整逻辑就放在thunk里。

我曾经踩过的坑:有一次我在多继承类中重载了虚函数,但忘记在派生类中override。结果通过不同基类指针调用时,行为完全不一样。排查了半天才发现,原来是虚函数表里有的条目指向了基类版本,有的指向了派生类版本。所以我的建议是:多继承下,虚函数override一定要显式写override关键字,让编译器帮你检查。

9.4 菱形继承问题

菱形继承,也叫钻石继承,是多继承中最经典也最坑爹的问题。看这个结构:

class A {
public:
    virtual void foo() { cout << "A::foo" << endl; }
    int a;
};

class B : public A {
public:
    virtual void foo() override { cout << "B::foo" << endl; }
    int b;
};

class C : public A {
public:
    virtual void foo() override { cout << "C::foo" << endl; }
    int c;
};

class D : public B, public C {
public:
    virtual void foo() override { cout << "D::foo" << endl; }
    int d;
};

这里DBC继承了两次A。也就是说,D对象里包含两个A子对象。这会导致什么问题?

  • 二义性D对象访问A::a时,编译器不知道是哪个Aa
  • 内存浪费A的成员被复制了两份
  • 虚函数混乱D中有两个vptr,分别指向BC的虚函数表,每个表里都有foo的条目

那怎么解决?C++提供了虚继承

class B : virtual public A { ... };
class C : virtual public A { ... };
class D : public B, public C { ... };

虚继承后,D对象里只有一个A子对象。编译器会通过虚基类指针(vbptr)来定位这个共享的A子对象。

虚继承的内存布局变化:

  • 每个虚派生类(B、C)会多一个vbptr,指向虚基类表
  • 虚基类表记录了虚基类子对象相对于当前对象起始地址的偏移
  • D对象中,A子对象被放在对象的末尾(不同编译器策略可能不同)

嗯,这里要特别提醒:虚继承的运行时开销比普通继承大。每次访问虚基类的成员,都需要通过vbptr间接寻址。我在一个性能敏感的项目中,就因为滥用虚继承导致性能下降了不少。

我的建议:菱形继承能用组合代替就尽量用组合。实在要用虚继承,记得把虚基类的构造函数设计好——因为虚基类由最派生类直接初始化,中间类不会调用虚基类的构造函数。这个细节我见过好几个人搞错。

最后总结一下多继承虚函数的核心要点:

特性 说明
vptr数量 每个基类一个vptr,派生类新增虚函数追加到第一个基类的虚表
指针调整 通过不同基类指针访问时,编译器自动调整指针偏移
thunk函数 用于调整this指针,确保虚函数调用时this指向正确位置
菱形继承 默认产生两个基类子对象,用虚继承解决,但增加运行时开销
虚基类表 记录虚基类偏移,通过vbptr间接访问

多继承的虚函数机制,说白了就是编译器在背后做了大量指针调整和查表工作。你写代码时觉得挺简单,但一旦出了问题,排查起来相当痛苦。我个人建议:能用单继承就别用多继承,能用组合就别用继承。这不是偷懒,是血的教训换来的经验。


公众号:蓝海资料掘金营,微信deep3321