文章

DefaultConstructor的构造操作

默认构造函数负责初始化基类、成员、虚基类和 vptr,确保对象构造阶段多态、继承和成员有效性,顺序严格按声明顺序执行。

DefaultConstructor的构造操作

Default Constructor 的构造操作

默认构造函数(default constructor):可以不带任何实参被调用的构造函数。

  • 典型形式:T();
  • 或者所有参数都有默认值:T(int = 0); 也能当默认构造用。

默认构造函数分为 显式定义(用户自己写的无参构造)和 隐式定义(编译器自动生成的构造函数,用于初始化基类和成员对象)。

编译器何时合成默认构造函数

类没有任何用户声明的构造函数 时,编译器会 隐式声明 默认构造函数,并在需要时 隐式定义它。编译器必须生成默认构造函数的情形主要有四种,目的是保证对象在构造时能正确初始化:

基类有默认构造函数

派生类对象中包含基类子对象。在构造派生类对象时,必须先构造其基类部分。如果基类有默认构造函数,编译器会在派生类默认构造函数体开始之前,自动插入对基类默认构造函数的调用,以保证基类子对象在派生类构造函数体执行前就绪。

成员对象有默认构造函数

类中的成员对象必须先被构造,以保证其有效性。即使用户自己提供了默认构造函数,编译器仍会在构造函数体开始前,自动调用成员对象的默认构造函数,确保成员对象在构造函数体中使用时已经完成初始化。

虚拟继承(Virtual Inheritance)

在多重继承中,如果一个基类被 虚继承,整个对象只会有一个该虚基类子对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 普通继承示例
struct A {};
struct B : A {};
struct C : A {};
struct D : B, C {};

D d;  
// D 对象中有:
//   B 子对象 → 含一个 A
//   C 子对象 → 含一个 A
// 结果:D 对象里有两个 A 子对象(重复基类子对象)

// 虚继承示例
struct A {};
struct B : virtual A {};
struct C : virtual A {};
struct D : B, C {};

D d;  
// B 和 C 虚继承 A,D 是最派生类
// 结果:D 对象里只有一个 A 子对象,由最派生类 D 来构造和初始化
  • 虚继承的核心目的就是避免重复基类子对象,保证多条继承链共享同一个基类子对象,从而解决菱形继承问题(diamond problem)。

最派生类(most derived class)负责初始化虚基类子对象。编译器生成的默认构造函数会在构造最派生类对象时,插入虚基类的初始化代码,以保证虚继承结构正确,从而避免虚基类子对象被重复或遗漏构造。

构造顺序示意

阶段构造对象说明
1虚基类 A最派生类初始化虚基类,保证共享唯一对象
2直接基类 BB 构造完成,虚基类已构造
3直接基类 CC 构造完成,虚基类已构造
4最派生类 D自身对象构造完成

类含虚函数(vptr / vtable 设置)

每个包含虚函数的对象都拥有一个 vptr(虚表指针),指向对象所属类的虚表,以保证虚函数调用的动态分派正确。即使用户提供了空的构造函数,编译器仍会生成默认构造函数来完成 vptr 的设置,确保构造阶段的虚函数调用行为正确。

vptr 的角色:

  • 构造函数中调用虚函数 时,编译器会通过对象的 vptr 来决定调用哪个实现。
  • 在基类构造函数执行时,vptr 暂时指向 基类的 vtable
  • 在派生类构造函数开始执行时,vptr 更新指向 派生类的 vtable

只要对象在构造期间 有必须执行的操作(初始化基类/成员对象、虚基类、设置 vptr),编译器就不能靠“简单的内存位操作”跳过,必须生成真正可执行的默认构造函数。

合成的默认构造函数做了什么(调用次序)

