第13章:继承体系的内存布局:单继承与多态代价

继承不是玄学,是内存里的一笔笔账:基类子对象排在前、自己的成员跟在后,vptr 藏在对象开头——多态的全部魔法都建立在这些字节的排列上。本章的每一处布局都上机实测,把单继承、多继承、虚继承的大小与代价盘个明明白白

🏛️

本章导师:狄仁杰

核心方法论:系统分析

「升堂断案,最忌拍脑袋。继承、虚函数、多继承、虚继承——这些词听着玄,其实每一桩都是一本账:对象占几字节、vptr 藏在哪个偏移、基类子对象排在第几格,账上都写得清清楚楚。这一章我们不当法师当仵作,把每一类对象的'尸体'摆上解剖台:sizeof 是尺,地址是秤,量出来的数字不会说谎。」

13.1 单继承的布局:基类子对象 + 自己的成员

先立一条最朴素的总账规则:继承在内存里的本质,就是把基类的成员"原样搬进"派生类对象。搬进来的这一整段连续内存,叫基类子对象(base class subobject,说人话:基类在派生类对象里占据的那段区域,它不是指针、不是引用,就是实实在在的一段成员序列)。在单继承(只有一个直接基类)下,布局规则只有一句:基类子对象排最前,自己的成员接在后面。第 12 章说过成员函数不占对象空间,所以算账只数成员变量,再套用第 10 章的对齐规则。

看个最简单的实测:基类 Base 有两个 int,派生类 Son 自己加一个 int。如果"继承是引用"的错觉成立,Son 应只比 Base 大一个指针;真相是:

// inherit1.cpp —— 单继承:基类子对象 + 自己的成员(本机实测)
#include <cstdio>
#include <cstddef>

struct Base { int a; int b; };
struct Son : public Base { int c; };

int main() {
    printf("sizeof(Base)=%zu sizeof(Son)=%zu\n", sizeof(Base), sizeof(Son));
    printf("offsetof: a=%zu b=%zu c=%zu\n",
            offsetof(Son, a), offsetof(Son, b), offsetof(Son, c));
    Son s; Base* p = &s;
    printf("&s=%p 基类子对象=%p 偏移=%td\n",
            (void*)&s, (void*)p, (char*)p - (char*)&s);
}
# 真实输出(Apple clang 21 / arm64)
sizeof(Base)=8 sizeof(Son)=12
offsetof: a=0 b=4 c=8
&s=0x16f1fe0c4 基类子对象=0x16f1fe0c4 偏移=0

数字对上了总账:ab 原封不动排在偏移 0 和 4,自己的 c 从 8 开始,共 12 字节,正好 4+4+4。而 (Base*)&s&s 地址相同、偏移为 0——单继承下基类子对象就住在对象开头,指针转换"一个字节都不用挪"。布局图画出来就是:

# Son 布局(偏移:字节)
偏移: 0    4    8    12
     ┌──────┬──────┬──────┐
Son  │ a    │ b    │ c    │
     ├──────┴──────┤      │
     │ Base 子对象  │ 自己的 │
     └─────────────┴──────┘
@基类子对象在偏移 0 大小 8;自己的 c 接在 8~11;共 12
类型成员sizeof基类子对象偏移说明
Baseint a; int b;8基类自己就是普通结构体
Son : Base+ int c;120基类子对象整体搬入,自己的成员接后
狄仁杰提示

记住这条总账:继承 = 把基类的成员序列原样嵌入派生类对象,单继承下基类子对象固定排在偏移 0。派生类对象"就是一个基类对象 + 多出来的尾巴",所以 Base* 指向 Son 时读到的永远是完整的基类部分——多态的根基就埋在这里。

注意:别把继承理解成"对象里存了一个指向基类的指针"。sizeof(Son) = 12 而不是 8 + 8,铁证如山:基类子对象是按值嵌入的成员序列,不是引用。加多少个基类成员,就多占多少字节(还要按第 10 章规则对齐取整)。

