构造函数的执行顺序

继承与虚函数

执行结果:

详细执行过程

对象创建过程

A *pa = new C();

继承链:ABC

构造顺序(从基类到派生类):

步骤1:A的构造函数执行

步骤2:B的构造函数执行

关键点:在B的构造函数中调用 f()

步骤3:C的构造函数执行

函数调用过程

pa->g();

对象销毁过程

delete pa;

析构顺序(从派生类到基类):

内存布局分析

对象内存布局

虚函数表(vtable)

A的虚函数表

B的虚函数表

C的虚函数表

构造过程中的虚函数表变化

构造过程中vptr的动态变化

在对象的构造过程中,vptr(虚函数表指针)会指向不同的虚函数表。这是C++虚函数机制的一个重要特性。

vptr 变化的详细过程

构造函数执行顺序

当创建 C 对象时,构造函数的执行顺序是:A构造函数B构造函数C构造函数

阶段1:A构造函数执行时

阶段2:B构造函数执行时

阶段3:C构造函数执行完成后

为什么要这样设计?

1. 安全性考虑

如果在Base构造函数执行时就调用Derived的init(),会访问未初始化的成员,导致未定义行为。

2. 逐步构建对象的"身份"

实际验证示例

输出结果:

Important

  1. vptr在构造过程中会动态更新:每个构造函数执行时,vptr都会指向当前类的虚函数表

  2. 构造函数中的虚函数调用是"安全的":只会调用当前已构造完成的类层次中的函数

  3. 对象的"类型身份"逐步建立:从基类逐步"进化"到最终的派生类类型

  4. 这种设计避免了未定义行为:防止调用使用未初始化成员的派生类函数

  5. 析构过程相反:vptr会从派生类逐步"退化"回基类

这种机制确保了C++对象在构造和析构过程中的类型安全性,是C++虚函数机制设计的精妙之处。

虚函数表的继承机制

虚函数表会继承,每个包含虚函数的类都有自己的虚函数表,派生类的虚函数表是基于基类虚函数表构建的。

虚函数表继承的基本继承规则:

  1. 继承基类的虚函数表结构:派生类复制基类的虚函数表作为起点

  2. 重写函数替换:如果派生类重写了虚函数,用新函数地址替换表中对应位置

  3. 新增虚函数追加:派生类新增的虚函数追加到表的末尾

  4. 保持函数顺序:虚函数在表中的位置(偏移量)保持一致

虚函数表分析

基于之前的代码示例:

A类的虚函数表 (A_vtable)

说明

B类的虚函数表 (B_vtable)

说明

C类的虚函数表 (C_vtable)

说明

虚函数表继承的过程

步骤1:A类虚函数表创建

步骤2:B类虚函数表继承

步骤3:C类虚函数表继承

更复杂的继承示例

虚函数表结构对比

编译器在以下情况下自动生成析构函数:

内存布局验证

Important

  1. 虚函数表会继承:派生类基于基类的虚函数表结构构建自己的表

  2. 偏移量保持一致:相同的虚函数在所有类的虚函数表中都有相同的偏移量

  3. 重写替换,新增追加

    • 重写的函数替换表中对应位置

    • 新增的虚函数追加到表末尾

  4. 每个类都有独立的虚函数表:即使内容相同,每个类也有自己的虚函数表实例

  5. 编译时确定结构:虚函数表的结构在编译时就确定了

  6. 运行时动态绑定:通过vptr和偏移量实现运行时的函数调用

析构函数执行顺序

在多层继承的C++对象析构过程中,析构函数的执行流程与构造函数正好相反,遵循派生类到基类的顺序。

在我们的例子中(A ← B ← C),当删除一个C类型的对象时,析构函数的调用顺序是:

vptr在析构过程中的变化

析构过程中,vptr会发生以下变化:

Important

  1. 析构顺序:总是从最派生类开始,逐级向上到基类

  2. vptr更新时机:每个析构函数执行完毕后,vptr才会更新到上一级基类的vtable

  3. 虚函数调用:在析构函数中调用虚函数时,会调用当前正在析构的类的版本,而不是最派生类的版本

  4. 安全性考虑:这种设计确保在析构过程中不会调用已经被析构的派生类成员

  5. 内存布局变化

完整的析构流程

Important

虽然通过虚析构函数机制确实会首先调用C的析构函数,但析构过程并不会在C的析构函数执行完毕后就结束

当执行 delete pa 时,完整的析构流程是:

  1. 通过虚析构函数机制找到C的析构函数:由于A的析构函数是虚函数,通过vptr找到实际对象类型C的析构函数

  2. 执行C的析构函数:输出 "C's des."

  3. 自动调用B的析构函数:C的析构函数执行完毕后,编译器自动调用基类B的析构函数,输出 "B's des."

  4. 自动调用A的析构函数:B的析构函数执行完毕后,编译器自动调用基类A的析构函数,输出 "A's des."

为什么会有析构链?

这是C++继承机制的基本规则:

每个类的析构函数只负责清理自己的资源,基类的资源必须由基类的析构函数来清理。

Tip

  1. 虚析构函数的作用:确保从基类指针删除派生类对象时,能正确调用派生类的析构函数

  2. 析构链的必要性:每个类都可能有自己的资源需要清理,所以必须调用整个继承链上的所有析构函数

  3. 自动调用机制:一旦开始析构过程,编译器会自动确保调用完整的析构链,这不是可选的

如果没有虚析构函数,delete pa 只会调用A的析构函数,导致B和C的资源无法正确清理,这就是为什么基类需要虚析构函数。

析构过程中vptr的变化

析构过程

1. 初始状态

2. 开始析构(delete pa)

关键机制

1. vptr的自动更新

2. 编译器如何找到基类析构函数

3. 对象的"部分存在"状态

实际的内存和调用过程

输出结果:

Important

  1. 编译器如何自动调用基类析构函数?

    • 编译器在编译时就知道继承关系,会在每个析构函数的末尾自动插入调用基类析构函数的代码

  2. vptr属于哪个类?

    • vptr始终属于当前"活着"的对象部分。C析构后,对象变成B类型,vptr指向B的vtable

  3. C对象是否不存在了?

    • C的特有部分被析构了,但对象的A和B部分仍然存在

    • 对象从C类型"退化"为B类型,然后再"退化"为A类型

  4. 后续调用不通过虚函数机制

    • 只有第一次调用(C的析构)通过虚函数机制

    • 后续的B和A析构函数调用是编译器直接插入的静态调用


这引出了一个新的问题,析构函数都在虚函数表里,但编译器在析构过程中却采用了特殊的调用机制而并没有使用虚函数表。

为什么后续调用不通过虚函数机制?

虽然析构函数在vtable中,但编译器在生成析构代码时采用了静态调用而不是虚函数调用:

对比两种调用方式

为什么要这样设计?

原因1:避免无限递归

原因2:确保正确的析构顺序

原因3:性能考虑

完整的析构机制

Note

  1. 只有第一次调用(从基类指针删除对象)使用虚函数机制

  2. 后续的基类析构调用都是编译器插入的静态调用

  3. vptr的更新发生在每个析构函数的末尾

  4. 这种设计避免了递归调用,确保了正确的析构顺序

所以虽然析构函数在vtable中,但编译器巧妙地只在需要多态性的地方(第一次调用)使用虚函数机制,后续调用采用更安全、更高效的静态调用方式。