news 2026/9/29 3:30:41

多重继承与虚继承:两个 vptr 与掰弯的指针

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多重继承与虚继承:两个 vptr 与掰弯的指针

① 钩子:一个对象能有两个 vptr?

一个对象能有两个 vptr?——能。只要你多重继承两个带虚函数的基类。

更反直觉的是:调用"第二个基类"的虚函数时,程序得先把指针**“掰弯”**(加一个偏移),才能找到正确的虚表和子对象。而一旦牵涉虚继承(钻石问题),代价还要再翻一档。

这一集我们从sizeof实测出发,把多重继承和虚继承的布局、指针调整(adjustor thunk)、虚基类偏移表全部解剖开。

② 源码 vs 实测对照

structA{virtualvoidfa(){}inta;};structB{virtualvoidfb(){}intb;};structC:A,B{virtualvoidfc(){}intc;};structVBase{intv;};structD1:virtualVBase{intd1;};structD2:virtualVBase{intd2;};structDiamond:D1,D2{intd;};voidcall_fb(C*c){c->fb();}// 调用第二个基类的虚函数

本机实测sizeof(g++ 15.2.0,x86-64,Itanium ABI):

A=16 B=16 C=32 VBase=4 D1=16 D2=16 Diamond=40

C的布局——两个 vptr:

偏移 0 8 12 16 24 28 32 ┌──────────┬────┬────┬──────────┬────┬────┐ │ vptrA │ a │ pad │ vptrB │ b │ c │ └──────────┴────┴────┴──────────┴────┴────┘ A 子对象(16B) B 子对象(16B)

call_fb(C*)的汇编(-O2)——看"掰弯"发生在哪:

call_fb(C*): leaq B::fb(%rip), %rdx ; 预取 B::fb 地址(投机去虚化,见 E08) movq 16(%rcx), %rax ; ← 从偏移 +16 取 vptr(B 子对象的虚表) movq (%rax), %rax ; vtable[0](fb 的槽位) cmpq %rdx, %rax jne .L5 ret ; 投机命中:B::fb 是空函数 .L5: addq $16, %rcx ; ← 指针"掰弯":C* 加 16 → 指向 B 子对象 jmp *%rax ; 然后才做虚调用

Diamond的布局——共享的 VBase 只出现一次:

偏移 0 16 32 36 40 ┌──────────┬──────────┬────┬────┐ │ D1 子对象 │ D2 子对象 │ d │ v │ │ vptr+d1 │ vptr+d2 │ │ │ └──────────┴──────────┴────┴────┘ 16B 16B 4B 4B ← 唯一的 VBase 共享子对象

③ 为什么这么设计

  • 多重继承 = 每个多态基类放一份子对象,各带一个 vptr。C里有 A 子对象和 B 子对象,所以对象开头有两个 vptr、两个虚表。对象更大,虚调用也更绕。
  • "掰弯"是必须的:调用fb()时,编译器手里的指针是C*(指向对象开头),但 B 子对象在偏移 +16 处。它得先算"B 子对象在哪",取那里的 vptr,查表,再把指针调整到 B 子对象上——这条addq $16就是指针调整(adjustor thunk)。每次跨基类虚调用都背着这笔开销。
  • 虚继承解决"钻石歧义":Diamond同时虚继承自D1和D2,而两者又都虚继承VBase。普通继承会让VBase出现两份;虚继承保证只存一份(上面布局里v只出现一次)。
  • 虚继承的代价:虚基类子对象的位置不再固定,要靠在 vtable 里的偏移表(vbase offset)运行时查找。所以虚继承访问虚基类成员比普通继承多一次间接,对象也更大(多了用于寻址的 vptr/偏移空间)。
  • 对比一句话:普通多重继承是"静态布局,编译期算偏移";虚继承是"运行时查偏移表"。

④ 深入一:把指针掰弯这件事拆到骨子里

call_fb里的addq $16, %rcx是关键。它回答了一个微妙的问题:虚函数fb是 B 的成员,它的this应该指向 B 子对象,而不是 C 对象的开头。

  • C 对象的开头是 A 子对象(含 vptrA);
  • B 子对象从偏移 16 开始;
  • 调用fb()时,进入B::fb的this必须是 B 子对象的地址;
  • 所以编译器必须在跳转前把C*调整成B*(+16)。

这就是adjustor thunk的一种形态。更常见的情形是:

