12、C++对象模型(下):多重继承、虚继承的内存布局,this指针调整与thunk技术

好,咱们接着聊对象模型。上一章我们把单继承的底裤扒了个干净,这一章要上硬菜了——多重继承和虚继承。说实话,这两个东西是C++里最让新手头疼的,也是面试官最喜欢挖坑的地方。我当年刚入行时,就因为没搞懂虚继承的内存布局,线上出了一个诡异的bug,查了整整两天……嗯,从那以后我就把这套东西刻在脑子里了。

12.1 多重继承的内存布局

先看个最简单的例子:一个类同时继承两个基类。

class Base1 {
public:
    int b1;
    virtual void foo() {}
};

class Base2 {
public:
    int b2;
    virtual void bar() {}
};

class Derived : public Base1, public Base2 {
public:
    int d;
    virtual void foo() override {}
    virtual void bar() override {}
};

你想想看,Derived对象在内存里长什么样?其实很简单——按声明顺序,把各个基类的子对象依次排列。就像叠罗汉,Base1在最底下,Base2在中间,Derived自己的成员在最上面。

内存布局示意:

|------------------|
| Base1::vptr      |  ← 指向Derived的虚函数表(第一部分)
| Base1::b1        |
|------------------|
| Base2::vptr      |  ← 指向Derived的虚函数表(第二部分)
| Base2::b2        |
|------------------|
| Derived::d       |
|------------------|

这里有个关键点:Derived对象里有两个虚函数表指针。每个基类子对象都有自己的vptr,但它们指向的虚函数表是经过编译器特殊处理的——表里记录了偏移信息,确保调用虚函数时能找到正确的this指针。

我个人习惯把这种布局叫做「拼接式继承」。说白了,就是把多个基类的内存块拼在一起,每个基类管自己那一亩三分地。

12.2 this指针调整——为什么需要它?

现在问题来了。假设你有一个Derived对象,通过Base2*指针调用虚函数:

Derived d;
Base2* p = &d;
p->bar();  // 调用Derived::bar()

编译器怎么知道this指针该指向哪里?p指向的是Base2子对象的起始位置,但Derived::bar()函数内部访问Derived::d时,需要的是整个Derived对象的起始地址。

这就引出了this指针调整。编译器会在调用虚函数时,根据偏移量把指针「掰」到正确的位置。具体来说,虚函数表里除了函数地址,还存了一个this偏移量。调用时,先取出偏移量,调整this指针,再跳转到真正的函数。

我的经验: 我曾经在调试一个多继承的崩溃问题时,发现crash栈里this指针的值「看起来不对」。后来才意识到,是编译器自动做了调整,而我在汇编层面没注意到这个偏移量。所以,如果你在调试器里看到this指针和对象地址不一致,别慌——先看看是不是多重继承的场景。

12.3 Thunk技术——编译器的小把戏

说到this指针调整,就不得不提thunk。这词儿听起来挺唬人,其实说白了就是一段「胶水代码」。它的作用很简单:调整this指针,然后跳转到真正的函数。

举个例子,上面Derived::bar()的调用过程:

  1. 通过Base2*找到虚函数表
  2. 从表中取出bar()对应的槽位
  3. 槽位里存的不是Derived::bar()的地址,而是一个thunk的地址
  4. thunk执行:this -= sizeof(Base1)(调整到Derived起始地址)
  5. thunk跳转到Derived::bar()

你可能会问:为什么不直接在虚函数表里存偏移量,让调用方自己调整?嗯,这里有个历史原因——早期的C++实现就是这么干的,但后来发现thunk更灵活。比如,thunk可以处理虚继承这种更复杂的场景,而且对调用方完全透明。

注意: thunk不是C++标准规定的,而是编译器实现细节。不同编译器的thunk策略可能不同。比如,MSVC和GCC在虚继承的thunk处理上就有细微差别。跨平台代码如果依赖了这些细节,移植时可能会踩坑。

12.4 虚继承——钻石问题的解法

虚继承,说白了就是为了解决「钻石继承」问题。看这个经典例子:

class A {
public:
    int a;
    virtual void func() {}
};

class B : virtual public A {
public:
    int b;
};

class C : virtual public A {
public:
    int c;
};

class D : public B, public C {
public:
    int d;
};

如果不加virtual,D里会有两份A的子对象,导致歧义。加了virtual,编译器保证只有一份A

但代价是什么?内存布局变得复杂了。虚继承的类不能像普通继承那样「拼接」,因为A子对象的位置在编译时不确定——它可能被放在对象的末尾,也可能被放在某个固定偏移处。

编译器通常用两种方式解决:

  • 虚基类指针(vbptr):每个虚继承的类里存一个指针,指向虚基类子对象。
  • 虚基类表(vbtable):表中记录虚基类相对于当前子对象的偏移量。

我见过一个项目,因为滥用虚继承,导致每个对象多了好几个指针,内存膨胀了30%。所以我的建议是:虚继承能用,但别滥用。能用组合解决的问题,就别搞虚继承。

12.5 虚继承的内存布局实战

咱们用代码验证一下。假设上面的D对象,在64位系统下,内存布局大概是这样:

|------------------|
| B::vptr          |  ← 指向B的虚函数表(含虚基类偏移信息)
| B::b             |
|------------------|
| C::vptr          |  ← 指向C的虚函数表(含虚基类偏移信息)
| C::c             |
|------------------|
| D::d             |
|------------------|
| A::vptr          |  ← 指向A的虚函数表
| A::a             |
|------------------|

注意,A子对象被放在了最后。为什么?因为BC都不知道D的存在,它们只能通过vbptr间接找到A。把A放在末尾,可以保证BC的布局不受影响。

当你通过B*访问A::a时,编译器会:

  1. B的vbtable中取出A的偏移量
  2. 计算地址:this + 偏移量
  3. 访问该地址处的a

这个过程在运行时完成,所以虚继承的成员访问比普通继承慢一些。我做过测试,虚继承的成员访问比普通继承慢了大约10%-20%。

核心总结:

  • 多重继承:多个基类子对象按顺序拼接,每个基类有自己的vptr
  • this调整:通过偏移量把指针「掰」到正确的子对象起始位置
  • Thunk:一段胶水代码,负责调整this并跳转,对调用方透明
  • 虚继承:通过vbptr/vbtable解决钻石问题,但牺牲了性能和内存
C++对象模型(下)核心知识结构 多重继承 基类子对象拼接 多个vptr共存 this指针调整 偏移量计算 运行时指针修正 Thunk技术 胶水代码 调整+跳转 虚继承 解决钻石问题 vbptr + vbtable 性能开销约10-20% 内存布局实战 虚基类放末尾 偏移量运行时计算 核心总结 理解布局是关键 避免滥用虚继承

好了,这一章的内容就到这儿。多重继承和虚继承的内存布局,说白了就是编译器在背后做了很多「手脚」——调整指针、插入thunk、维护偏移表。理解这些,你就能在遇到诡异的内存问题时,多一个排查方向。下一章我们聊聊模板元编程的入门,那又是另一个世界了。

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