0%

C++ 对象模型实战(三):单继承——基类子对象位置、构造链与切片

这一篇解决什么问题

前两篇搞清楚了单个对象的布局和 this 指针。这一篇加一层继承,回答三个问题:

  1. 派生类对象里,基类的部分放在哪?
  2. 构造和析构按什么顺序发生?
  3. 用基类指针指向派生对象时地址变不变,把派生对象赋值给基类对象又会怎样。

单继承的三条规则

规则 1:基类子对象放在偏移 0,派生成员追加在后。
派生类对象的开头就是一个完整的基类子对象,派生类自己的成员排在后面(同样遵守第一篇的对齐规则)。

规则 2:构造先基后派生,析构严格反向。
构造 Derived 时,先跑 Base 的构造函数(此时对象还只是 Base),再跑 Derived 的;析构倒过来,先 Derived 后 Base。链条再长也是这个顺序,一层包一层。

规则 3:单继承下基类指针不需要调整地址,但按值拷贝会切片。
因为基类子对象在偏移 0,Base* pb = &derived 拿到的指针值和 &derived 完全相同(多继承就没这么简单了,后面会看到)。而 Base copy = derived 这种按值拷贝,只复制基类子对象那部分,派生成员被”切掉”——这就是切片(slicing)。

验证工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 单继承: 基类子对象位置, 基类指针指向, 切片, 构造析构链.
#include <cstdio>

class Base {
public:
Base() { printf(" Base::Base() this=%p\n", (void*)this); }
Base(const Base&) { printf(" Base::Base(const Base&) this=%p\n", (void*)this); } // 切片时走这里.
~Base() { printf(" Base::~Base() this=%p\n", (void*)this); }
int b = 1;
};

class Derived : public Base {
public:
Derived() { printf(" Derived::Derived() this=%p\n", (void*)this); }
~Derived() { printf(" Derived::~Derived() this=%p\n", (void*)this); }
int d = 2;
};

int main() {
printf("sizeof(Base)=%zu sizeof(Derived)=%zu\n", sizeof(Base), sizeof(Derived));

printf("--- layout ---\n");
Derived obj;
printf("&obj=%p &obj.b=%p &obj.d=%p\n", (void*)&obj, (void*)&obj.b, (void*)&obj.d);

printf("--- base pointer ---\n");
Base* pb = &obj; // 单继承下基类在偏移 0, 指针值不需要调整.
printf("pb=%p &obj=%p\n", (void*)pb, (void*)&obj);

printf("--- slicing ---\n");
Base copy = obj; // 按值拷贝, 只有 Base 子对象被复制, d 被切掉.
printf("sizeof(copy)=%zu copy.b=%d\n", sizeof(copy), copy.b);

printf("--- scope end ---\n");

return 0;
}

预测输出

动手跑之前,我先按三条规则把预期结果推一遍:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
sizeof(Base)=4  sizeof(Derived)=8
--- layout ---
Base::Base() this=000000DA3D55F868
Derived::Derived() this=000000DA3D55F868
&obj=000000DA3D55F868 &obj.b=000000DA3D55F868 &obj.d=000000DA3D55F86C
--- base pointer ---
pb=000000DA3D55F868 &obj=000000DA3D55F868
--- slicing ---
Base::Base(const Base&) this=000000DA3D55F8A4
sizeof(copy)=4 copy.b=1
--- scope end ---
Base::~Base() this=000000DA3D55F8A4
Derived::~Derived() this=000000DA3D55F868
Base::~Base() this=000000DA3D55F868

逐行比对

  • 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
2
3
4
5
Base::Base(const Base&) this=000000DA3D55F8A4   ← copy 的构造
...
Base::~Base() this=000000DA3D55F8A4 ← copy 的析构, 地址一致
Derived::~Derived() this=000000DA3D55F868 ← obj 的 Derived 部分
Base::~Base() this=000000DA3D55F868 ← obj 的 Base 子对象

构造和析构的地址都是 F8A4,copy 的生命周期完整对上。而它的地址和 obj 的 F868 完全不同——切片产生的是一个独立的纯 Base 对象,不是原对象的一部分。另外注意析构顺序与声明顺序相反:copy 声明在 obj 之后,所以先析构。

配合 /d1 看布局

工程预置了 /d1 reportAllClassLayout,编译输出里直接打印了两个类的布局报告(去掉了开头的 1> 编译器前缀):

1
2
3
4
5
6
7
8
9
10
11
12
class Base    size(4):
+---
0 | b
+---

class Derived size(8):
+---
0 | +--- (base class Base)
0 | | b
| +---
4 | d
+---

读法很直观:左边一列是偏移量。Base 的 b 在偏移 0;Derived 里偏移 0 处先是一个完整的 Base 子对象(b 也在 0),d 排在偏移 4。这和运行输出里 &obj == &obj.b、&obj.d = &obj + 4 完全对上——编译器的自证和运行时的实测互相印证。

小结

1
2
3
4
单继承布局: [基类子对象][派生成员追加]
构造顺序: Base → Derived, 析构严格反向
Base* pb = &derived: 地址不变(基类在偏移 0)
Base copy = derived: 切片, 派生部分丢失

切片值得单独警惕:它是按值语义的天然结果,不报错不警告。所以涉及继承体系时,传参和返回一律用指针或引用,按值传递基类几乎总是 bug。