Item 4:确定对象使用前已经初始化
对齐 Effective C++ 第三版 Item 4:区分初始化与赋值,使用 member initialization list,遵守基类与成员声明顺序,并以 local static 避免跨翻译单元初始化次序问题。
学习目标
- 能区分 initialization 与 assignment,使用成员初始化列表一次构造对象并量化多余默认构造和赋值
- 能推导虚基类、直接基类与数据成员的真实初始化顺序,识别依赖声明顺序造成的未定义读取
- 能实现 function-local static accessor 替换跨翻译单元 non-local static 依赖,并验证首次调用和退出阶段
生命周期起点实验
对象何时真正有效
先预测初始化阶段和真实顺序,再切换场景查看构造、链接与退出证据。
当前阶段
成员在 constructor body 之前就必须成为有效对象。
规则
用 member initialization list 直接构造;不要 default-then-assign,尤其是 const / reference member。
当前场景 · member initialization list
用 member initialization list 直接构造;不要 default-then-assign,尤其是 const / reference member。
从“有存储不等于有有效对象”开始
↡为对象提供初始值、开始其生命周期并建立类不变量的过程。与
↡在对象生命周期已经开始后,用新值替换现有值的操作。不是同一阶段。构造函数体执行前,基类与所有成员已经初始化;在函数体中写 member = value 是再次赋值。
应在初始化结束时成立,而不是等待调用方再执行 setup。
内置类型也要显式初始化
原书指出 C++ 不同语境下内置类型初始化规则复杂,最可靠习惯是对象使用前明确给值。
int retryCount{};
double timeoutSeconds{2.5};
Widget* selected{};
std::array<int, 4> histogram{};int count; 在自动存储期可能保持不确定值;读取它会导致未定义行为或错误分支。成员可给
↡直接写在成员声明处、供未在构造函数列表覆盖的构造路径使用的默认初值。,
降低多个构造函数遗漏风险。
class ConnectionOptions {
public:
explicit ConnectionOptions(std::string endpoint)
: endpoint_{std::move(endpoint)} {}
private:
std::string endpoint_;
int retries_{3};
bool encrypted_{true};
};仍需审查每个字段是否有合理默认;{} 不是业务正确性的万能值。
成员初始化列表直接构造
↡构造函数冒号后为基类和成员选择构造实参的列表,在函数体前执行。错误方式先默认构造 string,再赋值:
class AddressEntry {
public:
AddressEntry(const std::string& name, const std::string& address)
{
name_ = name;
address_ = address;
}
private:
std::string name_;
std::string address_;
};正确方式一次复制构造:
AddressEntry::AddressEntry(const std::string& name,
const std::string& address)
: name_{name}, address_{address}
{}对 class 成员可能多做一次构造和赋值;对 const/reference 成员甚至无法在函数体赋值,必须列表初始化。
↡构造时必须立即绑定且之后不可重新绑定的引用数据成员。和 const member 都迫使接口尽早确定依赖,通常是好事。
初始化顺序由声明决定
↡类定义中数据成员出现的先后顺序,它决定实际初始化次序。initializer list 书写顺序不会改变真实顺序。
class Session {
public:
Session(std::string endpoint)
: connection_{endpoint_}, endpoint_{std::move(endpoint)} {}
private:
Connection connection_; // 先初始化,却依赖尚未初始化的 endpoint_
std::string endpoint_;
};这里即使 list 先写 endpoint,connection* 仍按声明顺序先构造并读取未开始生命周期的 endpoint*。
↡某成员构造表达式读取另一个成员,因此要求后者更早完成初始化的关系。应让被依赖成员先声明,并让 list 顺序与声明一致,编译器警告更清楚。
基类先于派生成员
↡当前类直接继承的基类,按 base-specifier list 顺序初始化。虚基类由最派生对象负责并最先初始化,然后直接基类,再到成员,最后构造函数体。析构顺序完全反向。
virtual bases
-> direct bases
-> data members in declaration order
-> constructor body初始化列表最好按此顺序书写,减少审查时的认知偏差。
多构造函数共享默认值
↡一个构造函数调用同类另一个构造函数完成共同初始化的语言机制。现代 C++ 可让一个主构造函数维护不变量,其余构造函数委托:
class Timer {
public:
Timer(std::chrono::milliseconds period, bool repeating)
: period_{validate(period)}, repeating_{repeating} {}
explicit Timer(std::chrono::milliseconds period)
: Timer{period, false} {}
private:
std::chrono::milliseconds period_;
bool repeating_;
};避免多个构造函数复制验证逻辑。委托完成后,委托构造体可做附加工作,但不应重新建立另一套不变量。
non-local static 的跨单元顺序
↡定义在函数外、namespace、class static 或文件作用域且具有静态存储期的对象。同一翻译单元内可按定义顺序推断动态初始化;不同翻译单元之间,如果一个 non-local static 构造依赖另一个,次序通常不可靠。
// filesystem.cpp
FileSystem globalFileSystem;
// directory.cpp
Directory tempDirectory(globalFileSystem.root()); // 跨翻译单元依赖链接顺序或构建变化即可触发“有时工作”。
construct-on-first-use
↡把静态对象放入 accessor 函数,首次执行声明时才初始化并返回引用的模式。FileSystem& fileSystem()
{
static FileSystem instance;
return instance;
}
Directory& tempDirectory()
{
static Directory instance{fileSystem().root()};
return instance;
}调用 tempDirectory() 时会先调用 fileSystem(),依赖顺序变得显式。C++11 起函数局部 static 的首次初始化具有线程安全保证。
如果两个 accessor 构成循环依赖,模式不能自动解决;需要重构依赖图或显式启动阶段。
退出阶段仍需审计
↡程序终止时静态对象按规则析构,某个析构函数可能访问已销毁依赖的风险。局部 static 在成功构造后会登记析构。若 A 先构造、B 后构造,通常 B 先析构;但跨 accessor 的退出调用仍可能形成悬空依赖。
↡在 main 开始后按明确顺序创建服务,并在 main 结束前按逆序显式销毁的生命周期管理。复杂系统常由 Application 对象拥有各服务,比大量全局 static 更容易测试。对进程寿命单例,有时刻意不析构可避免退出次序,但必须明确资源与工具影响,不能随意泄漏。
初始化审计与测试
↡检查每个对象初值、初始化列表、声明顺序、静态依赖和退出访问的系统过程。Make sure that objects are initialized before they are used(确定对象使用前已经初始化)应作为所有存储期的统一门禁:自动对象有明确领域初值,成员在构造体前直接完成,non-local static objects 不依赖跨单元偶然顺序。这里的 initialization order(初始化顺序)同时包含虚基类、直接基类、成员声明和静态动态初始化,不能只检查 initializer list 的视觉排列。
先预测:把 initializer list 顺序交换是否改变真实顺序?把两个 global 放在不同 .cpp 后能否依赖链接顺序?写下语言保证,再运行测试。
- 开启
-Wreorder/对应编译器警告并视为错误。 - 用计数类型断言成员只复制构造一次,没有 default-then-assign。
- MemorySanitizer/UBSan 捕获未初始化读取与生命周期错误。
- 两个翻译单元分别定义 static,反转链接顺序重复测试。
- 多线程同时首次调用 accessor,构造计数恰好为一。
- 测试退出路径,析构日志不访问已销毁服务。
小结
- initialization 开始生命周期并建立不变量,assignment 只替换已存在对象的值
- 内置类型也要明确初值,但语言层零值不等于领域有效值
- member initialization list 一次构造成员,避免 default-then-assign
- 基类和成员真实顺序由继承列表与声明顺序决定,不由初始化列表书写顺序决定
- 跨翻译单元 non-local static 顺序不可依赖,优先 function-local static accessor
- 首次构造线程安全不等于退出依赖安全,复杂服务应考虑显式 Application 生命周期
名词解释
本章出现的专业名词,用大白话再讲一遍。
- initialization
提供初值、开始生命周期并建立不变量的过程。
- assignment
在对象已存在后用新值替换现有值的操作。
- 类不变量
构造完成后由所有公开操作保持的状态约束。
- value initialization
使用空花括号或明确初值初始化对象的语法。
- default member initializer
成员声明处供构造路径使用的默认初值。
- member initialization list
构造体前为基类和成员选择构造实参的列表。
- default-then-assign
先默认构造成员再由赋值覆盖的冗余路径。
- reference member
构造时必须绑定且之后不能重新绑定的引用成员。
- member declaration order
数据成员在类定义中的顺序,决定真实初始化次序。
- member initialization dependency
一个成员构造读取另一个成员的顺序依赖。
- direct base initialization
直接基类按基类列表顺序完成的初始化。
- virtual base
由最派生构造函数负责初始化的共享基类子对象。
- delegating constructor
构造函数调用同类另一构造函数的机制。
- canonical constructor
验证参数并建立完整不变量的主要构造函数。
- non-local static object
函数外定义且具有静态存储期的对象。
- static initialization order problem
跨翻译单元 static 动态初始化次序不可依赖的问题。
- construct-on-first-use
通过 accessor 首次调用时构造 static 的模式。
- function-local static
函数内定义并在首次经过声明时初始化的 static。
- static destruction order
退出阶段 static 析构与依赖访问的顺序风险。
- explicit application lifetime
由 Application 显式拥有并排序服务的生命周期。
- initialization audit
检查初值、顺序、静态依赖和退出访问的过程。
练习
- 问题 1:修复构造成本(initialization、assignment、member initialization list)。 一个类在构造函数体给三个 string 和一个 const id 赋值,改写并说明哪些成员必须列表初始化。
- 问题 2:推导真实顺序(initialization order、member declaration order、virtual base)。 给出虚基类、两个直接基类和三个成员,初始化列表故意乱序,写出构造与析构顺序。
- 问题 3:消除 static 次序依赖(non-local static objects、construct-on-first-use、function-local static)。 两个 cpp 中的 logger 和 registry 在构造/析构时互相调用,设计初始化与退出方案。