C++ / a working model

84 / 103   ·   C++11   ·   约 14 分钟

构造失败:完整对象没析构,成员仍会清理

先记住这句话

构造函数抛异常时,未完成的完整对象不会调用其析构函数,但已构造完成的成员会按逆序销毁。用无分配的事件日志追踪构造、抛出与清理,区分对象生命周期、成员生命周期和分配到的存储。

本篇内容
  1. 先看规则适用哪个对象
  2. 把资源责任放进能完成构造的成员
  3. 历史草案不能冒充今日标准全文
  4. 运行示例
  5. 动手练习
READING EVIDENCE / 已读完整正文

The C++ Standard

已通读公开N3337工作草案全部30章与附录A—E的HTML正文,含全部可见代码、语法、要求表、注释与脚注。第1、15章由本代理读完,其余章节由7个分片读者逐段连续读完并保存精确台账;7幅图的SVG标签和连线关系也已读取,未声称截图核验。此full仅表示N3337公开草案完整文本,不是取得或通读付费ISO/IEC 14882:2011正式出版物,也不包括被引用的外部标准。

查看版本、实际阅读范围与原文入口 →

先看规则适用哪个对象

“构造失败不会析构”只说了一半。对于未完成的非委托构造,完整对象的析构函数不会执行;但每个已经完成构造的成员和基类有自己的生命周期,它们需要清理。资源若只是裸指针成员,并不会因为外层构造失败而自动 delete。

示例将两个成员当作资源句柄,用正数记录构造完成,用负数记录析构。外层构造体随后抛出异常。进入 catch 时,日志应是 1、2、-2、-1,外层析构标记不会出现。成员初始化顺序取决于声明顺序,不取决于初始化列表书写顺序。

把资源责任放进能完成构造的成员

这个机制解释了为什么 RAII 要真正形成一个对象,而不是仅在完整类析构里集中释放所有资源。若第一个成员取得资源后,第二个成员或构造体失败,第一个成员已经独立完成构造,语言会调用它的析构函数。

日志使用固定数组,避免在析构记录时分配内存或抛异常。实际资源句柄的析构也应尽量不抛异常;在栈展开中又有析构异常逃出,会触发 terminate。不要试图通过让异常继续传播来报告析构失败,应设计独立的可报告关闭操作。

历史草案不能冒充今日标准全文

N3337 是2012年的公开工作草案,不是付费 ISO/IEC 14882:2011 正式出版物。本课只使用其中明确读过的构造、析构、异常章节,且实例在 C++11 到 C++20 的相关行为一致。

历史措辞不能全部原样套到今天:N3337 还描述动态异常规格以及当时 noexcept 不属于函数类型的规则;现代语言已经变化。查规范时应记录版本、稳定条款标签与段落,而不是只记一个在不同版本会改变的章节号码。

容易答错的地方

  • 构造函数失败后,不能指望完整对象析构函数释放裸资源;应在成员类型中建立资源所有权。
  • 在构造函数 function-try-block 的处理器中,已完成成员已被销毁,不能再访问它们来做清理。

运行一个例子

最低标准 C++11 · 完整程序 · 下载 .cpp

#include <array>
#include <cassert>
#include <cstddef>
#include <stdexcept>
struct Log {
    std::array<int, 8> values{};
    std::size_t size = 0;
    void add(int value) noexcept { values[size++] = value; }
};
struct Member {
    Log& log;
    int id;
    Member(Log& target, int value) : log(target), id(value) { log.add(id); }
    ~Member() noexcept { log.add(-id); }
};
struct Bundle {
    Member first;
    Member second;
    Bundle(Log& log, bool fail) : first(log, 1), second(log, 2) {
        if (fail) throw std::runtime_error("construction stopped");
    }
    ~Bundle() noexcept { first.log.add(9); }
};
int main() {
    Log failed;
    bool caught = false;
    try { Bundle b(failed, true); }
    catch (const std::runtime_error&) { caught = true; }
    assert(caught && failed.size == 4);
    assert((failed.values == std::array<int, 8>{{1, 2, -2, -1, 0, 0, 0, 0}}));
    Log success;
    { Bundle b(success, false); }
    assert(success.size == 5);
    assert((success.values == std::array<int, 8>{{1, 2, 9, -2, -1, 0, 0, 0}}));
}

在本地编译

g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread books-cpp-standard.cpp -o example && ./example

预期结果

预期:正常退出、无输出;所有 assert 通过。

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

如果第二个成员在自己的构造体中抛异常,哪个析构会被调用?

查看参考答案

第一个成员已经完成构造,所以会析构;第二个成员自身未完成构造,所以不调用第二个成员的析构;外层 Bundle 也未完成。若仍在第二个构造体开始时记下 2,再抛出,日志为 1、2、-1;这里的 2 只表示进入构造体,不再表示完成构造,因此应同步修改日志事件的命名。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录