合成出来的默认构造函数会按固定顺序为“子对象”收尾:

  1. 虚基类:由最派生类负责,按声明顺序构造;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    struct A1 { A1() { std::cout << "A1\n"; } };
    struct A2 { A2() { std::cout << "A2\n"; } };
       
    struct B : virtual A1 {};
    struct C : virtual A2 {};
       
    struct D : B, C {};  // D 最派生类,声明顺序:B, C
       
    int main() {
        D d;
    }
    // 虚基类构造顺序:先 A1,再 A2,因为 D : B, C 中 B 在前,C 在后
    
  2. 直接基类:按它们在类头中声明的顺序构造(与成员无关);

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    struct Base1 { Base1() { std::cout << "Base1\n"; } };
    struct Base2 { Base2() { std::cout << "Base2\n"; } };
       
    struct Derived : Base2, Base1 {  // 声明顺序:Base2, Base1
        Derived() { std::cout << "Derived\n"; }
    };
       
    int main() {
        Derived d;
    }
    // 构造顺序:先 Base2,再 Base1,再 Derived
    
  3. 非静态数据成员:按它们在类内声明的顺序构造(与初始化列顺序无关);

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    struct A {
        A() { std::cout << "A\n"; }
    };
       
    struct B {
        B() { std::cout << "B\n"; }
    };
       
    struct MyClass {
        B b;
        A a;       // 成员在类里声明顺序:b, a
        MyClass() : a(), b() {  // 初始化列表顺序反了
            std::cout << "MyClass\n";
        }
    };
       
    int main() {
        MyClass obj;
    }
    // 即使在初始化列表里先写了 a(),编译器仍然先构造 b,再构造 a,因为顺序严格按成员在类里的声明顺序。
    
  4. 自身的构造函数体:执行函数体(若为合成,通常为空体,但已做完上面工作和必要的运行时布置,比如 vptr)。

    • 在合成或自定义的默认构造函数里,构造函数体指的是 { ... } 里面的代码。
    • 当对象构造时,先构造所有虚基类、直接基类和成员对象(按照前面讲的顺序),这些子对象就绪之后,才会执行构造函数体内的代码。
    • 如果是 编译器合成的默认构造函数,函数体通常为空 {},因为没有用户代码需要执行,但编译器仍然会完成必要的运行时布置,比如:
      • 设置对象的 vptr(虚函数表指针),保证虚函数调用正确;
      • 初始化虚基类子对象(如果有);
      • 其它必要的对象布局操作。

顺序只与“声明顺序”相关,与在成员初始化列表中的书写顺序无关。

trivial vs non-trivial

  • trivial 默认构造函数:什么都不做(不调用任何用户代码),对象内存不被清零,内置成员保持未定义值(自动存储期)。
    • 只有当类没有基类/成员需要构造、没有虚函数/虚继承、没有用户自定义构造等,编译器才可把默认构造“平凡化”(trivial)。
  • non-trivial 默认构造函数:需要真正执行代码来完成上节的构造步骤(调用基类/成员的默认构造、设置 vptr、处理虚继承)。

这正对应了书里“bitwise 初始化 vs 调用构造过程”的区分:

  • trivial → 编译器可用“按位策略”对付(历史书写里常说 bitwise init/copy)。
  • non-trivial → 必须进构造流程,不能 memset/memcpy 替代。
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
// 例 1:完全平凡
struct P { int x; double y; };
// 无任何构造/继承/虚函数 → 编译器合成 P::P(),且是 trivial(几乎啥也不干)

// 例 2:含虚函数
struct V { virtual void f() {} };
// 必须合成 V::V() 来设置 vptr → non-trivial

// 例 3:成员需要构造
struct M { M() {} };  // 用户定义默认构造
struct H {
    M m;              // 没写任何构造
};
// H 的默认构造必须调用 m.M() → 合成且 non-trivial

// 例 4:派生 & 虚继承
struct VB {}; 
struct D : virtual VB { /* 没写构造 */ };
// D 的默认构造必须处理虚基 → 合成且 non-trivial

