这一篇解决什么问题
单继承里基类永远舒舒服服躺在偏移 0。多继承打破了这份平静:两个基类不可能同时在偏移 0。这一篇回答:
- 多继承对象的内部怎么摆?
- 指向第二个基类的指针,和对象地址还相同吗?
- 通过第二个基类指针调虚函数,this 是怎么”拨回”派生类的?
多继承的三个要点
要点 1:基类子对象按声明顺序排列,每个含虚函数的基类带一份 vptr。class C : public A, public B 的布局是:A 子对象(含 A 的 vptr)→ B 子对象(含 B 的 vptr)→ C 自己的成员。C 的对象里有两个 vptr,分别服务两张虚表视角。
要点 2:指向第二个基类的指针 ≠ 对象地址。A* pa = &c 地址不变(A 在偏移 0);B* pb = &c 则是 &c + sizeof(A)——指针被编译器悄悄加了偏移。这个调整在指针转换时静态完成,和虚表无关。
要点 3:经第二个基类调用被重写的虚函数,需要把 this 调回去。pb->fb() 时,pb 指向 B 子对象,但 C::fb 期望的 this 是完整的 C 对象(首地址)。差值就是 B 子对象的偏移。MSVC 的解法:C 的”B 视角虚表”里,fb 那一项存的不是 C::fb 本身,而是一小段调整桩(adjustor thunk):先把 this 减去偏移,再跳进 C::fb。在反汇编里能看到名字带 adjustor{16} 字样的跳转目标。
验证工程
1 | // 多继承: 两个 vptr, 基类指针的地址偏移, this 调整 thunk, 成员函数指针. |
预测输出
动手跑之前先推一遍:
- A、B 各 16(vptr 8 + int 4 + padding 4);C = A 16 + B 16 + int 4 + padding 4 = 40。
pa == &obj;pb - pa = 16:B 子对象从 A 之后开始。- 输出
C::fa、C::fb:多态正常,只是 pb 那一路多走了一个 adjustor thunk。 - 成员函数指针预测为 16 字节:多继承下它除了函数地址还要携带 this 调整量,比普通函数指针(8)大。这个数字各编译器差异很大,以实测为准。
实测输出
VS2022 x64 Debug 下实际运行:
1 | sizeof(A)=16 sizeof(B)=16 sizeof(C)=40 |
逐行比对
- sizeof 16/16/40:命中。
pa == &obj == ...FAD8:命中,第一个基类在偏移 0。pb = ...FAE8,比&obj大0x10(16):命中,B 子对象从 A 之后开始,指向 B 的指针被编译器悄悄加了sizeof(A)的偏移。sizeof(&C::fa)=16,普通函数指针 8:命中,多继承下成员函数指针携带 this 调整信息,体积翻倍。
this 调整的实测验证
最后两行是这篇文章最直接的证据。C::fb 是经由 pb(指向 B 子对象,…FAE8)调用的,调用约定里 rcx 装的也是 pb;但函数体里打印出来的 this 却是 ...FAD8,和 &obj 完全一致——中间有人把 this 减了 16,这就是 adjustor thunk 干的活。对照组 C::fa 经 pa(第一基类,偏移 0)调用,this 本来就等于 &obj,不需要调整。
值得记录的一次弯路:我先在反汇编里单步找 thunk,调用点(mov rcx,[pb]; call [rax])和单继承长得一模一样,F11 跟进增量链接跳转表(ILT)后直接落到了 C::fb 函数体,没看到预想中的 sub rcx, 16,一度怀疑 thunk 不存在。最后靠”让函数自己打印 this”这个更直接的办法确认了调整确实发生——thunk 是编译器生成的一小段无名代码,夹在跳转表和函数体之间,单步时容易一眼掠过。教训:验证 this 调整,打印 this 比读汇编可靠。
用 /d1 让编译器自证
/d1 reportAllClassLayout 对 class C 的报告(去掉了开头的 1> 编译器前缀):
1 | class C size(40): |
布局部分和运行输出逐项对上:A 子对象在偏移 0(含一份 vfptr),B 子对象在偏移 16(含第二份 vfptr),c 在 32,总共 40。
最有说服力的是最后两行:C::fa this adjustor: 0、C::fb this adjustor: 16——编译器白纸黑字写明了:经 B 视角调 fb,this 要调整 16 字节。这就是 adjustor thunk 存在的官方自证。
还有一个容易漏看的细节:C::$vftable@B@ 的表头是 -16(A 视角那张是 0)。表头这一项记录的是”这个子对象离完整对象开头的偏移”,RTTI 和 dynamic_cast 靠它从基类子对象找回完整对象——上一篇结尾说 dynamic_cast 需要虚函数支持,机制上就落在这里。
小结
1 | 多继承布局: [A 子对象(vptr1)][B 子对象(vptr2)][C 成员] |