我们首先回顾一下错误处理的概念,特别是如何在 C++ 中编写异常安全的代码。资源获取即初始化(RAII)惯用法是 C++ 中处理错误的主要方法。已经为此专门用了一整章进行讲解,各位读者你也需要理解这一概念,才能明白我们接下来要做的事情。让先认清所面对的问题。
在本章的其余部分,将考虑以下问题:假设正在实现一个记录数据库。这些记录存储在磁盘上,但同时还有一个内存中的索引,用于快速访问这些记录。数据库 API 提供了一个将记录插入数据库的方法:
class Record { ... };
class Database {
public:
void insert(const Record& r);
...
};
如果插入成功,索引和磁盘存储都会被更新,并且彼此保持一致。如果出现问题,则会抛出异常。
尽管对数据库的使用端而言,插入操作看似是一个单一的事务,但其内部实现必须处理这样一个事实:该操作分多个步骤完成 —— 需要将记录插入索引并将其写入磁盘。为此,数据库包含两个类,各自负责一种类型的存储:
class Database {
class Storage { ... }; // 磁盘存储 Storage S;
class Index { ... }; // 内存索引 Index I;
public:
void insert(const Record& r);
...
};
insert() 函数的实现必须将记录同时插入存储和索引:
//Example 01
void Database::insert(const Record& r) {
S.insert(r);
I.insert(r);
}
不幸的是,上述任一操作都可能失败。我们先来看看如果存储插入失败会发生什么。
假设程序中所有故障都通过抛出异常来表示。如果存储插入失败,存储内容保持不变,索引插入根本不会尝试执行,异常会从 Database::insert() 函数向外传播。这正是我们所期望的结果 —— 插入失败,数据库未改变,并抛出了异常。
那么,如果存储插入成功但索引插入失败会发生什么?这次情况就不妙了 —— 磁盘数据已成功修改,随后索引插入失败,异常会向上传播给 Database::insert() 的调用者以表明插入失败。但这次插入并非完全失败,也并非完全成功。
数据库最终处于不一致的状态:磁盘上存在一条记录,却无法通过索引访问。这就是未能正确处理错误情况,属于异常不安全的代码,也不可接受。
冲动地尝试改变子操作的顺序也无济于事:
void Database::insert(const Record& r) {
I.insert(r);
S.insert(r);
}
当然,现在如果索引插入失败,一切都没问题了。但如果存储插入抛出异常,又遇到了同样的问题 —— 此时索引中会有一个条目,指向一个根本不存在的位置,记录从未写入磁盘。
显然,不能简单地忽略 Index 或 Storage 抛出的异常;必须以某种方式处理它们,以保持数据库的一致性。我们知道如何处理异常,这正是 try-catch 块的用途:
// Example 02
void Database::insert(const Record& r) {
S.insert(r);
try {
I.insert(r);
} catch (...) {
S.undo();
throw; // Rethrow
}
}
再次说明,如果存储操作失败,无需进行特殊处理。如果索引插入失败,必须撤销对存储的最后操作(假设存储类提供了相应的API来实现)。现在,数据库再次保持一致,就像插入操作从未发生过一样。尽管捕获了索引抛出的异常,但仍需要向调用者表明插入操作失败了,因此重新抛出该异常。到目前为止,一切顺利。
如果选择使用错误码而非异常,情况并无太大不同。来考虑一种变体:所有 insert() 函数在成功时返回 true,失败时返回 false:
bool Database::insert(const Record& r) {
if (!S.insert(r)) return false;
if (!I.insert(r)) {
S.undo();
return false;
}
return true;
}
必须检查每个函数的返回值,如果第二个操作失败,则撤销第一个操作,只有当两个操作都成功时才返回 true。
目前为止,我们成功解决了最简单的两阶段问题,使代码具备了错误安全性。现在,是时候增加复杂度了。假设存储系统需要在事务结束时执行一些清理工作,在调用 Storage::finalize() 方法之前,插入的记录并未处于最终状态(也许这样做是为了让 Storage::undo() 能够正常工作,而当插入操作最终确定,就无法再撤销了)。
请注意 undo() 和 finalize() 之间的区别:前者仅在我们想要回滚事务时才调用,而后者只要存储插入成功,无论后续发生什么,都必须调用。
通过以下控制流程来满足这些需求:
// Example 02a:
void Database::insert(const Record& r) {
S.insert(r);
try {
I.insert(r);
} catch (...) {
S.undo();
S.finalize();
throw;
}
S.finalize();
}
或者,在返回错误码的情况下也有类似处理(本章其余部分,将所有示例都使用异常,但转换为错误码并不困难)。
代码已经变得相当难看,尤其是确保清理代码(本例中是 S.finalize())在每条执行路径中都能运行的部分。如果有一系列更复杂的操作,且除非整个操作成功,否则所有操作都必须撤销,情况只会变得更糟。以下是包含三个操作、每个操作都有自己的回滚和清理的控制流程:
if (action1() == SUCCESS) {
if (action2() == SUCCESS) {
if (action3() == FAIL) {
rollback2();
rollback1();
}
cleanup2();
} else {
rollback1();
}
cleanup1();
}
明显的问题在于需要显式地检查成功状态,无论是通过条件判断还是通过 try-catch 块。更严重的问题是,这种错误处理方式不具备可组合性。N+1 个操作的解决方案并不是在 N 个操作的代码基础上简单地添加一些代码;相反,必须深入代码内部,在合适的位置添加相应的处理逻辑,但我们已经见过一种 C++ 惯用法可以解决这个问题。
RAII 惯用法将资源与对象绑定。当获取资源时构造对象,当对象销毁时释放资源。我们只关心后半部分,即对象的销毁。RAII 惯用法的优点在于,当控制流到达作用域末尾时,无论以何种方式(return、throw、break 等),所有局部对象的析构函数都必须调用。由于在清理工作上遇到了困难,将其交给一个 RAII 对象来处理:
// Example 02b:
class StorageFinalizer {
public:
StorageFinalizer(Storage& S) : S_(S) {}
~StorageFinalizer() { S_.finalize(); }
private:
Storage& S_;
};
void Database::insert(const Record& r) {
S.insert(r);
StorageFinalizer SF(S);
try {
I.insert(r);
} catch (...) {
S.undo();
throw;
}
};
StorageFinalizer 对象在构造时绑定到 Storage 对象,并在其销毁时调用 finalize()。由于当进入 StorageFinalizer 对象的作用域,就无法在不调用其析构函数的情况下退出该作用域,因此不再需要担心控制流问题,至少清理工作会自动执行。只有在存储插入成功后,StorageFinalizer 才会正确构造;如果第一次插入失败,则无需进行清理。
这段代码可以正常工作,但看起来有些半成品的感觉:函数结束时需要执行两个操作,第一个操作(清理或 finalize())已经实现了自动化,而第二个操作(回滚或 undo())却没有。此外,这种技术仍然不具备可组合性。以下是三个操作的控制流程:
class Cleanup1() {
~Cleanup1() { cleanup1(); }
...
};
class Cleanup2() {
~Cleanup2() { cleanup2(); }
...
};
action1();
Cleanup1 c1;
try {
action2();
Cleanup2 c2;
try {
action3();
} catch (...) {
rollback2();
throw;
}
} catch (...) {
rollback1();
}
同样,要添加另一个操作,必须在代码深处添加一个 try-catch 块。另一方面,清理部分本身是完全可组合的。考虑一下,如果不需要执行回滚操作,前面的代码会是什么样子:
action1();
Cleanup1 c1;
action2();
Cleanup2 c2;
我们不能简单地将对 undo() 的调用移到另一个对象的析构函数中;析构函数总是会被调用,而回滚操作仅在发生错误时才需要执行,但可以通过让析构函数有条件地调用回滚来实现:
// Example 03:
class StorageGuard {
public:
StorageGuard(Storage& S) : S_(S) {}
~StorageGuard() {
if (!commit_) S_.undo();
}
void commit() noexcept { commit_ = true; }
private:
Storage& S_;
bool commit_ = false;
};
void Database::insert(const Record& r) {
S.insert(r);
StorageFinalizer SF(S);
StorageGuard SG(S);
I.insert(r);
SG.commit();
}
现在来看这段代码:如果存储插入失败,会抛出异常,数据库保持不变。如果插入成功,则会构造两个 RAII 对象。第一个对象会在作用域结束时无条件地调用 S.finalize()。第二个对象会调用 S.undo(),除非通过调用 StorageGuard 对象的 commit() 方法来提交更改。这种情况只有在索引插入失败时才不会发生,此时会抛出异常,作用域内剩余的代码被跳过,控制流直接跳转到作用域末尾(即右花括号 }),在那里调用所有局部对象的析构函数。由于在此场景中我们从未调用 commit(),StorageGuard 仍然处于活动状态,将会撤销插入操作。另外这里完全没有显式的 try-catch 块:原先在 catch 子句中的操作现在由析构函数完成。当然,异常最终还是应该捕获(本章所有示例中,异常都在 main() 函数中捕获)。
局部对象的析构函数按与构造顺序相反的顺序调用。很重要;如果需要撤销插入操作,只能在操作最终确定之前进行,回滚操作必须在清理操作之前发生。因此,需要以正确的顺序构造 RAII 对象:首先构造用于清理的对象(最后执行),然后构造用于回滚的保护对象(必要时最先执行)。
现在的代码看起来非常简洁,完全没有 try-catch 块,看起来不像常规的 C++ 代码。这种编程风格被称为声明式编程;它是一种编程范式,程序逻辑的表达无需显式说明控制流(与之相对的是更常见的命令式编程,即程序描述要执行的步骤及其顺序,但不一定说明原因)。存在声明式编程语言(典型的例子是 SQL),但 C++ 并不属于此类。尽管如此,C++ 非常擅长实现能够构建更高层次语言的构造,因此这里实现了一种声明式的错误处理语言。程序现在表明,在将记录插入存储后,有两个待执行的操作 —— 清理和回滚。
如果整个函数成功执行,回滚操作将被解除。代码看起来是线性的,没有显式的控制流,换句话说,是声明式的。
尽管如此,着还远非完美,必须为程序中的每个操作编写一个保护类或最终化类。不太明显的问题是,正确编写这些类并不容易,而目前为止做得并不够好。请花点时间思考一下这里缺少了什么,然后再看下面的修正版本:
class StorageGuard {
public:
StorageGuard(Storage& S) : S_(S), commit_(false) {}
~StorageGuard() { if (!commit_) S_.undo(); }
void commit() noexcept { commit_ = true; }
private:
Storage& S_;
bool commit_;
// 重要:如果此守护对象被复制,将会发生非常糟糕的事情!
StorageGuard(const StorageGuard&) = delete;
StorageGuard& operator=(const StorageGuard&) = delete;
};
void Database::insert(const Record& r) {
S.insert(r);
StorageFinalizer SF(S);
StorageGuard SG(S);
I.insert(r);
SG.commit();
}
我们需要一个通用的框架,允许在作用域结束时无条件或有条件地执行任意操作。下一节将介绍提供这种框架的模式 —— ScopeGuard。