B::fb thunk: addq $16, %rdi ; this += 16(把 C 的 this 调成 B 子对象) jmp B::fb ; 再跳真正的实现

当派生类覆盖了基类虚函数时,编译器会为"从 B 视角调用"生成一个 thunk,专门负责"把 this 掰到正确子对象"。所以派生类的虚表里,指向派生覆盖函数的槽位,有时存的是 thunk 的地址而不是函数本体——这就是"隐藏代码"的又一例。

⑤ 深入二:虚继承的"运行时查偏移"

普通继承:Diamond里的v在编译期就固定了偏移(+36),movl 36(%rax), %eax一条指令搞定。

虚继承:因为VBase子对象的位置不固定(虚基类可以出现在对象尾部,且不同最派生类布局不同),编译器必须运行时查表:

; 访问 diamond.v(虚基类成员) movq 8(%rax), %rax ; 从 vptr 附近取"虚基类偏移表"指针 movl (%rax), %eax ; 查偏移(可能还要加) addq %rax, %rcx ; 定位 VBase 子对象 movl (%rcx), %eax ; 这才读到 v

对比普通继承的"一条movl 偏移",虚继承多了两次间接访问 + 一次加法。这就是"虚继承是持续税"的机器理由:每次访问虚基类成员,都在为"运行时才知道基类在哪"买单。

⑥ 常见误区

  • 误区 1:“多重继承很慢”:不一定。非虚的多重继承在"访问自己/第一基类成员"时和单继承一样快(编译期偏移);只有跨基类虚调用、虚继承访问才慢。
  • 误区 2:“钻石问题只能靠虚继承解决”:虚继承只是"共享子对象"的答案;很多场景改用组合(把公共部分作为成员)更简单、更快。
  • 误区 3:“C*转B*是零成本”:错,涉及指针偏移(+16),是真实的一条指令(见 E10 的dynamic_cast更贵)。
  • 误区 4:“虚继承让对象变小”:正相反,虚继承对象通常更大(多了偏移表指针/寻址开销),Diamond=40就是证据。而且虚继承访问虚基类成员每次都要"查表定位",指令数多于普通继承的"直接偏移"。
  • 误区 5:“两个 vptr 只是浪费 16 字节”:不止——跨基类虚调用每次都要指针调整,虚继承访问每次都要查表,这是运行时的持续开销。换句话说,多继承的"税"分两笔:一笔是对象变大(空间),一笔是每次跨基类调用/虚基类访问多几条指令(时间)。
  • 误区 6:“dynamic_cast到基类也是运行时开销”:向上转型(派生→基类)通常是编译期完成的(指针调整是常量偏移),只有向下/跨枝转型(基类→派生、兄弟基类)才需要运行时__dynamic_cast(E10 详述)。别把所有转换都当"慢"。向上转型(C*→A*或C*→B*)在汇编里只是addq $0/$16,零运行时决策。

⑦ 实战启示

  1. 别为"看着面向对象"随意多重继承:每多一个多态基类 = 多一个 vptr + 更绕的虚调用 + 更大的对象。
  2. "接口多继承"可以但要有数:纯抽象接口(无成员)作为基类代价主要是 vptr 数量;带数据成员的多继承成本明显更高。
  3. 能用组合就组合:真出现钻石依赖,先想"是不是该用组合/指针成员代替继承",而不是直接上虚继承——虚继承的运行时间接访问是持续税。
  4. 跨基类的类型转换有成本:把C*转成B*涉及指针偏移调整,dynamic_cast到兄弟基类更是运行时路径(见 E10)。
  5. 关心布局就用static_assert(sizeof(...)==N)守门:把关键类的体积固化成断言,别人改坏了编译立刻失败(E23 会展开 ABI 一致性)。

⑧ 扩展专题一:C++ 的"对象模型"其实是 ABI 的产物

你可能好奇:为什么C里 A 子对象在前、B 在后?为什么 vptr 在最前?这些不是 C++ 标准规定的,而是Itanium C++ ABI(本系列用的 g++/clang 遵循它)规定的布局规则。标准只保证"可观察语义",布局细节交给 ABI。

理解这一点很重要:

  • 换编译器/换平台,布局可能变(E23 会实测 Windows vs Linux 的差异);
  • 但同一 ABI 内布局稳定,所以sizeof/offsetof可以跨翻译单元一致;
  • 依赖布局的代码(序列化、内存映射结构体)必须锁死 ABI,否则就是定时炸弹。

