C++ 构造函数:初始化顺序、explicit 与合法状态
02 构造函数怎样把对象带进合法状态
看一段代码:一个 Logger 类在构造函数体里打开文件,一个 Connection 类在构造函数体里建立网络连接。如果连接建立失败,构造函数抛出异常。这时候文件句柄已经打开,但 Logger 的析构函数不会被调用,因为整个对象还没有构造完成。文件句柄泄漏了。这个问题不在于打开文件或建立连接本身,真正的原因在于构造函数体执行之前,对象的成员已经完成了自己的初始化;进入构造函数体时,对象已经不是"空的",构造函数体只是在一个已经部分初始化完成的对象上继续做事情。如果构造函数体里做的事失败回滚不完整,就会出现只构造了一半的成员没有得到正确清理的情况。
这个例子引出了一个许多人从 C++ 教科书上读到但未必真正理解的事实:成员进入构造函数体之前,已经完成了各自的初始化。构造函数体并非在"创建对象",其实是在"已经创建完毕的对象基础上执行额外的设置"。真正负责初始化的是成员初始化列表(member initializer list)。即使不写初始化列表,也不代表没有初始化发生,编译器会替成员调用它们自己的默认构造函数,没有默认构造函数的成员则直接报编译错误。
构造函数体为什么太晚
对象从被分配存储空间的那一刻开始经历一个分阶段的过程。首先是基类子对象的构造(如果存在基类),然后按照在类定义中的声明顺序逐个初始化非静态数据成员,最后才进入构造函数体执行花括号内的代码。当执行流进入构造函数体时,所有成员都已经完成了各自的构造——对内置类型来说这意味着它们已经占据了存储但值不确定(除非在初始化列表中给了初值),对类类型成员来说这意味着它们的构造函数已经执行完毕。
如果在构造函数体里用赋值语句给成员设值,这不叫初始化,应当称为修改。以 std::string name; 为例:如果不在初始化列表里给它初值,进入构造函数体时它已经通过 std::string 的默认构造函数初始化为空字符串;然后再在构造函数体里写 name = "Alice";,这其实是先构造了一个临时的 std::string("Alice"),再通过拷贝赋值把 name 替换成新值。多了一次构造函数调用、一次析构函数调用(临时对象),以及一次赋值操作。这些开销通常不会成为性能瓶颈,但概念上的混淆才是更大的代价。这种混淆会让程序员误以为构造函数体是"对象诞生的地方",却不知道在进入函数体之前成员已经拥有了独立的生命周期。
更关键的是三种必须在初始化列表中处理的成员。第一,const 数据成员:它们必须在初始化时获得值,之后不能再修改,构造函数体里的赋值语句对它们来说根本不合法。第二,引用成员:引用也必须在定义时绑定,不能先默认初始化再在构造函数体里"重新指向"。第三,没有默认构造函数的类类型成员:它们根本没有"空着等赋值"这个选项,不在初始化列表里调用它们的某个构造函数,编译就通不过。
这三点合在一起说明了初始化列表绝非语法糖,也绝非"写起来漂亮一点"的可选形式,它本质上是 C++ 对象模型对某些成员类型提出的硬性要求。
从另一面看,即便是那些可以在构造函数体里通过赋值设置的内置类型成员(如 int、double),在初始化列表中给出初值也是更好的做法:它能保证成员从一开始就处于确定的值,避免在构造函数体执行前悬挂于一个不确定的状态。
初始化顺序只取决于声明顺序
成员初始化列表的书写顺序不影响实际初始化顺序。实际的初始化顺序取决于成员在类定义中的声明顺序,从上到下,和初始化列表怎么写毫无关系。为了看清楚这一点,可以构造一个简单的反例:一个类持有两个 int 成员,声明顺序是 a 先 b 后,但在初始化列表中先写 b(val) 再写 a(b),意图让 a 用 b 的值来初始化。这段代码可以通过编译,甚至在一些简单测试中碰巧得到"正确"结果,但 a 实际上在 b 之前初始化,导致 a 读到的 b 的值处于未定义状态。
多数编译器在此场景下会给出警告,但标准并不强制报错。这意味着依赖初始化列表顺序来"编排"成员之间的初始化依赖是代码中的定时炸弹。把一个成员的初始值设置为另一个成员的值时,被依赖的那个成员必须在类定义中声明在前面。这也是为什么把成员按依赖关系在类定义中排列不仅是为了整洁,更因为它直接影响初始化行为的正确性。
这个规则在继承场景下会扩展:基类子对象的构造函数先于派生类成员初始化,更先于派生类构造函数体。完整的构造顺序是:直接基类(按继承声明顺序)→ 非静态数据成员(按类内声明顺序)→ 构造函数体。对虚基类而言情况有所不同:虚基类由最派生类负责初始化,且在一切非虚基类和成员之前构造。虚基类的细节我们留到最后一章详细讨论,对于现在的单继承场景,只需要记住"基类先于成员,成员先于构造函数体"的严格顺序。
委托构造(delegating constructor)在这个体系中增加了一个新的层次。一个构造函数可以在初始化列表中调用同一类的另一个构造函数,将部分或全部初始化工作委托给它。被委托的构造函数按照完整的构造顺序执行(成员初始化列表 + 构造函数体),执行完毕后控制权回到委托构造函数,后者只执行自己的构造函数体。委托构造带来的好处是避免在多个构造函数之间复制相同的初始化代码,但有一个关键约束:委托构造函数的初始化列表中不能同时包含成员初始化器和委托调用,两者只能二选一(要么委托给另一个构造函数,要么自己初始化成员)。
默认成员初始化:把默认值放在定义处
C++11 引入了默认成员初始化器(default member initializer):在类定义中为数据成员直接提供默认值。double balance = 0.0; 和 std::string owner{"unknown"}; 这种写法定义的并非"构造后赋值",其确切含义在于:"如果某个构造函数没有在初始化列表中为该成员提供值,就使用这里指定的默认值来初始化它"。它仍然是初始化,发生在成员构造的时刻,与构造函数体执行的时机无关。
默认成员初始化器让只有一个简单成员的类不再需要显式写一个构造函数来给默认值,从而让这个类更容易满足编译器自动生成特殊成员函数的条件。这是 Rule of Zero 的技术基础,在拷贝控制一章会再次展开。对于一个有多个构造函数的类,默认成员初始化器可以显著减少初始化列表中的重复代码:所有构造函数共享的默认值写在定义处,构造函数只需要在初始化列表中覆盖那些不同于默认值的情况。
explicit:阻止意料之外的隐式转换
单参数的构造函数如果没有标记 explicit,就同时定义了一条从参数类型到本类类型的隐式转换路径。这条路径有时是合理的(比如一个 Complex 类可能允许 Complex c = 3.0; 把一个 double 隐式转换成复数)。但更多时候它是陷阱:比如 BankAccount account = 1000.0; 表面上像是"声明一个账户并给它初始余额 1000",实际上却是一个构造函数的隐式调用。如果代码本身清楚地写了 BankAccount account(1000.0); 倒也没问题,但隐式转换会在函数参数传递、返回值和表达式中悄然发生,一些不经意的写法可能在完全不被注意的情况下创建了临时对象,或者把某个语义上不该转换的类型变成了函数参数。
explicit 构造函数禁止隐式转换,要求调用方必须显式写出类型名或使用直接初始化语法。从 C++11 开始,带多个参数(包括带默认值的参数)的构造函数也可以标记 explicit,禁止通过花括号初始化列表进行隐式转换。建议的策略是:默认把单参数构造函数标记为 explicit,只有当确实存在自然的、语义清晰的类型转换需求时才去掉 explicit。在工程代码中,因为漏掉 explicit 导致的隐式转换 Bug 远比因为加上 explicit 导致的不便要多得多。
隐式转换一旦进入重载决议,出问题的场景常常非常隐蔽。设想一个函数 void Process(BankAccount account),调用方不小心传入了一个 double 值,未能传入 BankAccount 对象。由于存在非 explicit 的单参数构造函数,编译器会默默构造一个临时 BankAccount 对象然后传给函数,调用方完全没有意识到自己写错了参数类型。如果碰巧类型语义接近(比如两个类都有接受 int 的构造函数),隐式转换会在代码中形成一条没有人有意设计、也没有人注意到的自动类型通道,出了问题以后排查方向完全偏离实际原因。explicit 的价值并不在于让"写法更啰嗦",它真正的意义是"把类型转换这件事重新还给代码作者做显式决策"。
还需说明的是,explicit 对于零参数构造函数没有意义(不存在从"无参数"到类型的隐式转换路径),对于两个或更多非默认参数的构造函数在 C++11 之前也无影响。C++11 的列表初始化改变了一部分情况:explicit 同样禁止了通过复制列表初始化进行的隐式构造,比如 BankAccount account = {1000.0, "savings"}; 这种写法在有 explicit 构造函数时会被拒绝。这个规则的动机始终是一致的:只有代码中明确写出类型名或者处于直接初始化上下文的构造才被允许,所有"看起来在复制值但实际上在构造对象"的写法都被拦截。
一段代码同时演示初始化顺序和 explicit
以下代码用一个打印日志的 Reporter 辅助类和两个演示目标把声明顺序与 explicit 放进同一份可编译示例中。Demo 类的两个 int 成员声明顺序是 a_ 在前、b_ 在后,但初始化列表刻意写成 b_{val}, a_{b_},提示"先写 b 再写 a"。实际初始化严格按照声明顺序:a_ 先初始化,此时它读取的 b_ 还是未定义值。编译器(Clang 和 GCC)在此场景下会给出 -Wreorder 警告,但标准不要求报错。
#include <iostream>struct Reporter {
Reporter(const char* label) : label_{label}
{ std::cout << label_ << " 构造\n"; }
~Reporter()
{ std::cout << label_ << " 析构\n"; }
private:
const char* label_;
};
class Demo {
public:
Demo(int val) : b_{val}, a_{b_} { // 书写顺序:b 在前,a 在后
std::cout << "a=" << a_ << " b=" << b_ << '\n';
}
private:
int a_; // 声明在前,先初始化——读到 b_ 的未定义值int b_; // 声明在后,后初始化
Reporter r_{"r"}; // 在 a_ b_ 之后初始化,析构时最先退出
};
struct Widget {
explicit Widget(int id) : id_{id} {}
int id_;
};
int main() {
Demo d{42};
// 输出示例(a_ 的值未定义,每次运行可能不同):// r 构造// a=32766 b=42
Widget w1{10}; // OK: 直接初始化// Widget w2 = 10; // 编译错误:explicit 禁止复制初始化式的隐式转换
}
Demo{42} 的运行输出中 a_ 的值是未定义的。由于它恰好在声明顺序上排在 b_ 前面,又在初始化列表中依赖了 b_ 的值,构成了一个"合法语法、错误语义"的典型陷阱。小项目里这种 Bug 可能长期潜伏,因为未定义值在某些编译优化级别下碰巧看起来正确(比如栈上残留的值恰好等于预期的 42)。Reporter 的构造和析构日志也印证了三次规则:r_ 在 a_ 和 b_ 之后声明,因此构造顺序是 a_ → b_ → r_,析构逆序是 r_ → b_ → a_。虽然输出只有 r 的构造和析构日志(a_ 和 b_ 是内置类型,无构造析构输出),但逆序规则的物理执行不依赖于是否有日志输出。
Widget 是 explicit 的最小示例。Widget w1{10} 直接调用构造函数,编译器接受;Widget w2 = 10 被拒绝,因为复制初始化语法要求隐式转换路径存在,而 explicit 把它堵死了。两行注释之间就是 explicit 的全部机制:它不改变构造函数的行为,只移除了一条从 int 到 Widget 的静默转换通道。
委托构造:一件事不写两遍
当一个类需要提供多组不同的构造参数时,传统做法是在每个构造函数里复制相同的初始化逻辑(如检查参数、设置相互依赖的成员、建立资源连接)。重复代码带来的首要麻烦并非美观问题,主要在于修改时可能遗漏某个构造函数,导致不同构造路径对同一个成员的初始化行为不一致,从而产生难以定位的 Bug。
C++11 的委托构造解决了这个问题:一个构造函数可以在初始化列表中使用另一个构造函数(同一类的)来完成初始化,自身的职责缩减到只处理本构造路径特有的逻辑。语法上是这样:BankAccount(int id) : BankAccount(id, 0.0) {}。在这个例子中,当前构造函数把自己的工作委托给了接受两个参数的重载版本,后者完成初始余额设为 0.0 的完整构造流程。被委托的构造函数先按完整的构造顺序执行(包括自己的初始化列表和构造函数体),执行完毕后控制权交还给委托构造函数,后者再执行自己的构造函数体。
委托构造有一个硬约束:在委托构造函数的初始化列表中,要么只有委托调用,要么只有成员初始化器,不能两者混用。原因很直接:如果既委托了另一个构造函数又初始化了自己的成员,两条路径可能对同一个成员给出不同的值,语义矛盾的代价远大于省掉一行初始化列表代码的收益。如果委托构造之后还需要对成员做特殊的后续调整,调整代码应该放在委托构造函数的函数体中(并非初始化列表中)。这一点和所有构造函数一样:一旦进入函数体,成员就已经初始化完毕,函数体只能修改,不能"重新初始化"。
委托构造不能形成循环链。如果 A 委托 B,B 又委托 A(可能通过中间环节),编译器通常会报错,因为循环委托意味着永远无法完成初始化。幸运的是,这种错误通常在编译期就被捕获,不会留到运行时。
默认值与默认构造:对象的"零参数"出生方式
如果类没有声明任何构造函数,编译器会生成一个隐式的默认构造函数。这个编译器生成的默认构造函数做的事很简单:按顺序调用每个类类型成员的默认构造函数,对于内置类型成员不做初始化(它们的值不确定)。这是隐式生成规则的最低限度行为,旨在保证类类型成员处于合法状态,却不保证内置类型成员有确定值。
如果你声明了任何带参数的构造函数,编译器就不再替你生成这个隐式默认构造函数。这意味着如果类定义中只有 BankAccount(double initialBalance) 而没有 BankAccount(),那么写 BankAccount a; 就是编译错误(因为没有默认构造函数可用)。这个设计的动机在于,一旦你定义了专属的构造逻辑,编译器便不再自动假定"默认构造"属于合理的操作。做出这个决定的权力被交还给设计者自行判断。
如果类确实有合理的默认状态,可以通过 = default 要求编译器生成默认构造函数,也可以手动写一个。= default 的优势在于它明确表达了"默认行为就够用,我只是需要它存在",而且可以被编译器优化为聚合初始化等更高效的路径。如果类没有合理的默认状态(比如一个文件句柄不应该有"未打开"状态来表示合法对象),就不要提供默认构造函数——强迫调用方在构造对象时就给出所有必要信息,这是类型设计层面最有效的错误预防。
构造失败时,已经完成的部分会逆序回滚
如果构造函数在执行过程中抛出异常,语言保证已经构造完成的基类子对象和非静态数据成员会按构造的逆序调用它们的析构函数(仅针对各自成员的析构,而非整个对象的析构)。这个"构造失败自动回滚"机制是由编译器生成的代码实现的,在构造函数抛出异常时,运行时逆序遍历已经完成初始化的子对象并调用析构函数。
这同时意味着两件事。第一,构造函数中获取资源的顺序需要与成员的声明顺序协调,让依赖关系与其一致。例如,如果成员 B 的初始化依赖成员 A 的资源,A 就应该在类定义中声明在 B 前面,这样回滚时 B 先析构(它的析构可能用到 A 的资源),A 后析构。第二,构造函数体中对裸资源的分配应该使用 RAII 包装。假若在构造函数体内用裸 new 分配了资源,而在后续某行又抛出异常,鉴于异常回滚不覆盖构造函数体内的裸资源,泄漏便不可避免地发生了。使用 std::unique_ptr 或 std::vector 这样的 RAII 成员包装资源,成员回滚机制就能自动覆盖这些资源的释放。
还需要分清两个概念:构造失败和析构函数不会同时作用在同一个对象上。如果构造函数抛出了异常,这个对象被认为从未完成构造,它的析构函数不会被调用。尽管如此,其成员的析构函数依然会被调用(因为这些成员已经完成了构造)。如果构造函数成功返回,对象就进入了完整构造状态,此后它的析构函数必须被调用来完成生命周期收尾。这个区分在写 RAII 包装类、管理动态分配对象和设计工厂函数时非常重要。
一个经常被问到的场景是:在构造函数中要不要做可能失败的操作,比如打开文件或建立网络连接?答案是这些操作可以做,但必须放入 RAII 成员(把文件句柄交给一个 RAII 成员,把网络连接交给另一个 RAII 成员)。如果连接建立失败、成员构造抛出异常,所有已经构造的成员(包括那个已经成功打开的文件句柄 RAII 对象)都会逆序析构,不会泄漏。如果把连接成功的资源放在构造函数体内,而非成员当中,那么构造失败回滚就无法覆盖它。RAII 成员是异常路径上唯一可靠的清理入口。
从出生就合法,而无需事后补救
回到本章的核心命题。构造函数的设计目标绝不仅是"给字段设初值",它的核心使命在于"确保对象从生命周期开始就处于合法状态"。这个承诺通过三个机制实现:初始化列表保证成员在进入构造函数体时已经拥有确定的值(避免等构造函数体去补救);成员声明顺序决定初始化顺序,消除顺序依赖的歧义;构造失败自动回滚保证要么完整构造成功,要么不留半成品。
一个设计良好的类,在构造函数成功返回之后,不需要调用方再调用任何 "init" 函数或检查任何 "is_valid" 标志,合法状态即为构造成功的同义词。任何需要两步初始化(先构造再手动初始化)的设计,本质上均源于构造函数的职责没有履行完整。它们把本应在构造期间完成的验证和资源获取推迟到了构造之后,导致对象的合法状态边界不再由语言机制保证,转而依赖调用方自律。在 C++ 里,"对象构造成功 = 对象可用",应当作为一个自觉的设计硬度来追求,而不应设法回避。
下一章讨论对称的另一面:析构函数怎样在对象生命周期结束时完成逆向的收尾,以及为什么这条逆向路径对 RAII 和异常安全至关重要。
阅读导航