13.2 虚函数继承:vptr 与 vtable 的传承

第 12 章我们见过:类只要声明了虚函数,对象开头就多出一个虚表指针(vptr,virtual table pointer,指向虚函数表的指针,占 8 字节)。派生类继承了虚函数,vptr 怎么排?答案很干脆:vptr 跟着基类子对象走,仍在对象开头,派生类不新造 vptr,只在自己的尾部加成员。但有个关键细节:对象里存的是"指向哪张表"的指针,而虚函数表(vtable,按槽位排着函数指针的表)是每个类一张、全类对象共享的。构造 Son 对象时,编译器会把 vptr 填成"指向 Son 自己的虚表"——哪怕基类子对象排在开头,这张表也已经换成儿子的了。上机看:

Base 加一个虚函数 print()Son 重写它并加 int c。按总账推算:Base = vptr(8) + a(4) + b(4) = 16;Son = 16 + c(4) = 20,对齐到 8 = 24。实测并打印两个对象首字节处的 vptr:

// inherit2.cpp —— 虚函数继承:vptr 的传承(本机实测)
#include <cstdio>
#include <cstddef>

struct Base {
    int a; int b;
    virtual void print() const { printf("Base::print\n"); }
};
struct Son : public Base {
    int c;
    void print() const override { printf("Son::print\n"); }
};

int main() {
    printf("sizeof(Base) = %zu\n", sizeof(Base));
    printf("sizeof(Son)  = %zu\n", sizeof(Son));
    printf("offsetof: a=%zu b=%zu c=%zu\n",
            offsetof(Son, a), offsetof(Son, b), offsetof(Son, c));
    Base b; Son s;
    printf("&b = %p, vptr = %p\n", (void*)&b, *(void**)&b);
    printf("&s = %p, vptr = %p\n", (void*)&s, *(void**)&s);
    Base* p = &s;
    p->print();   // 动态分派:调用的却是 Son::print
}
# 真实输出(地址是 ASLR 随机值,每次运行不同)
sizeof(Base) = 16
sizeof(Son)  = 24
offsetof: a=8 b=12 c=16
&b = 0x16fd120c0, vptr = 0x1000f0020
&s = 0x16fd120a8, vptr = 0x1000f0048
Son::print

账目完全对上:vptr 占 0~7,a 从 8、b 从 12、自己的 c 从 16 开始,总大小 24。BaseSon 对象的 vptr 值不一样——它们指向各自的虚表。所谓动态分派(dynamic dispatch,运行时才决定调用哪个函数)说人话就三步:① 从对象开头读 vptr;② 按虚函数在虚表中的槽位取函数指针;③ 间接调用。虚表每个槽位 8 字节,按虚函数声明顺序排号。下面这段是本机编译的真实汇编(arm64,-O0),fBase 的第 3 个虚函数(前两个槽位被析构相关项占用),槽位偏移 2×8=16

