84 / 103 · C++11 · 约 14 分钟
构造失败:完整对象没析构,成员仍会清理
构造函数抛异常时,未完成的完整对象不会调用其析构函数,但已构造完成的成员会按逆序销毁。用无分配的事件日志追踪构造、抛出与清理,区分对象生命周期、成员生命周期和分配到的存储。
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 只表示进入构造体,不再表示完成构造,因此应同步修改日志事件的命名。
继续查证
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。