// 例 5:一旦你自己声明了别的构造函数(哪怕带参)
struct X {
    X(int) {}   // 有用户构造
};
// 编译器不再“自动合成”默认构造;X x; 将报错(除非显式 X() = default;)

默认初始化 vs 值初始化 vs 零初始化

默认初始化(Default Initialization)

默认初始化发生在 声明一个对象而不带任何初始化器 时,例如:

1
T t;  // 默认初始化
对象类型行为
类类型(有默认构造函数或编译器合成的)调用默认构造函数(user-defined 或 compiler-generated)
内置类型(int, double, pointer 等)不初始化(自动存储期对象是未定义值,堆栈上可能是垃圾值)
静态存储期的对象自动进行零初始化(静态区全 0)后再调用默认构造(如果是类类型)
属性自动存储期静态存储期
声明位置函数或块内局部全局、static 局部、类静态成员
生命周期进入作用域开始 → 离开作用域结束程序开始 → 程序结束
内置类型初始化默认未初始化(垃圾值)自动零初始化
分配位置静态/数据区
1
2
3
4
5
6
7
8
struct A { int x; A() { x = 42; } };
struct B { int y; };  // POD

int main() {
    A a;  // 调用 A(),a.x == 42
    B b;  // 未初始化,b.y 是垃圾值
    int n;  // 未初始化,n 是垃圾值
}

值初始化(Value Initialization)

值初始化通常出现在 使用 {}() 形式初始化对象时

1
2
T t{};  // 推荐 C++11 及以后
T t();  // 注意!这是函数声明,非对象初始化
对象类型行为
类类型(有默认构造函数)调用默认构造函数
类类型(POD 或无默认构造)零初始化,然后按需要调用构造函数
内置类型零初始化(初始化为 0)
1
2
3
4
5
6
7
8
struct A { int x; };
struct B { int y; B() {} };

int main() {
    A a{};  // POD,先零初始化,a.x == 0
    B b{};  // B() 被调用,如果 B() 不初始化 y,则 y 是垃圾值
    int n{};  // n == 0
}

注意:T t(); 在 C++ 中是 函数声明(最著名的 Most Vexing Parse),不会创建对象。

零初始化(Zero Initialization)

零初始化是 把对象的所有内置类型成员和指针清零 的操作,通常是值初始化的一部分,也会自动发生在静态/全局对象中。

  • 内置类型:设置为 0(整型)、0.0(浮点型)、nullptr(指针)
  • 类类型:先对 POD 成员零初始化,然后调用默认构造函数(如果有)
1
2
3
4
5
6
struct A { int x; double y; };
static A a_static;  // 静态存储期,先零初始化:x=0, y=0.0

int main() {
    A a{};  // 值初始化,先零初始化 x=0, y=0.0
}

三者的执行顺序关系

T t{}; 为例,值初始化执行步骤大致如下:

  1. 零初始化:将对象内存全部清 0
  2. 调用默认构造函数(如果类类型有默认构造)
  3. 构造函数体执行(合成或用户定义)

T t;(默认初始化)则跳过第一步,直接调用默认构造函数(或对内置类型保持未定义值)。

const / 引用成员与默认构造

  • const 成员和引用成员 (T&):必须在构造时通过 构造函数初始化列表默认成员初始值 给出初值,不能在构造函数体内赋值替代。
  • C++11 及以后:如果默认构造函数被编译器合成,而类中存在 const 或引用成员 没有可用的初始化方式,该默认构造函数会被 定义为 deleted,对象无法默认构造。
  • C++98/03:遇到同样情况时,编译器通常 无法合成默认构造函数,导致 类对象不可构造,必须手动提供构造函数来初始化这些成员。

与 vptr / vtable 的关系

基本概念回顾

  • vtable(虚表):类级别的数据结构,存储虚函数的地址。
  • vptr(虚表指针):对象内的指针,指向当前对象所使用的虚表。
  • 每个有虚函数的对象都会包含一个 vptr(或多个 vptr,取决于多继承情况)。

构造对象时 vptr 的作用