C的 32 字节、Diamond的 40 字节,都是 Itanium ABI 在 x86-64 下的具体产物。我们本系列所有"实测数字"都以"g++ 15.2.0 + x86-64 + Itanium ABI"为基准。

⑨ 扩展专题二:怎么"看见"对象的完整布局

工程上想知道一个复杂继承对象的布局,有三个手段:

  1. offsetof逐个打(只对标准布局类型合法——注意带虚函数的类不是 standard layout,见 E06);
  2. clang -fdump-record-layouts:直接把每个类的成员偏移、大小、对齐画成 ASCII 布局图,这是最直观的;
  3. 看构造函数反汇编:vptr 被写入哪些偏移、成员在哪些偏移构造,都能从-O0 -S还原出布局。

例如用 clang 的-Xclang -fdump-record-layouts编译E09_mixin.cpp,你会看到每个类的字段偏移表,和本集画的 ASCII 图完全一致——这是把"猜测布局"变成"读官方数据"的利器。

⑩ 扩展 FAQ

  • Q:C里为什么是 32 字节而不是 28?
    A:A 子对象 16 字节(vptr8 + a4 + 尾对齐4),B 子对象 16 字节(vptr8 + b4 + c4),加起来正好 32,且整体对齐 8。c 在 B 子对象的 b 之后,没有额外填充。
  • Q:多重继承下typeid(*c)返回什么?
    A:返回最派生类型(C)的 typeinfo。vptr 通常指向"最派生类"的 vtable,RTTI 信息从那里取(E10 详述)。
  • Q:Diamond里v为什么在最后?
    A:Itanium ABI 把虚基类子对象放在对象尾部(越虚越靠后),普通基类/成员按声明顺序在前。这是该 ABI 的约定,不是 C++ 标准要求的。
  • Q:虚继承真的比组合慢很多吗?
    A:访问虚基类成员每次多 2 次间接,但现代 CPU 的 cache 可能让差距很小;真正的成本在"每条访问都多指令"。除非有明确的钻石需求,否则组合更省心。
  • Q:override/final和多重继承冲突吗?
    A:不冲突。final类不能作为基类;但多重继承里可以给个别虚函数加final,编译器就能对那个槽位去虚化(E08 讲过)。

⑪ 扩展实验

  1. 跑sizeof:编译运行E09_mixin.cpp,确认A=16 B=16 C=32、VBase=4 D1=16 D2=16 Diamond=40。
  2. 看 thunk:给C覆盖fb()(void fb() override {}),g++ -O2 -S,在汇编里找B::fb相关的 thunk(带addq $16的那段)。
  3. -fdump-record-layouts:用 clang 看每个类的字段偏移表,和本集 ASCII 图对照。
  4. 组合对照:把Diamond改成"组合两个非虚成员 + 共享 VBase 成员",sizeof对比哪个更小、访问哪个更快。
  5. 跨基类转换:C*转B*和转A*,反汇编看addq偏移是否不同(A 是 +0,B 是 +16)。

⑬ 扩展专题三:多重继承下"虚表不止一张"的完整图景

本集说C有两个 vptr、两张 vtable,但更准确地说:一个多态类有几条"多态路径",就有几个 vtable。

以C : A, B为例:

  • 一张primary vtable(主表,对应 A 子对象,从对象开头被 vptrA 指向)——含 A 的虚函数 + C 新增的fc;
  • 一张secondary vtable(次表,对应 B 子对象,从偏移 16 被 vptrB 指向)——含 B 的虚函数。

两条路径的虚表槽位各自独立。如果 C 覆盖了 B 的fb,那么:

  • vptrB 指向的表里,fb槽位存的是thunk(先把 this +16,再跳C::fb);
  • vptrA 指向的表里,fb不在其中(那是 A 路径的表)。

这个"一对象多表 + thunk 串场"的设计,让"通过任何基类指针调用虚函数"都正确,代价就是内存里有 N 张表、跨路径调用多一层指针调整。

⑭ 扩展专题四:纯抽象接口的"最佳实践"与成本模型

接口多继承(mixin)是多重继承最常见的合法用途:

