DefaultConstructor的构造操作
默认构造函数负责初始化基类、成员、虚基类和 vptr,确保对象构造阶段多态、继承和成员有效性,顺序严格按声明顺序执行。
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 | 直接基类 B | B 构造完成,虚基类已构造 |
3 | 直接基类 C | C 构造完成,虚基类已构造 |
4 | 最派生类 D | 自身对象构造完成 |
类含虚函数(vptr / vtable 设置)
每个包含虚函数的对象都拥有一个 vptr(虚表指针),指向对象所属类的虚表,以保证虚函数调用的动态分派正确。即使用户提供了空的构造函数,编译器仍会生成默认构造函数来完成 vptr 的设置,确保构造阶段的虚函数调用行为正确。
vptr 的角色:
- 在 构造函数中调用虚函数 时,编译器会通过对象的 vptr 来决定调用哪个实现。
- 在基类构造函数执行时,vptr 暂时指向 基类的 vtable。
- 在派生类构造函数开始执行时,vptr 更新指向 派生类的 vtable。
只要对象在构造期间 有必须执行的操作(初始化基类/成员对象、虚基类、设置 vptr),编译器就不能靠“简单的内存位操作”跳过,必须生成真正可执行的默认构造函数。
合成的默认构造函数做了什么(调用次序)
合成出来的默认构造函数会按固定顺序为“子对象”收尾:
虚基类:由最派生类负责,按声明顺序构造;
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 在后
直接基类:按它们在类头中声明的顺序构造(与成员无关);
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
非静态数据成员:按它们在类内声明的顺序构造(与初始化列顺序无关);
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,因为顺序严格按成员在类里的声明顺序。
自身的构造函数体:执行函数体(若为合成,通常为空体,但已做完上面工作和必要的运行时布置,比如 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{};
为例,值初始化执行步骤大致如下:
- 零初始化:将对象内存全部清 0
- 调用默认构造函数(如果类类型有默认构造)
- 构造函数体执行(合成或用户定义)
而 T t;
(默认初始化)则跳过第一步,直接调用默认构造函数(或对内置类型保持未定义值)。
const / 引用成员与默认构造
const
成员和引用成员 (T&
):必须在构造时通过 构造函数初始化列表 或 默认成员初始值 给出初值,不能在构造函数体内赋值替代。- C++11 及以后:如果默认构造函数被编译器合成,而类中存在
const
或引用成员 没有可用的初始化方式,该默认构造函数会被 定义为deleted
,对象无法默认构造。 - C++98/03:遇到同样情况时,编译器通常 无法合成默认构造函数,导致 类对象不可构造,必须手动提供构造函数来初始化这些成员。
与 vptr / vtable 的关系
基本概念回顾
- vtable(虚表):类级别的数据结构,存储虚函数的地址。
- vptr(虚表指针):对象内的指针,指向当前对象所使用的虚表。
- 每个有虚函数的对象都会包含一个 vptr(或多个 vptr,取决于多继承情况)。
构造对象时 vptr 的作用
在构造过程中,对象的动态类型随着构造阶段不断变化:
- 构造虚基类
- 构造非虚直接基类
- 构造最派生类自身
在每个阶段,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 指向 | 说明 |
---|---|---|
构造虚基类 A | A 的虚表 | 保证 A 构造时调用虚函数 f() 调用 A 版本 |
构造直接基类 B | B 的虚表 | 保证 B 构造时调用虚函数 g() 调用 B 版本,f() 仍然指向 A |
构造最派生类 C | C 的虚表 | 对象完成构造,所有虚函数指向最终版本 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,错误信息更直观。
noexcept
与 constexpr
T() = default
的 noexcept 性质是可推导的:如果所有基类/成员的默认构造都是noexcept
,它就是noexcept
。- 在满足常量初始化条件时,默认构造也可成为
constexpr
(C++20 放宽许多限制)。
Trivial 的判定
一个默认构造是 trivial 的大致条件:
- 没有用户自定义构造;
- 没有虚函数、虚继承;
- 基类与成员的默认构造都 trivial;
- 类不是带有某些特殊注解/属性导致的非常规布局。
trivial 的意义:可被按位初始化/拷贝、安全地放在 memcpy
等优化路径上;对象创建极其便宜。
常见误区
- “没写构造就会自动清零”——错
- 自动存储期的内置成员在“默认初始化”下是未定义值;要清零用
T t{};
或自行初始化。
- 自动存储期的内置成员在“默认初始化”下是未定义值;要清零用
- 成员初始化顺序和列表顺序
- 构造函数的初始化列表里写的顺序 不会改变成员实际构造顺序,成员总是按照 它们在类中声明的顺序 构造。
- 如果在初始化列表里对某个成员 依赖另一个成员的值,而实际构造顺序恰好是被依赖的成员 还没构造,就可能访问到 未初始化的成员,导致未定义行为。
- 写了一个带参构造,还以为有默认构造
- 一旦声明了任何构造,编译器不再“自动合成默认构造”,除非
T() = default;
。
- 一旦声明了任何构造,编译器不再“自动合成默认构造”,除非
- 含虚函数的类也可能 trivial?——基本不可能
- 由于 vptr 设置,默认构造一定是 non-trivial。
- const/引用成员的默认构造
- 没默认值就会把默认构造“删掉”(C++11+),或直接构造失败。