这一篇解决什么问题
前两篇搞清楚了单个对象的布局和 this 指针。这一篇加一层继承,回答三个问题:
- 派生类对象里,基类的部分放在哪?
- 构造和析构按什么顺序发生?
- 用基类指针指向派生对象时地址变不变,把派生对象赋值给基类对象又会怎样。
单继承的三条规则
规则 1:基类子对象放在偏移 0,派生成员追加在后。
派生类对象的开头就是一个完整的基类子对象,派生类自己的成员排在后面(同样遵守第一篇的对齐规则)。
规则 2:构造先基后派生,析构严格反向。
构造 Derived 时,先跑 Base 的构造函数(此时对象还只是 Base),再跑 Derived 的;析构倒过来,先 Derived 后 Base。链条再长也是这个顺序,一层包一层。
规则 3:单继承下基类指针不需要调整地址,但按值拷贝会切片。
因为基类子对象在偏移 0,Base* pb = &derived 拿到的指针值和 &derived 完全相同(多继承就没这么简单了,后面会看到)。而 Base copy = derived 这种按值拷贝,只复制基类子对象那部分,派生成员被”切掉”——这就是切片(slicing)。
验证工程
1 | // 单继承: 基类子对象位置, 基类指针指向, 切片, 构造析构链. |
预测输出
动手跑之前,我先按三条规则把预期结果推一遍:
sizeof(Base)=4(一个 int),sizeof(Derived)=8(基类 4 + 派生 4,无 padding)。- 构造输出 Base 在前 Derived 在后,且两个 this 是同一个地址——基类子对象就在偏移 0,不是另一个对象。
&obj.b == &obj(基类在开头,b 又是基类的第一个成员),&obj.d = &obj + 4。pb == &obj,单继承无地址调整。sizeof(copy)=4:copy 是纯 Base,d 被切掉,切得连存在过的痕迹都没有。- 析构顺序严格反转:先 Derived 后 Base。
实测输出
VS2022 x64 Debug 下实际运行(断点打在 main 结束的右括号上,这样析构输出也看得到):
1 | sizeof(Base)=4 sizeof(Derived)=8 |
逐行比对
sizeof(Base)=4、sizeof(Derived)=8:命中。- 两个构造函数的 this 都是
...F868:命中,基类子对象就在偏移 0,和派生对象是同一个地址。 &obj.b与&obj相同,&obj.d比&obj大 4:命中。pb == &obj:命中。sizeof(copy)=4、copy.b=1:命中,d 被切掉。
copy 的构造在哪,析构为什么是三行
第一版代码里 Base 只有默认构造和析构带打印,跑出来的析构却是三行:多出来那行属于 copy,但当时看不到它的构造。原因是 Base copy = obj; 走的是拷贝构造函数,这个函数没写,编译器自动合成了一个(逐成员拷贝 b 的值),合成版里没有打印,静默执行。
给 Base 补上带打印的拷贝构造后再跑,--- slicing --- 之后就能看到它了:
1 | Base::Base(const Base&) this=000000DA3D55F8A4 ← copy 的构造 |
构造和析构的地址都是 F8A4,copy 的生命周期完整对上。而它的地址和 obj 的 F868 完全不同——切片产生的是一个独立的纯 Base 对象,不是原对象的一部分。另外注意析构顺序与声明顺序相反:copy 声明在 obj 之后,所以先析构。
配合 /d1 看布局
工程预置了 /d1 reportAllClassLayout,编译输出里直接打印了两个类的布局报告(去掉了开头的 1> 编译器前缀):
1 | class Base size(4): |
读法很直观:左边一列是偏移量。Base 的 b 在偏移 0;Derived 里偏移 0 处先是一个完整的 Base 子对象(b 也在 0),d 排在偏移 4。这和运行输出里 &obj == &obj.b、&obj.d = &obj + 4 完全对上——编译器的自证和运行时的实测互相印证。
小结
1 | 单继承布局: [基类子对象][派生成员追加] |
切片值得单独警惕:它是按值语义的天然结果,不报错不警告。所以涉及继承体系时,传参和返回一律用指针或引用,按值传递基类几乎总是 bug。