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()的调用过程:
- 通过
Base2*找到虚函数表 - 从表中取出
bar()对应的槽位 - 槽位里存的不是
Derived::bar()的地址,而是一个thunk的地址 - thunk执行:
this -= sizeof(Base1)(调整到Derived起始地址) - 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子对象被放在了最后。为什么?因为B和C都不知道D的存在,它们只能通过vbptr间接找到A。把A放在末尾,可以保证B和C的布局不受影响。
当你通过B*访问A::a时,编译器会:
- 从
B的vbtable中取出A的偏移量 - 计算地址:
this + 偏移量 - 访问该地址处的
a
这个过程在运行时完成,所以虚继承的成员访问比普通继承慢一些。我做过测试,虚继承的成员访问比普通继承慢了大约10%-20%。
核心总结:
- 多重继承:多个基类子对象按顺序拼接,每个基类有自己的vptr
- this调整:通过偏移量把指针「掰」到正确的子对象起始位置
- Thunk:一段胶水代码,负责调整this并跳转,对调用方透明
- 虚继承:通过vbptr/vbtable解决钻石问题,但牺牲了性能和内存
好了,这一章的内容就到这儿。多重继承和虚继承的内存布局,说白了就是编译器在背后做了很多「手脚」——调整指针、插入thunk、维护偏移表。理解这些,你就能在遇到诡异的内存问题时,多一个排查方向。下一章我们聊聊模板元编程的入门,那又是另一个世界了。