在构造过程中,对象的动态类型随着构造阶段不断变化

  1. 构造虚基类
  2. 构造非虚直接基类
  3. 构造最派生类自身

在每个阶段,vptr 都必须指向“当前阶段对应的虚表”,这样:

  • 基类构造函数调用虚函数时,能找到 基类版本的函数
  • 派生类构造函数执行完后,vptr 指向最派生类的虚表

设置顺序

假设有类继承结构:

1
2
3
4
struct A { virtual void f(); };
struct B : virtual A { virtual void g(); };
struct C : B { virtual void h(); };
C c;

构造顺序与 vptr 设置:

阶段vptr 指向说明
构造虚基类 AA 的虚表保证 A 构造时调用虚函数 f() 调用 A 版本
构造直接基类 BB 的虚表保证 B 构造时调用虚函数 g() 调用 B 版本,f() 仍然指向 A
构造最派生类 CC 的虚表对象完成构造,所有虚函数指向最终版本 h()/g()/f()(根据覆盖)

每个阶段都会更新对象中 vptr,确保在构造函数体里调用虚函数时,调用的是该阶段应该可见的版本

多 vptr 情况

  • 如果存在多重继承或虚继承,一个对象可能包含 多个 vptr(每个子对象一份)。
  • 编译器会在构造阶段分别设置各个 vptr,对每个子对象的虚函数调用生效。

现代 C++(C++11+)的补充与对照

= default / = delete

  • 可以显式要求默认构造:T() = default;
    • 如果语义允许(所有子对象可默认构造),它可能是 trivial 的;否则 non-trivial
  • 如果不想暴露默认构造:T() = delete; 明确禁用它。

“被删除”的默认构造(implicitly deleted)

编译器虽然会隐式声明默认构造,但在这些情况下会把它判定为 deleted,导致不可用。例如:

  • 成员或基类没有可用的默认构造(且没有在默认成员初始值里给它们值);
  • 引用成员 / const 成员 但又没有在类内给默认初值,且也没有其它可行构造路径;
  • 某些 联合体继承布局 的限制(比如含有无法默认构造的子对象)。

这类规则在书的年代没有“=delete”的概念,现代标准把这些“不可默认构造”的情形形式化为 deleted,错误信息更直观。

noexceptconstexpr

  • T() = defaultnoexcept 性质是可推导的:如果所有基类/成员的默认构造都是 noexcept,它就是 noexcept
  • 满足常量初始化条件时,默认构造也可成为 constexpr(C++20 放宽许多限制)。

Trivial 的判定

一个默认构造是 trivial 的大致条件:

  • 没有用户自定义构造;
  • 没有虚函数、虚继承;
  • 基类与成员的默认构造都 trivial;
  • 类不是带有某些特殊注解/属性导致的非常规布局。

trivial 的意义:可被按位初始化/拷贝、安全地放在 memcpy 等优化路径上;对象创建极其便宜。

常见误区

  1. “没写构造就会自动清零”——错
    • 自动存储期的内置成员在“默认初始化”下是未定义值;要清零用 T t{}; 或自行初始化。
  2. 成员初始化顺序和列表顺序
    • 构造函数的初始化列表里写的顺序 不会改变成员实际构造顺序,成员总是按照 它们在类中声明的顺序 构造。
    • 如果在初始化列表里对某个成员 依赖另一个成员的值,而实际构造顺序恰好是被依赖的成员 还没构造,就可能访问到 未初始化的成员,导致未定义行为。
  3. 写了一个带参构造,还以为有默认构造
    • 一旦声明了任何构造,编译器不再“自动合成默认构造”,除非 T() = default;
  4. 含虚函数的类也可能 trivial?——基本不可能
    • 由于 vptr 设置,默认构造一定是 non-trivial
  5. const/引用成员的默认构造
    • 没默认值就会把默认构造“删掉”(C++11+),或直接构造失败。
本文由作者按照 CC BY 4.0 进行授权