By Andrei Alexandrescu and Petru Marginean, December 01, 2000 https://www.drdobbs.com/cpp/generic-change-the-way-you-write-excepti/184403758 |
面对现实:编写异常安全的代码是困难的。但是有了这个惊人的模板就得简单多了
你可能认为这篇文章有些过度营销,但我们有这篇文章的杀手级素材。我说服了我的好朋友Petru Marginean成为我的合作者。Petru开发了一个库工具来帮助处理异常。我们一起简化了实现,获得了一个精简的、有意义的库,它可以使编写异常安全的代码变得更加容易。
事实上,在存在异常的情况下编写正确的代码不是一件容易的任务。异常建立一个单独的控制流,它与应用程序的主控制流几乎没有关系。处理异常流需要一种不同的思维方式,以及新的工具。
例子:编写异常安全的代码是困难的
假设您正在开发一种流行的即时消息服务器。用户可以登录和登出系统,并可以互相发送消息。您有一个用户服务器端数据库用于保存用户信息和用户的好友列表,当用户登录到服务器时将他的信息及其好友列表调入内存。
当用户删除或添加好友时你需要做两件事:更新数据库并更新内存缓存。这很简单!
假设你使用 User
类保存用户信息,用 UserDataBase
类建立与用户数据库的连接,那么上述代码可能像这样:
class User{
// 其他代码。。。
string getName();
void AddFriend(User& newFriend);
private:
typedef vector<User*> UserCont;
UserCont friends_;
UserDatabase* pDB_;
};
void User::AddFriend(User& newFriend){
// 添加新好友到数据库
pDB_->AddFriend(GetName(), newFriend.GetName());
// 添加新好友到内存
friends_.push_back(&newFriend);
}
但是,User::AddFriend
中的这两行代码存在一个致命的bug:在内存不足的情况下, vector::push_back
将运行失败并抛出异常。最终的结果是好友添加到数据库中,但是并未添加到内存中。
那么问题来了:不管在什么情况下数据库和内存中数据的不一致都是一个及其严重的问题,你的应用程序的很多部分可能都是基于内存数据和数据库数据相同的假设。
解决这个问题的一个简单方法是交换这两行代码的位置:
void User::AddFriend(User& newFriend){
// 将好友添加到内存,若失败则抛出异常
friends_.push_back(&newFriend);
// 添加好友到数据库
pDB_->AddFriend(GetName(), newFriend.GetName());
}
看起来内存中的数据和数据库中的数据已经保持了一致。可是当你查阅 UserDatabase::AddFriend
的文档时,发现它也会抛出异常,这下子变成了内存中的数据比数据库中的数据多。
是时候对数据库团队发出灵魂拷问了:“为什么要抛出异常而不是返回错误代码?”。数据库团队可能的回答时:“我们在高度可靠的张三网络中使用了高度可靠的李四牌数据库,数据库失败的情况及其罕见,完全没法预知,所以我们认为抛出异常更好”
理由很充分,但是你依然需要定位失败的原因。很明显你并不希望由于数据库失败而导致整个服务器系统混乱,结果最后不得不使用重启这一强大的武器解决服务器的问题。
本质上,你只需要做两种操作:两者任何一个失败你都需要回滚更改来保证内存和数据库数据的一致性。让我们看看怎么做:
方案一:暴力解决方案
最简单的一个方法是将代码放到一个 try-catch
代码块中:
void User::AddFriend(User& newFriend){
friends_.push_back(&newFriend);
try{
pDB_->AddFriend(GetName(), newFriend.GetName());
}
catch (...){
friends_.pop_back();
throw;
}
}
这次无论是 vector::push_back
抛出异常还是 UserDataBase::AddFriend
抛出异常你都可以高枕无忧啦。最漂亮的时你最后抛出了一个相同的异常。
那么问题来了,这种方案的代价是增加了代码的臃肿,两行代码变成了七行代码。想象一下,也许你的代码中到处都是 try-cache
。
此外,这种方式的伸缩性也非常差:当你有三个或更多操作时怎么办?给 try
来次套娃还是使用逻辑更加复杂的方案?这不仅带来了代码臃肿和运行速度下降,更重要的时可维护性也更差了。
方案二:学术上正确的方案
将方案一拿给任何C++专家估计他都会说:“呐,这种方法很不好,你应当遵循 资源获取即初始化 的约定,并在失败时使用析构函数释放资源”
OK,让我们用上面的约定:对于每个可以撤销的操作都匹配一个类,这个类的构造函数执行这个操作,当失败时在析构函数中撤销操作(除非使用了 commit
方法,此时析构函数不会撤销更改)
有点代码讲什么都清楚,以 push_back
方法为例,则代码可能如下:
class VectorInserter{
public:
VectorInserter(vector<User*>&v, User&u)
: container_(v), commit_(false){
container_.push_back(&u);
}
void Commit() throw() {
isCommited = true;
}
~VectorInserter(){
if(!commit_) container_.pop_back();
}
private:
vector<User*>& container_;
bool commit_;
};
也许上面的代码中最重要的就是 Commit
旁边的那个 throw()
了,它告诉你 Commit
总是成功的,毕竟 Commit
唯一的作用就是告诉析构函数:所有的操作都成功了,不需要撤销更改。
你可能需要这样使用它:
void User::AddFriend(User& newFriend){
VectorInserter ins(friends_, &newFriend);
pDB_->AddFriend(GetName(), newFriend.GetName());
// 所有的操作都成功了,commit这个操作
ins.Commit();
}
现在 AddFriend
操作由两部分进行限制:操作语句和commit语句。当任何操作失败是都会导致 Commit
语句无法到达,从而在析构函数中撤销更改。这样数据依然和进入 AddFriend
之前相同。
而且当数据插入失败后析构函数也没有被调用,数据也不会由于 pop_back
变少。(既然对象没有构造成功又何必析构呢?)
这种方法工作得很好,但实际上它并没有那么好。您必须编写一堆小类来支持这个用法。额外的类意味着需要编写额外的代码、脑力开销、查看类时需要多看很多类。此外,还有很多地方必须处理异常安全。仅仅为了撤销操作而不时地添加一个新类并不是最有效的。
而且 VectorInserter
还有一个潜在的bug,VectorInserter
的复制构造函数做了很糟糕的事。定义一个类总是很困难的,这是避免这个方案的另一个原因。
方案三:最好的方案
要么你已经阅读了上面的方案,要么你没有时间或不关心它们。你知道真正的方法是什么吗?这里给出了正确的方案:
void User::AddFriend(User& newFriend){
friends_.push_back(&newFriend);
pDB_->AddFriend(GetName(), newFriend.GetName());
}
这是一个并不那么科学的方案。因为有很多人可能要反驳前述观点:
“谁说内存即将用尽?内存插槽还有一个呢!” “即使内存被用光了,分页系统也会保证程序获取到内存” “数据库团队都说了 AddFriend
不可能失败,他们可是使用的张三网络和李四数据库!” “这个不用担心,我们以后再考虑它”
需要大量训练和复杂代码的解决方案不是很有吸引力。在计划表的压力下,一个好的但笨拙的解决方案几乎没有什么用。每个人都知道事情最好按章办事,但总是需要走捷径。一个真正的方法是提供正确且易于使用的可重用解决方案。
当您提交代码,有一种不愉快而且不完美的感觉。随着所有测试的顺利运行,也许这种感觉会逐渐消失,但随着时间的推移和日程压力的增加,“理论上”会导致问题的地方就会突然出现。
现在最大的问题是:你已经丧失了对软件的控制力。现在,当服务器崩溃时,你不知道从哪里开始:是硬件故障、软件bug还是其他原因导致的故障?你不仅无意识中写出了bug,而且还故意引入了一些bug。
实际情况总是变幻无常的:随着用户的数量的增加,内存的压力可能会达到极限;网络管理员可能会为了性能而禁用分页;你的数据库可能不是那么可靠。而你对这些却毫无准备。
方案四:Petru的方法
使用 ScopeGuard
你可以更方便地写出简单、正确、有效的代码:
void User::AddFriend(User& newFriend){
friends_.push_back(&newFriend);
ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back);
pDB_->AddFriend(GetName(), newFriend.GetName());
guard.Dismiss();
}
guard
的唯一工作是当退出其作用域时调用 friends_.pop_back
,除非你 Dissmiss
了,此种情况下 guard
不会做任何工作。 ScopeGuard
在被析构时会自动调用指定的函数。当你希望在存在异常的情况下实现原子操作的自动撤销时,它可能很有帮助。
当你需要“一系列操作要么全做要么全不做”的情况下 ScopeGuard
非常有用。在每一个操作后面放置一个 ScopeGuard
对象,当其析构时会自动撤销操作。例如:
friends_.push_back(&newFriend);
ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back);
ScopeGuard也适用于普通函数:
void* buffer = std::malloc(1024);
ScopeGuard freeIt = MakeGuard(std::free, buffer);
FILE* topSecret = std::fopen("cia.txt");
ScopeGuard closeIt = MakeGuard(std::fclose, topSecret);
如果所有的原子操作都成功了,就可以 Dismiss
所有的 guard
。否则,每个构造的ScopeGuard在析构时都会调用初始化它的函数。
通过 ScopeGuard
你可以轻松地撤销多个操作而不用写一堆方案二中1的小类。 ScopeGuard
是一个使用的、可重用的、可以解决异常安全的方式,而且还很简单。
实现ScopeGuard
ScopeGuard是RAII的泛化,不同的是ScopeGuard只负责释放资源,而不负责获取资源。(事实上,释放资源可以说是RAII最重要的部分)
清理资源有不同的方法:比如调用一个函数、调用一个仿函数或调用一个对象的成员函数。上述的函数可能都需要0个、1个或多个参数。
自然地,我们构造了一个类层次,类层次中只有析构函数做实际工作,类层次中的基类如下:
class ScopeGuardImplBase
{
public:
void Dismiss() const throw(){
dismissed_ = true;
}
protected:
ScopeGuardImplBase() : dismissed_(false){}
ScopeGuardImplBase(const ScopeGuardImplBase& other)
: dismissed_(other.dismissed_){
other.Dismiss();
}
~ScopeGuardImplBase() {} // 非虚函数 (下面有原因)
mutable bool dismissed_;
private:
// 禁用赋值函数
ScopeGuardImplBase& operator=(const ScopeGuardImplBase&);
};
ScopeGuardImplBase
控制者 dismissed_
标志位,当 dismissed_
为 true时,派生类将会执行清理工作。
这里我们看到 ScopeGuardImplBase
的析构函数并不是虚函数,那么我们如何获取多态行为呢?下面看看我们如何在没有虚函数开销的情况下实现多态。
现在让我们看看如何在构造函数中调用一个函数或仿函数。当然,如果你 Dissmiss
了,那么这些函数不会被调用。
template <typename Fun, typename Parm>
class ScopeGuardImpl1 : public ScopeGuardImplBase{
public:
ScopeGuardImpl1(const Fun& fun, const Parm& parm)
: fun_(fun), parm_(parm)
{}
~ScopeGuardImpl1(){
if (!dismissed_) fun_(parm_);
}
private:
Fun fun_;
const Parm parm_;
};
让我们写一个辅助函数使 ScopeGuardImpl1
更简单:
template <typename Fun, typename Parm>
ScopeGuardImpl1<Fun, Parm>
MakeGuard(const Fun& fun, const Parm& parm){
return ScopeGuardImpl1<Fun, Parm>(fun, parm);
}
MakeGuard依赖于编译器自动推导模板参数的能力。通过这种方式,你不需要为 ScopeGuardImpl1
指定模板参数。实际上,通过标准库函数你不需要显式地创建 ScopeGuardImpl1
对象,比如 make_pair
和 bind1st
。
还在好奇如何在没有虚析构函数的情况下实现析构函数的多态行为吗?是时候编写 ScopeGuard
了,令人惊讶的是,它仅仅是一个 typedef
:
typedef const ScopeGuardImplBase& ScopeGuard;
现在,让我阐述一下其中的机制:根据C++标准,使用一个临时值初始化的引用会导致这个临时值的生命周期与这个引用的生命周期相同。
以一个例子解释:假设你写了:
FILE* topSecret = std::fopen("cia.txt");
ScopeGuard closeIt = MakeGuard(std::fclose, topSecret);
然后 MakeGuard
创建了一个临时变量:
ScopeGuardImpl1<int (&)(FILE*), FILE*>
之所以表达式是这样是因为 std::fclose
传入一个 FILE*
参数并返回一个 int
值。上述的临时值将会分配到类型为 const引用
的 closeIt
上,正如标准所说的那样,临时值的生命周期将和引用的生命周期相同。当对象被销毁时,会调用正确的析构函数,然后析构函数会关闭文件。 ScopeGuardImpl1
支持包含一个参数的函数(或仿函数)。当然,构造支持更多参数的 ScopeGuardImpl0
, ScopeGuardImpl2
等也很简单,然后你可以重载 MakeGuard
:
template <typename Fun>
ScopeGuardImpl0<Fun>
MakeGuard(const Fun& fun)
{
return ScopeGuardImpl0<Fun >(fun);
}
现在我们已经有了一个强大的工具用于自动调用函数。当涉及到没有编写包装类的C API时 MakeGuard
的优势将更加突出。
更好的是没有虚函数,保证了代码的效率。
将MakeGuard用于对象和成员函数
到目前为止一切都很好,但是涉及到调用对象的成员函数呢? 也并不难,现在让我们实现一个 ObjScopeGuardImpl0
: 一个可以调用类成员函数的模板。
template <class Obj, typename MemFun>
class ObjScopeGuardImpl0 : public ScopeGuardImplBase{
public:
ObjScopeGuardImpl0(Obj& obj, MemFun memFun)
: obj_(obj), memFun_(memFun)
{}
~ObjScopeGuardImpl0(){
if (!dismissed_) (obj_.*fun_)();
}
private:
Obj& obj_;
MemFun memFun_;
};
ObjScopeGuardImpl0有点奇怪,因为它使用了很少人了解的成员指针和 operator.*
。为了理解它是如何工作的,让我们来看看 MakeObjGuard
的实现:
template <class Obj, typename MemFun>
ObjScopeGuardImpl0<Obj, MemFun, Parm>
MakeObjGuard(Obj& obj, Fun fun){
return ObjScopeGuardImpl0<Obj, MemFun>(obj, fun);
}
现在,如果你可以调用:
ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back);
生成的表达式如下:
ObjScopeGuardImpl0<UserCont, void (UserCont::*)()>
幸运的是, MakeObjGuard
让你不需要写这些繁琐的表达式。类的机制和上述的相同,当 guard
退出作用域时将会调用析构函数,析构函数将通过成员指针调用类成员函数。
差错处理
如果你已经了解了Herb Sutter在异常上的工作,你应当知道析构不得抛出异常这一准则。一个会抛出异常的析构函数几乎不可能写出正确的代码,而且还可能在没有任何提醒的情况下导致程序崩溃。
ScopeGuardImplX
和 ObjScopeGuardImplX
的析构函数将会调用未知的函数,而这些函数也许可能抛出异常。理论上,你不应该向 MakeGuard
和 MakeObjGuard
传递任何可能抛出异常的函数。但是实际上。析构函数可以屏蔽任何抛出的异常:
template <class Obj, typename MemFun>
class ObjScopeGuardImpl0 : public ScopeGuardImplBase
{
public:
~ScopeGuardImpl1(){
if (!dismissed_)
try {
(obj_.*fun_)();
}
catch(...) {}
}
}
catch(…)块不执行任何操作。 这不是什么骚操作。 在存在异常的情况下,若您的“撤消/恢复”操作失败,则您将无能为力。 您尝试撤消操作,然后无论撤消操作是否成功代码都会继续执行。
假设有以下情况发生:你向数据库插入了一个好友,但是向内存中插入数据失败,因此你必须从数据库删除刚才插入的数据。但是从数据库中删除的操作也有可能失败,这将导致非常糟糕的情况。
通常,您应该在您确信能够成功撤销的操作上设置 guard
。
通过引入传入参数
使用 ScopeGuard
让我们开心了一会,但是,假设你遇到了这种情况:
void Decrement(int& x) { --x; }
void UseResource(int refCount){
++refCount;
ScopeGuard guard = MakeGuard(Decrement, refCount);
}
上面的 guard
对象确保在退出 UseResource
时保留 refCount
的值。(这在某些资源共享的情况下很有用)
尽管上面的代码很有用,但却不能工作。问题在于 ScopeGuard
存储了 refCount
的一个副本(参见ScopeGuardImpl1的定义,成员变量parm),而不是对它的引用。在这种情况下,我们需要存储refCount的引用,以便 Decrement
可以对它进行操作。
一种解决方案是实现额外的类,比如 ScopeGuardImplRef
和 MakeGuardRef
。这种繁琐的工作当您为多个参数实现类时,情况会变得很糟糕。
我们确定的解决方案使用一个辅助类,它将引用转换为值:
template <class T>
class RefHolder{
T& ref_;
public:
RefHolder(T& ref) : ref_(ref) {}
operator T& () const{
return ref_;
}
};
template <class T>
inline RefHolder<T> ByRef(T& t)
{
return RefHolder<T>(t);
}
RefHolder
及其辅助函数 ByRef
很巧妙地将值换成它的引用,并允许 ScopeGuardImpl1
在不进行任何修改的情况下使用引用。您所要做的就是将引用封装到对 ByRef
的调用中:
void Decrement(int& x) {
--x;
}
void UseResource(int refCount){
++refCount;
ScopeGuard guard = MakeGuard(Decrement, ByRef(refCount));
}
我们发现这个解决方案非常有表现力和启发性。
此部分最好的部分是引用支持 ScopeGuardImpl1
中的 const
修饰字。这里是上文的摘录:
template <typename Fun, typename Parm>
class ScopeGuardImpl1 : public ScopeGuardImplBase{
private:
Fun fun_;
const Parm parm_;
};
这个小小的 const
很重要。编译期和运行时使用 非const
引用。换句话说,如果您忘记将`ByRef` 与函数一起使用,将无法通过编译。
客官请留步,这里有更多技巧
现在你已经不必苦苦思索如何编写异常安全的代码了。但是有时候为这些 guard
起名字却很痛苦,你只是希望一个临时变量,它无需具有什么特殊意义的名字。
ON_BLOCK_EXIT
宏让你可以写出下面这种富有表现力的代码:
{
FILE* topSecret = fopen("cia.txt");
ON_BLOCK_EXIT(std::fclose, topSecret);
// ... use topSecret ...
} // topSecret自动关闭文件
ON_BLOCK_EXIT
将会在退出时自动执行代码块中的代码,类似地,你也可以写出一个 ON_BLOCK_EXIT_OBJ
。
由于这里使用了不受欢迎的宏,因此这里不再贴出实现代码,感兴趣的可以在实现代码中查看。
ScopeGuard的实际使用
也许ScopeGuard最酷的地方是它的易用性和概念上的简单性。 本文详细介绍了整个实现,但是解释ScopeGuard的用法仅需几分钟。 在我们的同事中,ScopeGuard像燎原之火般蔓延开来。 所有人都认为ScopeGuard是一种有价值的工具,可以在各种情况下提供帮助。 借助ScopeGuard,您最终可以轻松合理地编写异常安全代码,并同样容易地理解和维护它。
每个工具都有使用建议,ScopeGuard也不例外。您应该按照ScopeGuard的意图使用他:作为函数中的一个自动变量。您不应该将ScopeGuard对象作为成员变量,尝试将它们放入 vector
中,或者将它们分配到堆中。出于这些目的,附带的代码包含一个 Janitor
类,它的作用与 ScopeGuard
的作用完全相同,但是更加通用,这样做会降低一些效率。
结论
我们已经介绍了在编写异常安全代码时出现的问题。在讨论了几种实现异常安全的方法之后,我们介绍了一种通用的解决方案。ScopeGuard使用几种通用编程技术,允许您在ScopeGuard变量退出作用域时调用指定的函数。您也可以 Dissmiss
ScopeGuard对象。当您需要执行资源的自动清理时,ScopeGuard非常有用,尤其是当您希望将多个原子操作组装成一个操作时(每个原子操作都可能失败)
鸣谢
作者感谢Mihai Antonescu审阅了本文,并提出了有用的更正和建议。
引用
Bjarne Stroustrup. The C++ Programming Language, 3rd Edition (Addison Wesley, 1997), page 366.
Herb Sutter. Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions (Addison-Wesley, 2000).
Andrei Alexandrescu 是美国西雅图RealNetworks Inc.( www.realnetworks.com )公司的研发经理。在 www.moderncppdesign.com 可以联系到他
Petru Marginean是Plural的高级C++开发人员,可以通过 petrum@hotmail.com 联系到他。