Item 4:确定对象使用前已经初始化

对齐 Effective C++ 第三版 Item 4:区分初始化与赋值,使用 member initialization list,遵守基类与成员声明顺序,并以 local static 避免跨翻译单元初始化次序问题。

学习目标

  • 能区分 initialization 与 assignment,使用成员初始化列表一次构造对象并量化多余默认构造和赋值
  • 能推导虚基类、直接基类与数据成员的真实初始化顺序,识别依赖声明顺序造成的未定义读取
  • 能实现 function-local static accessor 替换跨翻译单元 non-local static 依赖,并验证首次调用和退出阶段
对象构造与析构顺序构造顺序(自上而下)① 基类构造Base::Base()② 成员变量构造成员按声明顺序初始化③ 派生类构造体Derived::Derived()析构严格反向于构造析构顺序(严格反向)① 派生类析构Derived::~Derived()② 成员变量析构成员按声明逆序析构③ 基类析构Base::~Base()条款 9:构造/析构期间不要调用 virtual 函数构造基类时派生部分尚未成型,virtual 退化为基类版本——不会多态析构基类时派生部分已经销毁,virtual 同样不会多态构造自基向派,析构自派向基——中间夹着成员,成员始终比对象本体先构造后析构条款 1-12:初始化、构造/析构顺序、operator= 返回引用、自我赋值
对象构造顺序:基类构造 → 成员变量构造 → 派生类构造体;析构严格反向。构造/析构期间调用 virtual 函数不会触发多态。

生命周期起点实验

对象何时真正有效

先预测初始化阶段和真实顺序,再切换场景查看构造、链接与退出证据。

当前阶段

成员在 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 顺序与声明一致,编译器警告更清楚。

基类先于派生成员

虚基类由最派生对象负责并最先初始化,然后直接基类,再到成员,最后构造函数体。析构顺序完全反向。

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 的跨单元顺序

同一翻译单元内可按定义顺序推断动态初始化;不同翻译单元之间,如果一个 non-local static 构造依赖另一个,次序通常不可靠。

// filesystem.cpp
FileSystem globalFileSystem;
 
// directory.cpp
Directory tempDirectory(globalFileSystem.root()); // 跨翻译单元依赖

链接顺序或构建变化即可触发“有时工作”。

construct-on-first-use

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 的退出调用仍可能形成悬空依赖。

复杂系统常由 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 生命周期

资料与写作方式声明

本章以Effective C++, Third Edition, Item 4权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

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. 问题 1:修复构造成本(initialization、assignment、member initialization list)。 一个类在构造函数体给三个 string 和一个 const id 赋值,改写并说明哪些成员必须列表初始化。
  1. 问题 2:推导真实顺序(initialization order、member declaration order、virtual base)。 给出虚基类、两个直接基类和三个成员,初始化列表故意乱序,写出构造与析构顺序。
  1. 问题 3:消除 static 次序依赖(non-local static objects、construct-on-first-use、function-local static)。 两个 cpp 中的 logger 和 registry 在构造/析构时互相调用,设计初始化与退出方案。

讨论

评论区加载中…