# call_virtual:b.f() 的虚调用汇编节选(arm64)
        ldr     x0, [sp, #8]      ; 加载对象地址(this)
        ldr     x8, [x0]          ; 第 1 跳:读对象开头的 vptr
        ldr     x8, [x8, #16]     ; 第 2 跳:按槽位偏移 16 取函数指针
        blr     x8                ; 间接调用:目标运行时才定
# 对比:非虚函数 g() 的调用
        bl      __ZNK4Base1gEv    ; 直接跳转:地址编译期写死
Son 的成员偏移大小说明
vptr(虚表指针)08继承自基类子对象,指向 Son 自己的虚表
a84基类成员
b124基类成员
c164派生类自己的成员
总计(对齐后)2420 对齐到 8 的整数倍
狄仁杰提示

vptr 的"传承"一句话:继承的是"拥有虚函数"这件事,不是那张表。每个多态类一张虚表、类内对象共享;对象里的 vptr 是构造时按"实际构造的类型"填写的——构造 Son 时填指向 Son 虚表的地址。这就是为什么用 Base*print() 能喊出 Son::print

注意:虚函数有"起步价"——哪怕一个成员变量都没有,只要有一个虚函数,对象就有 8 字节(本机实测:空类 1 字节、带普通成员函数仍 1 字节、带一个虚函数立刻 8 字节、再加 int 变 16 字节)。这个 8 字节 vptr 是每个对象都要付的,对象一多账单就大——13.5 节专门算这笔总账。

13.3 多继承的布局:多个基类子对象

多继承(multiple inheritance,一个类同时继承两个或更多基类)的布局就是总账规则的直接延伸:几个基类子对象按声明顺序依次排列,然后才是自己的成员。注意:多继承和 13.4 节的虚继承是两码事——普通多继承下每个基类子对象都是完整的一份,谁都不让谁。让两个基类 AB 各自带一个虚函数(于是各自有 vptr),C : A, B 再加 int c

先手算:vptrA(0~8) + a(8~12),为了让第二个 vptr 对齐到 8,12~16 是填充;vptrB(16~24) + b(24~28),c 从 28 到 32。共 32 字节。账本手算容易错,上机量:

// inherit3.cpp —— 多继承:多个基类子对象依次排列(本机实测)
#include <cstdio>
#include <cstddef>

struct A { int a; virtual void fa() const {} };
struct B { int b; virtual void fb() const {} };
struct C : A, B {
    int c;
    void fa() const override {}   // 重写 A 的虚函数
};

int main() {
    printf("sizeof(A)=%zu sizeof(B)=%zu sizeof(C)=%zu\n",
            sizeof(A), sizeof(B), sizeof(C));
    printf("offsetof: a=%zu b=%zu c=%zu\n",
            offsetof(C, a), offsetof(C, b), offsetof(C, c));
    C c;
    A* pa = &c; B* pb = &c;
    printf("&c=%p 转A*=%p(偏移%td)转B*=%p(偏移%td)\n",
            (void*)&c, (void*)pa, (char*)pa-(char*)&c,
            (void*)pb, (char*)pb-(char*)&c);
}
# 真实输出(Apple clang 21 / arm64)
sizeof(A)=16 sizeof(B)=16 sizeof(C)=32
offsetof: a=8 b=24 c=28
&c=0x16d0fa0b0 转A*=0x16d0fa0b0(偏移0)转B*=0x16d0fa0c0(偏移16

测量和手算一致:C 对象里依次排着 A 子对象(vptrA + a,0~15)、B 子对象(vptrB + b,16~27)、自己的 c(28~31),共 32 字节。注意 b 的偏移是 24 而不是 12——B 子对象整体被推到了偏移 16。文字版布局:

# C 布局(偏移:字节)
偏移: 0      8    12  16     24   28 32
     ┌──────┬────┬────┬──────┬────┬────┐
C    │ vptrA│ a  │填充│ vptrB│ b  │ c  │
     └──────┴────┴────┴──────┴────┴────┘
     │◄── A 子对象 ──►│◄── B 子对象 ──►│
@12~16 是给 vptrB 对齐的填充;自己的成员排最后

布局的连锁反应是指针调整(pointer adjustment,说人话:派生类指针转基类指针时地址要挪动):C*A* 不用动(A 在开头),转 B* 却要加 16 字节。这个调整是编译器在 static_cast / 隐式转换时自动算好的(编译期算好偏移,转换时加一条指令);把一个对象当 B 用(如传给 void use(B&)),传的就是那个偏移 16 的地址。

指针转换转换后地址相对 C 开头偏移原因
C*A*0x16d0fa0b00A 子对象在开头,不用挪
C*B*0x16d0fa0c016B 子对象在偏移 16,指针要加 16
狄仁杰提示

多继承就是"总账规则的重复应用":N 个基类 = N 段依次排列的基类子对象 + 自己的成员。基类各自带虚函数就有 N 个 vptr。指针调整平时由编译器代劳,但 (B*)&c(void*)&c 不是同一个值这个事实,是排查"地址对不上"类 bug 的第一线索。

注意多继承 ≠ 虚继承,两个概念别混。普通多继承下每个基类子对象都是完整的一份;如果两个基类都继承自同一个祖先(菱形),祖先就会在对象里出现两份——这是 13.4 节要解决的事。另外多继承有二义性坑:两个基类有同名成员时 obj.name 直接编译报错,必须 obj.A::name 指名道姓。

13.4 虚继承:菱形继承问题的解法

把 13.3 再推一步,就撞上著名的菱形继承问题(diamond inheritance):YZ 都继承自 XW 又同时继承 YZ——按普通多继承的规则,W 对象里会塞进两份 X 子对象(一份走 Y 的路径、一份走 Z 的路径),形成"两个爷爷"的窘境:访问 x 有歧义,改其中一份另一份不知道,内存也白费。验证很简单,把两条路径的 X 地址打出来比一比:

虚继承(virtual inheritance)就是为解这个问题而生的:继承时加一个 virtual 关键字,声明"这个基类我不独占,大家共享"。实现上,对象里不再把 X 完整嵌入每个分支,而是放一个虚基类指针(vbptr,virtual base pointer)——虚基类表(vbtable)里记录着"共享的虚基类子对象排在对象哪个偏移",运行时靠它去"找"那份唯一的 X。用真实代码同时量虚继承和非虚菱形:

// inherit4.cpp —— 虚继承 vs 普通菱形:X 子对象是否重复(本机实测)
#include <cstdio>

struct A { int a; };
struct B : virtual A { int b; };   // 虚继承:B、C 共享 A
struct C : virtual A { int c; };
struct D : B, C { int d; };

struct X { int x; };
struct Y : X { int y; };          // 对照:普通菱形,各自持有一份 X
struct Z : X { int z; };
struct W : Y, Z { int w; };

int main() {
    printf("== 虚继承 ==\n");
    printf("sizeof(A)=%zu sizeof(B)=%zu sizeof(C)=%zu sizeof(D)=%zu\n",
            sizeof(A), sizeof(B), sizeof(C), sizeof(D));
    D d;
    A* a1 = static_cast<A*>(static_cast<B*>(&d));
    A* a2 = static_cast<A*>(static_cast<C*>(&d));
    printf("经B路径A=%p 经C路径A=%p 判定:%s\n", (void*)a1, (void*)a2,
            a1 == a2 ? "同一份A(共享)" : "两份A(重复)");

    printf("== 普通菱形 ==\n");
    printf("sizeof(X)=%zu sizeof(Y)=%zu sizeof(Z)=%zu sizeof(W)=%zu\n",
            sizeof(X), sizeof(Y), sizeof(Z), sizeof(W));
    W w;
    X* x1 = static_cast<X*>(static_cast<Y*>(&w));
    X* x2 = static_cast<X*>(static_cast<Z*>(&w));
    printf("经Y路径X=%p 经Z路径X=%p 判定:%s\n", (void*)x1, (void*)x2,
            x1 == x2 ? "同一份X" : "两份X(菱形重复!)");
}
# 真实输出(Apple clang 21 / arm64)
== 虚继承 ==
sizeof(A)=4 sizeof(B)=16 sizeof(C)=16 sizeof(D)=40
经B路径A=0x16ee3e0c8 经C路径A=0x16ee3e0c8 判定:同一份A(共享)
== 普通菱形 ==
sizeof(X)=4 sizeof(Y)=8 sizeof(Z)=8 sizeof(W)=20
经Y路径X=0x16ee3e094 经Z路径X=0x16ee3e09c 判定:两份X(菱形重复!)

证据确凿:虚继承下,从 B 路径和从 C 路径拿到的 A 子对象是同一个地址——整个 D 里只有一份 A,大家共享;普通菱形下两个地址差了 8 字节,X 被复制成两份。代价也写在账上:虚继承的 D 有 40 字节(vbptrB + b + vbptrC + c + d + 共享 A),而普通菱形 W 只有 20 字节——A 很小(4 字节)时,省下的一份 A 却要多付两个 vbptr(16 字节)。所以虚继承买的是"语义正确",不是"省内存"。B 的布局:vbptr(0~8) + b(8~12) + 共享 A(12~16),共 16 字节。

继承方式sizeof祖先子对象份数多出的指针访问祖先成员
普通菱形 W : Y, Z : X202(重复)直接偏移访问,但有二义性
虚继承 D : B, C : virtual A401(共享)vbptr × 2查 vbtable 间接定位
狄仁杰提示

虚继承的判案手法很直接:地址相等 = 共享,地址不等 = 重复。菱形继承里你到底踩没踩"两个爷爷"的坑,把两条路径的祖先子对象地址打出来比一比,一眼定罪。vbtable 就是一张"共享基类位置表",运行时告诉程序那份唯一的虚基类住在当前对象的哪个偏移——这也是虚继承访问成员比普通继承慢一拍的原因。

注意:虚继承不是免费的:① 每个带虚基类的子对象多一个 vbptr(8 字节);② 访问虚基类成员要间接寻址,性能打折;③ 构造函数责任链变复杂(最派生类负责构造虚基类);④ 布局不再"基类在前",指针转换要查表。所以能不用就不用——业界普遍把它当最后手段,优先用组合(把共同部分做成成员对象)避开菱形。

13.5 多态的内存代价小结

把前三节拆开的账汇总,多态的代价一共三笔:第一笔,空间代价——vptr。每个多态对象多 8 字节(虚继承再加 vbptr);vtable/vbtable 虽然类级共享,对象大小却是实打实涨上去的。第二笔,性能代价——间接调用。13.2 的汇编看得很清楚:非虚调用一条 bl 直接跳转;虚调用要"取 vptr → 查槽位 → 间接跳"三条指令,还因此无法内联(编译器不知道运行时到底调哪个函数),热点循环里的虚调用尤其肉疼。第三笔,也是最阴险的——按位复制陷阱:对象里藏着 vptr,用 memcpy 把对象当"一坨字节"拷贝,vptr 也被复制过去;更致命的是指针成员被复制成同一份,双双释放 = 双重释放崩溃。上机演一遍:

下面程序先通过 Base* 完成一次多态调用,再把 Derived 对象用 memcpy 按位拷给另一个 Derived——编译器当场发出警告(-Wdynamic-class-memaccess,提示"vptr 将被覆盖"),执意运行则结局在意料之中:

// polycopy.cpp —— memcpy 按位复制多态对象的代价(本机实测)
#include <cstdio>
#include <cstring>

struct Base {
    virtual ~Base() {}
    virtual const char* who() const { return "Base"; }
};
struct Derived : Base {
    int* big;
    const char* who() const override { return "Derived"; }
};

int main() {
    Derived d1; d1.big = new int[1000];
    Base* p = &d1;
    fprintf(stderr, "虚调用输出: %s\n", p->who());   // Derived
    Derived d2;
    memcpy(&d2, &d1, sizeof(Derived));  // 连 vptr + big 一起按位拷
    fprintf(stderr, "d1.big=%p d2.big=%p(同一块!)\n",
            (void*)d1.big, (void*)d2.big);
    delete[] d1.big;
    fprintf(stderr, "继续释放 d2.big...\n");
    delete[] d2.big;   // 同一块内存释放两次 → 崩溃
}
# 编译警告(真实,GCC/Clang 都报):
g++ polycopy.cpp -o polycopy
# warning: destination for this 'memcpy' call is a pointer to dynamic class 'Derived';
#   vtable pointer will be overwritten [-Wdynamic-class-memaccess]
# 运行(真实输出):
虚调用输出: Derived
d1.big=0x1050e5f90 d2.big=0x1050e5f90(同一块!)
继续释放 d2.big...
Trace/BPT trap: 5   # 双重释放,进程当场死亡(exit 133)

同类陷阱还有切片(slicing,说人话:把派生类对象按值赋给基类对象,派生部分被"切掉"):Derived d; Base b = d; 只拷贝基类子对象,b 的 vptr 指向 Base 的虚表,所以 b.who() 输出 Base 而非 Derived(本机实测)。拷贝多态对象的正解是拷贝构造/赋值、或虚 clone() 函数,容器里存指针或智能指针而不是裸对象。

代价具体表现规避手段
vptr 空间每个多态对象 +8 字节;虚继承再加 vbptr只在需要多态处用虚函数;对象池化
间接调用虚调用 3 条指令且无法内联;vbtable 查表更慢热点代码用 final 类让编译器去虚拟化(devirtualize)
按位复制memcpy 连 vptr 一起拷 → 双重释放崩溃;值传递 → 切片拷贝构造/虚 clone;容器存指针;禁用拷贝(=delete)
狄仁杰提示

三笔账一句话:多态 = 拿 8 字节和一次间接寻址,换"一份代码对多种类型生效"。划不划算看对象数量:几百万个对象的集合,每个多 8 字节就是几十 MB;热点循环里的虚调用可能就是性能瓶颈。狄某的建议:先量,再算,别拍脑袋

注意memcpy 拷多态对象是教科书级事故现场,但编译器其实早就提醒过你——-Wdynamic-class-memaccess 就是在喊"vptr 要被覆盖了"。养成习惯:把编译器警告当线索,别当噪音。任何"把对象当字节数组处理"的操作(序列化、写文件、套网络协议)都要先问:这个类有多态吗?有,就别直接按位操作。

13.6 章节小结

本章的账目收拢成一条主线:对象的字节排列 = 基类子对象序列 + 自己的成员 + 必要时附加的指针(vptr / vbptr)。单继承最简单——基类子对象在偏移 0,自己接尾巴;多继承是总账的重复应用——N 个基类 N 段子对象,转换指针要挪地址;虚继承把重复的祖先收敛成一份,代价是多付 vbptr 和一次间接寻址。所有规则都服从第 10 章的对齐、第 12 章的 vptr 起步价。

把所有实测数字收进一张总表——这就是本章的"结案陈词"(Apple clang 21 / arm64 实测):

继承形态示例sizeof布局要点代价
单继承(无虚函数)Son : Base{2×int} + int12基类子对象偏移 0,成员接后
单继承(有虚函数)Son : Base{virtual} + int24vptr 在开头,a/b/c 偏移 8/12/16+8 字节 vptr
多继承(两个虚基类)C : A, B + int32A 子对象 0~15、B 子对象 16~27、c 28~31两个 vptr;B* 转换 +16
虚继承菱形D : B, C : virtual A + int40一份共享 A;B@0、C@16、d@28、A@32两个 vbptr;间接寻址
普通菱形(对照)W : Y, Z : X + int20两份 X 子对象,地址不同状态重复、二义性

最后留一张"断案口诀"和一道开卷自测:下面的 A/B/C 依次继承,先动笔预测三个 sizeof,再上机验证——预测对了,说明总账规则你已吃透:

// 断案口诀(狄仁杰版)
单继承:基类排最前,自己接尾巴
多继承:基类排成队,转换要挪址
虚继承:祖先只一份,vbptr 去认领
算大小:成员排好队,对齐再加 vptr
// selfcheck.cpp —— 开卷自测:先预测,再上机验证
#include <cstdio>
struct A { int a; virtual void f() {} };
struct B : A { int b; };
struct C : B { int c; };
int main() {
    printf("sizeof(A)=%zu sizeof(B)=%zu sizeof(C)=%zu\n",
            sizeof(A), sizeof(B), sizeof(C));
}

本机实测答案:16 / 16 / 24——A = vptr(8) + a(4) + 填充 = 16;B 复用基类的 vptr,vptr + a + b = 16;C = 20 对齐到 8 = 24。

狄仁杰提示

系统分析的方法论收个尾:先建模型(布局规则),再用证据(sizeof/地址)校验,最后按证据修正认知。本章的数字不是背下来的,是量出来的——换 32 位机器、换 MSVC,数字会变(vptr 从 8 字节变 4 字节),但"基类子对象 + 成员 + vptr"这条总账永远成立。把方法带走,比把数字带走值钱。

注意:本章所有 sizeof 均为 macOS / Apple clang 21 / arm64 64 位 实测,遵循 Itanium C++ ABI 的布局规则。换平台必须重测:32 位平台上指针只有 4 字节,MSVC 的虚继承布局是另一套体系。不声明平台的 sizeof 讨论,都是耍流氓。

章末练习

练习 1:概念配对 入门

把左边的概念与右边的描述配对:① 基类子对象;② vptr;③ vtable;④ vbptr。候选描述:A. 对象开头指向虚函数表的指针;B. 基类在派生类对象里占据的那段成员序列;C. 指向虚基类表的指针,用来定位共享的虚基类;D. 每个多态类一张、全类对象共享的函数指针表。

提示

回看 13.1 的总账规则、13.2 的 vptr/vtable 分工、13.4 的虚继承实现。

参考答案

① → B(按值嵌入的成员序列);② → A(对象里的指针);③ → D(类级共享的表);④ → C(服务于虚继承)。记住:对象里放指针,表是类级共享的

练习 2:预测 sizeof 进阶

本章小结的自测代码:A{int a; virtual f()}B : A{int b}C : B{int c}(64 位平台)。先预测三个 sizeof 并写出 C 的成员偏移,再上机验证。

提示

vptr 8 字节起步;B 不新造 vptr;最后结果对齐到 8 的整数倍。

参考答案

A = vptr(8) + a(4) = 12,对齐到 8 = 16;B 复用基类的 vptr:vptr(0~8) + a(8~12) + b(12~16) = 16;C = vptr(0~8) + a(8~12) + b(12~16) + c(16~20) = 20,对齐到 8 = 24。实测 16 / 16 / 24。要点:vptr 只有一个(在开头),逐层加成员,最后整体对齐

练习 3:多继承的指针调整 进阶

13.3 节里 (B*)&c(void*)&c 大了 16 字节,而 (A*)&c&c 相等。为什么 B 子对象不在开头?这个调整是谁做的、发生在什么时候?如果把 C : A, B 改成 C : B, A(A*)&c 的偏移会变成多少?

提示

基类子对象按声明顺序排列;第一个基类在偏移 0,后面的顺延。调整由编译器在转换时自动完成。

参考答案

B 子对象排在 A 之后(声明顺序 A 在前),所以 B 住在偏移 16。调整是编译器在 static_cast 或隐式转换时自动加的(编译期算好偏移,转换时加一条指令),无需运行时查表。若改成 C : B, A,B 回到偏移 0,A 被推后,(A*)&c 会偏移 16 字节。

练习 4:亲手验证菱形继承 挑战

写一段程序同时定义"普通菱形"和"虚继承菱形"(参考 13.4 的 inherit4.cpp),打印:① 两类 D 对象的 sizeof;② 从两条路径取到的祖先子对象地址是否相等;③ 解释虚继承为什么能消除重复。

提示

地址相等 = 共享。虚继承下祖先只有一份;vbtable 记录的是"虚基类相对当前子对象的偏移"。

参考答案

普通菱形(本机实测):X=4、Y=8、Z=8、W=20,两条路径的 X 地址不同(相差 8 字节)——两份 X,访问 w.x 编译报二义性错误。虚继承:A=4、B=16、C=16、D=40,两条路径地址完全相同——A 只构造一次、只存一份。原理:虚继承把 A 从"各分支内嵌"改为"对象末尾共享一份 + 各分支用 vbptr 指向 vbtable(表里记着 A 的偏移)",于是无论从 B 还是 C 找 A,都定位到同一个地址。