Generic<Programming>:简化异常安全代码

来源:互联网 发布:.net文件管理系统源码 编辑:程序博客网 时间:2024/06/08 04:02
发信人: meteors (刘欣), 信区: C 
标  题: Generic<Programming>:简化异常安全代码 
发信站: BBS 碧海青天站 (Mon Jan  3 13:24:55 2005), 转信 
  
  
编者按:这是Generic<Programming>系列中又一篇极好的文章。资源管理一直是程序设计中挥之不去的阴影,而其中最常见的就是资源泄露问题。以Java为代表的语言引入了垃圾收集(Garbage Collection)的机制,然而也只能勉强算解决内存泄露的一个方向,而更广义的资源同步问题,又该如何是好呢?有没有更好的解决方案呢?这,就是本文的话题。感谢Andrei Alexandrescu先生授权,本文原文请见http://www.cuj.com/experts/1812/alexandr.htm?topic=experts,有兴趣的朋友不妨参考一下。 
  
尽管有点自卖自夸,我还是要在一开始就告诉你,这篇文章里有精彩内容。因为我说服我的好朋友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) 

     // Add the new friend to the database 
     pDB_->AddFriend(GetName(), newFriend.GetName()); 
     // Add the new friend to the vector of friends 
     friends_.push_back(&newFriend); 

  
令人吃惊的是,只有两行的User::AddFriend里隐藏了一个致命的错误。在内存用尽的情况下,vector::push_back会通过抛出异常来表示操作失败。在那种情况下,你最终只把好友加到数据库里去,但是没有加到内存信息里。  
现在我们遇到了问题,是吗?在任何情况下,缺少信息一致性是危险的。很可能在你的应用程序里的很多地方都假设数据库里的信息和内存里的是同步的。  
  
一个简单的解决方法是考虑交换两行代码的顺序:  
  
void User::AddFriend(User& newFriend) 

     // Add the new friend to the vector of friends 
     // If this throws, the friend is not added to 
     //     the vector, nor the database 
     friends_.push_back(&newFriend); 
     // Add the new friend to the database 
     pDB_->AddFriend(GetName(), newFriend.GetName()); 

  
这确实能在vector::push_back失败的情况下保护数据一致性。不幸的是,当你查看UserDatabase::AddFriend的文档,你发现它也会抛出异常。现在你会把好友加到vector里,但没有加到数据库里。  
这时候你会质问做数据库的人们:“为什么你们不返回出错代码,而要抛出异常呢?”他们会说:“我们使用的是一个运行在TZN网络上的高可靠性的集群数据库服务器,极少出错。因此,我们认为应该用异常来表示出错是最好的,因为异常只出现在异常的情况下,不是吗?”  
  
这个理由讲得通,但是你还是要处理出错情况。你不会希望因为数据库出错而导致整个系统一片混乱。这样你修复数据库时,不必关闭整个服务器程序。  
  
本质上,你必须做两个操作,它们中的任何一个都可能失败。当其中一个失败时,你必须撤销全部操作。让我们来看看怎么做这件事。  
  
方法1:粗鲁的方法 
一个简单的办法是在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不会被执行。如果UserDatabase::AddFriend失败,你捕获这个异常(不管什么类型),然后你调用vector::pop_back撤销push_back的操作,然后再次抛出同样类型的异常。  
这样的代码可以工作,代价是增加了代码量,并显得臃肿。本来两行的程序变成了六行。想象一下如果你的代码到处是这样的try-catch语句,那太可怕了,所以这个方法一点也不吸引人。  
  
而且,这个方法也不好扩展。假如你有三个操作要做,用这个方法写出来的代码将臃肿得多。你有两个差不多坏的写法可选:用嵌套的try语句,或者用使流程更复杂的附加标志。这个方法会导致很多问题,如代码膨胀、效率下降,以及最重要的可理解性和可维护性降低。  
  
方法2:原则(politically)上正确的方法 
如果你把上面的代码给任何一个C++专家看,它很可能会告诉你:“啊,那方法不好。你应该使用惯用法RAII(Resource Acquisition Is Initialization,资源分配即初始化)[1],在出错的情况下,依靠析构函数来释放资源。”  
  
OK,让我们沿着这条路走下去。对于每一个你需要撤销的操作,都需要有一个对应的类,这个类的构造函数“做”这个操作,而析构函数“撤销”这个操作,除非你调用了一个“提交”函数,那样的话析构函数就什么也不做。  
  
用一些代码可以把这一切说清楚。对于push_back操作,我们写一个VectorInserter类,就像这样:  
  