structDrawable{virtualvoiddraw()const=0;virtual~Drawable()=default;};structClickable{virtualvoidonClick()=0;virtual~Clickable()=default;};structButton:Drawable,Clickable{...};
  • 纯接口无数据成员,所以每个接口贡献 8 字节 vptr,两个接口 = 对象开头两个 vptr;
  • 若所有接口都是纯虚(无成员、无实现),跨接口虚调用仍要 thunk 调整(Button*→Clickable*子对象);
  • 析构函数必须是虚的,否则通过Drawable*delete 会泄漏Button的部分(E07 讲过)。

成本模型:每多一个多态基类 ≈ +8 字节 vptr + 跨基类虚调用 +1 次指针调整。对"真需要多接口"的设计这是合理代价;对"只想要一个接口但图省事多继承"就是浪费。

⑮ 扩展 FAQ(第二轮)

  • Q:std::is_polymorphic_v<C>和std::is_multiple?
    A:is_polymorphic只问"有没有虚函数"(有即 true),不关心继承形态。多重继承没有专门的 trait,用sizeof与alignof判断布局即可。
  • Q:虚继承和虚函数在"表"上如何共存?
    A:一个类的 vtable 里既有虚函数槽位,也可能有虚基类偏移条目(vbase offset)。它们住在同一张表的不同区域,这正是"查表"访问虚基类的入口。
  • Q:Diamond里访问v有几种路径?
    A:从Diamond*直接访问是最派生类视角,偏移固定或查主表;从D1*/D2*访问则是"从虚基类子对象视角",查各自的偏移表。所以同一个v,不同视角的成本不同。
  • Q:为什么 C++ 里"继承两个带实现的类"是坏味道?
    A:除了布局成本,还有语义混乱(两份数据、二义性、菱形)。实践中"实现复用"用组合/继承单类,"接口抽象"用多接口继承。C++ 允许多重继承但工程上慎用,本集给你的是"为什么"的机器理由。
  • Q:final类能多重继承吗?
    A:final限制的是"它作为基类";它自己仍然可以继承多个基类。final的作用仍是给编译器"不会有更派生类"的信息,利于去虚化。
  • Q:本集所有数字都是"Windows 下的 g++"吗?
    A:是的,基线是 g++ 15.2.0 + x86-64 + Itanium C++ ABI。Windows 的 MSVC 用的不是 Itanium ABI,布局/虚表细节可能不同;但"多基类多 vptr、跨基类要调指针、虚继承要查偏移表"这些原则在所有主流 ABI 下成立。E23 会专门做 Windows vs Linux 的 ABI 对照。

⑯ 扩展实验(第二轮)

  1. thunk 深度观察:覆盖 B 的fb后,g++ -O2 -S,在C::fb附近找addq $16, %rdi; jmp C::fb形态的 thunk,确认"串场跳转"。
  2. 三接口对比:继承 1/2/3 个纯接口,sizeof分别是 8/16/24,验证"每接口 +8 vptr"。
  3. 虚基类访问反汇编:Diamond*访问vvsD1*访问v,对比汇编里"查表间接"vs"直接偏移"的差别。
  4. 组合替代实验:把接口多继承改造成"成员对象 + 转发函数",sizeof和调用开销对比,体会"继承 vs 组合"的取舍。

⑱ 扩展专题五:一个"会变"的偏移——为什么虚基类在尾部

你可能已经注意到Diamond布局里v(VBase 的成员)被放到了对象尾部。这不是偶然,而是 Itanium ABI 的明确约定:虚基类子对象排在所有普通基类和成员之后,且多个虚基类按"先继承的先排、越虚越靠后"排序。

为什么这么设计?因为虚基类子对象的位置不能影响"普通部分"的布局:

  • 如果 VBase 放在 D1 前面,那么 D1 里的d1偏移会被"虚基类是否真的出现、有多大"影响;
  • 把它甩到尾部,普通成员(d1、d2、d)的偏移就固定了,与"是否有其他类也虚继承 VBase"无关;
  • 代价是:普通继承里"编译期算偏移"变成"运行时查表定位"(本集 ⑤ 已展开)。

这个"固定普通部分、偏移虚部分"的取舍,是 ABI 在"布局稳定"与"共享子对象"之间找的平衡点。理解它,你就理解了为什么虚继承必然比普通继承多一次间接——位置不固定,就必须运行时问"在哪"。

⑲ 扩展专题六:从 E09 到 E10 的桥——RTTI 如何利用 vptr