class VectorInserter 

public: 
     VectorInserter(std::vector<User*>& v, User& u) 
     : container_(v), user_(u), commit_(false) 
     { 
         container_.push_back(&u); 
     } 
     void Commit() throw() 
     { 
         commit_ = true; 
     } 
     ~VectorInserter() 
     { 
         if (!commit_) container_.pop_back(); 
     } 
private: 
     std::vector<User*>& container_; 
     User& user_; 
     bool commit_; 
}; 
  
大概上面代码里最重要的东西就是Commit后面的throw()。它说明了一个事实,就是Commit调用永远成功(不会抛出异常)。因为你已经完成你的工作——Commit只是告诉VectorInserter:“一切顺利,不用恢复任何东西。”  
你可以像这样使用整个机制:  
  
void User::AddFriend(User& newFriend) 

     VectorInserter ins(friends_, &newFriend); 
     pDB_->AddFriend(GetName(), newFriend.GetName()); 
     // Everything went fine, commit the vector insertion 
     ins.Commit(); 

  
AddFriend现在有两个不同的部分:动作阶段——完成操作;提交阶段——不会抛出异常,只是停止所有的撤销工作。  
AddFriend的工作方式很简单:如果任何一个操作失败,那么就不能到达提交点,全部操作都会被取消。VectorInserter会把加入的数据pop_back掉,所以程序会保持在调用AddFriend以前的状态。  
  
这个方法在所有情况下都工作得很好。比如,当vector插入失败时,ins的析构函数不会被调用,因为ins没有被成功构造出来。  
  
这个方法很好,但是在现实世界里,做不到那么简洁。你必须编写一大堆很小的类来支持这个方法。额外的类意味着要写额外的代码,多费脑筋,以及在你的class browser里会有更多的条目。而且,你会有更多的地方需要处理异常安全问题。仅仅为了在析构函数里撤销一个操作而不断增加新的类,从生产率来看,这不是最聪明的办法。  
  
哦,还有,VectorInserter里有一个bug,你注意到了吗?编译器为你隐式生成的拷贝构造函数会导致错误:如果被复制的对象还没有提交过,那么在以后的析构函数里,就可能做过多的pop_back操作。定义一个类是很困难的,这是我们要避免写很多类的另一个理由。  
  
方法3:现实中的方法 
在现实世界里,当程序员坐下来编写AddFriend时,要么他看过上面的几个选择,要么他没有时间去关心这些。一天过去后,你知道真实的结果通常是什么吗?你当然知道:  
  
void User::AddFriend(User& newFriend) 

     friends_.push_back(&newFriend); 
     pDB_->AddFriend(GetName(), newFriend.GetName()); 

  
这是个基于“not-too=scientific”论调的解决方法:  
译者注:我觉得作者的观点是,现实中的程序员会排斥为了“不太”会出现的错误而采用“太”复杂难用的技术或者编程规范,从而在改善程序的安全性和保证开发进度的权衡中选择进度,并认为这是“科学”的做法。其实这两者并不是“鱼与熊掌,不可得兼”的东西,这是作者在这一系列文章中介绍GP可重用类模板技术的目的。  
  
“谁说过内存会用完?这机器有半G内存呢!”  
  
“程序会因为没有内存而崩溃?要那样的话,内存交换早就让系统慢得像蜗牛了。”  
  
“做数据库的那帮家伙说AddFriend几乎不可能失败。他们用的是XYZ和TZN!”  
  
“这太麻烦了,不值得。以后review的时候再考虑它吧。”  
  
一个方法如果需要很多约束规则和受到抱怨的工作,那么它就不具有吸引力。在进度的压力下,一个好的但是笨拙的方法会变得不实用。虽然每个人都知道应该按照书本上说的那样去做,但他们始终都喜欢走捷径。唯一的方法是提供一个可重用的解决方案,正确而容易使用。  
  
当你走捷径时,因为你知道你的代码不完美,你会怀着某种不愉快的心情check in(译者注:我觉得这里是代码管理的check in,在一个项目的coding阶段,一个模块完成的标志就是把代码check in到全体代码中去。我觉得原文的意思是,虽然coding做完了,也可以通过测试,符合check in的标准,迫于进度,你可以也必须check in代码,但是因为你知道还有隐患,所以有不完美的感觉)你的代码。但是这种心情会逐渐消失,因为所有的测试都可以通过。但是随着时间推移,那些“理论上”会引起问题的地方,还是会开始从现实中冒出来。  
  
你知道你遇到了问题,而且是个大问题:你放弃了对应用程序正确性的控制。现在,当服务器程序崩溃时,你没有很多线索去找错:硬件故障?真正的bug?还是因异常而引起的混乱状态?你遇到的不是无心的bug,而是你自己故意引入的。  
  
即使在一段时间内程序可以工作,但是事情总是会变化的。用户数目会增加,导致内存使用到达极限。你的网络管理员可能为了保证性能而禁止内存分页系统。你的数据库可能不是那么可靠。你对这一切都没有准备。  
  
方法4: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,除非你调用了Dismiss。如果你调用了,那么guard就什么也不做。  
ScopeGuard在它的析构函数里实现自动调用某个全局函数或者成员函数。在有异常的情况下,你会想要实现自动撤销原子操作的功能,这时候ScopeGuard会很有用。  
  
你可以这样使用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,你可以简单的安置各种撤销操作,而不再需要写特别的类来做诸如删除vector的最后一个元素、释放内存、关闭文件这样的事情。这使ScopeGuard成为编写异常安全代码的一个极其有用、并且可重用的解决方案,它使一切变得很简单。  
  
实现ScopeGuard 
ScopeGuard是对C++惯用法RAII(资源分配即初始化)典型实现的一个推广。它们的区别在于ScopeGuard只关注资源清理的那部分——资源分配由你自己做,而ScopeGuard处理资源的释放(事实上,可以论证清理工作是这个谚语里最重要的部分)。  
  
释放资源有很多种形式,比如调用一个函数、调用一个functor、或者调用一个对象的成员函数,而每种方式都可能有零个、一个或者更多的参数。  
  
自然,我们通过一个类层次关系来对这些变体建模。层次中各个类的对象的析构函数完成实际工作。层次中的根为ScopeGuardImplBase类,如下:  
  
class ScopeGuardImplBase 

public: 
     void Dismiss() const throw() 
     {    dismissed_ = true;    } 
protected: 
     ScopeGuardImplBase() : dismissed_(false) 
     {} 
     ScopeGuardImplBase(const ScopeGuardImplBase& other) 
     : dismissed_(other.dismissed_) 
     {    other.Dismiss();    } 
     ~ScopeGuardImplBase() {} // nonvirtual (see below why) 
     mutable bool dismissed_; 
private: 
     // Disable assignment 
     ScopeGuardImplBase& operator=( 
         const ScopeGuardImplBase&); 
}; 
  
ScopeGuardImplBase集中了对dismissed_标志的管理,这个标志控制派生类是否要执行清理工作。如果dismissed_为真,则派生类在他们的析构函数里什么也不做。  
现在我们来看看ScopeGuardImplBase析构函数定义里缺少的virtual。如果析构函数不是virtual的,你怎么可以期望析构函数有正确的多态行为呢?好,把你的好奇心再保持一会儿,我们手里还有张王牌,我们可以通过它得到多态的析构行为,而不必付出虚函数的代价。  
  
现在我们先来看看怎么实现这样一个对象,它在析构函数里调用一个带一个参数的函数或者functor。然而当你调用了Dismiss,那么这个函数或者functor就不会被调用。  
  
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++标准,如果const的引用被初始化为对一个临时变量的引用,那么它会使这个临时变量的生命期变得和它自己一样。让我们举个例子来解释这件事。如果你写:  
FILE* topSecret = std::fopen("cia.txt"); 
ScopeGuard closeIt = MakeGuard(std::fclose, topSecret); 
  
那么MakeGuard创建了一个临时变量,它的类型为(看以前做一下深呼吸):  
ScopeGuardImpl1<int (&)(FILE*), FILE*> 
  
这是因为std::fclose是接受FILE*类型参数返回int的函数。具有上面那个类型的临时变量被指派给了const引用closeIt。根据上面提到的C++语言规则,这个临时变量会和它的引用closeIt有同样长的生存期——当这个临时变量被析构时,会调用正确的析构函数。接着,析构函数关闭文件。  
ScopeGuardImpl1支持有带参数的函数(或functor)。很容易就可以写出不带参数、带两个参数或带更多参数的类(ScopeGuardImpl0、ScopeGuardImpl2……)。当你有了这些类,你就可以重载MakeGuard,从而得到一个优美、统一的语法:  
  
template <typename Fun> 
ScopeGuardImpl0<Fun> 
MakeGuard(const Fun& fun) 

     return ScopeGuardImpl0<Fun >(fun); 

... 
  
到现在为止,我们已经有了一个强大的工具来表达调用一组函数的原子操作。MakeGuard是一个优秀的工具,特别是它同样可以用于C语言的API,而� 
原创粉丝点击