本集讲完多 vptr 布局,正好回答 E10 的一个细节:typeid怎么知道对象的真实类型?

  • 每个多态对象的 vptr 指向"它所属类"的 vtable;
  • vtable 的"前面"(vptr 指向位置再往前偏移 8 字节)存着该类的type_info指针;
  • 所以typeid(*b)的机器实现是:b→ vptr → 往前 8 字节 →type_info,再和typeid(Der)的type_info比较。

这就是为什么 E10 里你会看到movq -8(%rax), %rcx这条指令——对象头一个 vptr,既通向函数表(往后查),也通向类型信息(往前查)。多重继承下,vptrB 往前 8 字节拿到的则是"B 视角"的类型信息;而dynamic_cast更复杂,要靠 vtable 里的更多信息沿继承链走(E10 会给出__dynamic_cast的运行时调用)。

⑳ 扩展 FAQ(第三轮)

  • Q:多重继承 + 虚继承一起用,布局会怎样?
    A:会同时出现"多个普通子对象(多 vptr)+ 虚基类子对象在尾部 + 偏移表"。布局更复杂,sizeof通常更大。工程上这类组合很少见,出现就该反思设计。
  • Q:为什么说"多重继承是 C++ 的黑魔法"?
    A:因为它的正确性依赖 ABI 的布局规则(thunk、vbase offset、RTTI 表),而这些规则对使用者是"隐藏代码"。用得好是 mixin,用得乱就是 UB 温床。
  • Q:-fdump-record-layouts需要 clang,g++ 怎么办?
    A:g++ 可用-fdump-lang-class(较老)或-fdump-class-layout。不同版本选项名略有差异,g++ --help=common | grep dump可查。看汇编也能还原布局(vptr 写入偏移、成员构造偏移)。
  • Q:对象里 vptr 的顺序能自定义吗?
    A:不能。Itanium ABI 规定:vptr 在对象开头(每个多态子对象的开头),布局顺序由继承顺序决定。标准不管这些,但 ABI 管——换 ABI 布局就变。
  • Q:多重继承和"按位可拷贝"冲突吗?
    A:带虚函数的类本来就不是 trivially copyable 意义下的"可 memcpy 类型"。多重继承只是让它更明显——两个 vptr 一起拷,若类型不符就是 UB。

㉑ 扩展实验(第三轮)

  1. 三基类:struct X : A, B, D1 { };,sizeof(X)与布局对比,验证"每多一个普通基类子对象 = 多 16 字节(vptr + 成员)"。
  2. 虚继承 + 多态:给 VBase 加虚函数,Diamond的sizeof再涨,反汇编看虚基类偏移表如何与 vtable 共存。
  3. RTTI 联动:对C对象执行typeid,反汇编确认"从 vptr 往前 8 字节拿 type_info"。
  4. 组合 vs 继承性能:分别用"组合共享成员"和"虚继承共享成员"实现同功能,-O2后对比访问共享成员的汇编条数。
  5. ABI 换平台的差异:同一cpp用-m32(若支持)或换 clang 交叉目标编译,sizeof是否变化——体会"A 布局是 ABI 的产物"。

㉒ 悬念

编译器到底怎么知道一个对象的真实类型,好让dynamic_cast、typeid工作?虚表里还藏着一个指向类型信息的指针。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 3:30:41

异常与 RTTI 的代价

① 钩子&#xff1a;try/catch 不经过 if&#xff0c;为什么总被说"贵"&#xff1f; try/catch 不经过 if&#xff0c;为什么总被说"贵"&#xff1f; 真相是反直觉的&#xff1a;不抛异常时几乎零成本&#xff08;这就是"零成本异常模型"&#x…

作者头像 李华
网站建设 2026/9/29 3:30:39

PWM调节DCDC参数计算原理:从占空比到环路补偿的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 3:30:25

YOLOv11改进版精密零件缺陷检测实战指南

简介&#xff1a;本资源是一份面向工业视觉算法工程师与质检系统开发者的深度技术文档&#xff0c;聚焦YOLOv11改进版在精密零件缺陷检测场景中的落地实践&#xff0c;着力解决传统方法精度不足、小目标漏检及产线部署效率低等核心痛点。文档共30页PDF&#xff0c;结构完整、支…

作者头像 李华
网站建设 2026/9/29 3:28:49

unslop-review - SKILL

name: unslop-review description: ‘Rewrites code review comments so they read like a human teammate wrote them. Cuts corporate-AI throat-clearing (“I noticed…”, “I was wondering if perhaps…”, “It might be worth considering…”). Each comment is dire…

作者头像 李华