通用:永远改变编写异常安全代码的方式 ######################################## .. epigraph:: 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`` 类建立与用户数据库的连接,那么上述代码可能像这样: .. code-block:: cpp class User{ // 其他代码。。。 string getName(); void AddFriend(User& newFriend); private: typedef vector 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`` 将运行失败并抛出异常。最终的结果是好友添加到数据库中,但是并未添加到内存中。 那么问题来了:不管在什么情况下数据库和内存中数据的不一致都是一个及其严重的问题,你的应用程序的很多部分可能都是基于内存数据和数据库数据相同的假设。 解决这个问题的一个简单方法是交换这两行代码的位置: .. code-block:: cpp void User::AddFriend(User& newFriend){ // 将好友添加到内存,若失败则抛出异常 friends_.push_back(&newFriend); // 添加好友到数据库 pDB_->AddFriend(GetName(), newFriend.GetName()); } 看起来内存中的数据和数据库中的数据已经保持了一致。可是当你查阅 ``UserDatabase::AddFriend`` 的文档时,发现它也会抛出异常,这下子变成了内存中的数据比数据库中的数据多。 是时候对数据库团队发出灵魂拷问了:“为什么要抛出异常而不是返回错误代码?”。数据库团队可能的回答时:“我们在高度可靠的张三网络中使用了高度可靠的李四牌数据库,数据库失败的情况及其罕见,完全没法预知,所以我们认为抛出异常更好” 理由很充分,但是你依然需要定位失败的原因。很明显你并不希望由于数据库失败而导致整个服务器系统混乱,结果最后不得不使用重启这一强大的武器解决服务器的问题。 本质上,你只需要做两种操作:两者任何一个失败你都需要回滚更改来保证内存和数据库数据的一致性。让我们看看怎么做: 方案一:暴力解决方案 **************************************** 最简单的一个方法是将代码放到一个 ``try-catch`` 代码块中: .. code-block:: cpp 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`` 方法为例,则代码可能如下: .. code-block:: cpp class VectorInserter{ public: VectorInserter(vector&v, User&u) : container_(v), commit_(false){ container_.push_back(&u); } void Commit() throw() { isCommited = true; } ~VectorInserter(){ if(!commit_) container_.pop_back(); } private: vector& container_; bool commit_; }; 也许上面的代码中最重要的就是 ``Commit`` 旁边的那个 ``throw()`` 了,它告诉你 ``Commit`` 总是成功的,毕竟 ``Commit`` 唯一的作用就是告诉析构函数:所有的操作都成功了,不需要撤销更改。 你可能需要这样使用它: .. code-block:: cpp 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`` 的复制构造函数做了很糟糕的事。定义一个类总是很困难的,这是避免这个方案的另一个原因。 方案三:最好的方案 **************************************** 要么你已经阅读了上面的方案,要么你没有时间或不关心它们。你知道真正的方法是什么吗?这里给出了正确的方案: .. code-block:: cpp void User::AddFriend(User& newFriend){ friends_.push_back(&newFriend); pDB_->AddFriend(GetName(), newFriend.GetName()); } 这是一个并不那么科学的方案。因为有很多人可能要反驳前述观点: “谁说内存即将用尽?内存插槽还有一个呢!” “即使内存被用光了,分页系统也会保证程序获取到内存” “数据库团队都说了 ``AddFriend`` 不可能失败,他们可是使用的张三网络和李四数据库!” “这个不用担心,我们以后再考虑它” 需要大量训练和复杂代码的解决方案不是很有吸引力。在计划表的压力下,一个好的但笨拙的解决方案几乎没有什么用。每个人都知道事情最好按章办事,但总是需要走捷径。一个真正的方法是提供正确且易于使用的可重用解决方案。 当您提交代码,有一种不愉快而且不完美的感觉。随着所有测试的顺利运行,也许这种感觉会逐渐消失,但随着时间的推移和日程压力的增加,“理论上”会导致问题的地方就会突然出现。 现在最大的问题是:你已经丧失了对软件的控制力。现在,当服务器崩溃时,你不知道从哪里开始:是硬件故障、软件bug还是其他原因导致的故障?你不仅无意识中写出了bug,而且还故意引入了一些bug。 实际情况总是变幻无常的:随着用户的数量的增加,内存的压力可能会达到极限;网络管理员可能会为了性能而禁用分页;你的数据库可能不是那么可靠。而你对这些却毫无准备。 方案四:Petru的方法 **************************************** 使用 ``ScopeGuard`` 你可以更方便地写出简单、正确、有效的代码: .. code-block:: cpp 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`` 对象,当其析构时会自动撤销操作。例如: .. code-block:: cpp friends_.push_back(&newFriend); ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back); ScopeGuard也适用于普通函数: .. code-block:: cpp 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个或多个参数。 自然地,我们构造了一个类层次,类层次中只有析构函数做实际工作,类层次中的基类如下: .. code-block:: cpp 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`` 了,那么这些函数不会被调用。 .. code-block:: cpp template 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`` 更简单: .. code-block:: cpp template ScopeGuardImpl1 MakeGuard(const Fun& fun, const Parm& parm){ return ScopeGuardImpl1(fun, parm); } MakeGuard依赖于编译器自动推导模板参数的能力。通过这种方式,你不需要为 ``ScopeGuardImpl1`` 指定模板参数。实际上,通过标准库函数你不需要显式地创建 ``ScopeGuardImpl1`` 对象,比如 ``make_pair`` 和 ``bind1st`` 。 还在好奇如何在没有虚析构函数的情况下实现析构函数的多态行为吗?是时候编写 ``ScopeGuard`` 了,令人惊讶的是,它仅仅是一个 ``typedef`` : .. code-block:: cpp typedef const ScopeGuardImplBase& ScopeGuard; 现在,让我阐述一下其中的机制:根据C++标准,使用一个临时值初始化的引用会导致这个临时值的生命周期与这个引用的生命周期相同。 以一个例子解释:假设你写了: .. code-block:: cpp FILE* topSecret = std::fopen("cia.txt"); ScopeGuard closeIt = MakeGuard(std::fclose, topSecret); 然后 ``MakeGuard`` 创建了一个临时变量: .. code-block:: cpp ScopeGuardImpl1 之所以表达式是这样是因为 ``std::fclose`` 传入一个 ``FILE*`` 参数并返回一个 ``int`` 值。上述的临时值将会分配到类型为 ``const引用`` 的 ``closeIt`` 上,正如标准所说的那样,临时值的生命周期将和引用的生命周期相同。当对象被销毁时,会调用正确的析构函数,然后析构函数会关闭文件。 ``ScopeGuardImpl1`` 支持包含一个参数的函数(或仿函数)。当然,构造支持更多参数的 ``ScopeGuardImpl0`` , ``ScopeGuardImpl2`` 等也很简单,然后你可以重载 ``MakeGuard`` : .. code-block:: cpp template ScopeGuardImpl0 MakeGuard(const Fun& fun) { return ScopeGuardImpl0(fun); } 现在我们已经有了一个强大的工具用于自动调用函数。当涉及到没有编写包装类的C API时 ``MakeGuard`` 的优势将更加突出。 更好的是没有虚函数,保证了代码的效率。 将MakeGuard用于对象和成员函数 ======================================== 到目前为止一切都很好,但是涉及到调用对象的成员函数呢? 也并不难,现在让我们实现一个 ``ObjScopeGuardImpl0`` : 一个可以调用类成员函数的模板。 .. code-block:: cpp template 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`` 的实现: .. code-block:: cpp template ObjScopeGuardImpl0 MakeObjGuard(Obj& obj, Fun fun){ return ObjScopeGuardImpl0(obj, fun); } 现在,如果你可以调用: .. code-block:: cpp ScopeGuard guard = MakeObjGuard(friends_, &UserCont::pop_back); 生成的表达式如下: .. code-block:: cpp ObjScopeGuardImpl0 幸运的是, ``MakeObjGuard`` 让你不需要写这些繁琐的表达式。类的机制和上述的相同,当 ``guard`` 退出作用域时将会调用析构函数,析构函数将通过成员指针调用类成员函数。 差错处理 ======================================== 如果你已经了解了Herb Sutter在异常上的工作,你应当知道析构不得抛出异常这一准则。一个会抛出异常的析构函数几乎不可能写出正确的代码,而且还可能在没有任何提醒的情况下导致程序崩溃。 ``ScopeGuardImplX`` 和 ``ObjScopeGuardImplX`` 的析构函数将会调用未知的函数,而这些函数也许可能抛出异常。理论上,你不应该向 ``MakeGuard`` 和 ``MakeObjGuard`` 传递任何可能抛出异常的函数。但是实际上。析构函数可以屏蔽任何抛出的异常: .. code-block:: cpp template class ObjScopeGuardImpl0 : public ScopeGuardImplBase { public: ~ScopeGuardImpl1(){ if (!dismissed_) try { (obj_.*fun_)(); } catch(...) {} } } catch(...)块不执行任何操作。 这不是什么骚操作。 在存在异常的情况下,若您的“撤消/恢复”操作失败,则您将无能为力。 您尝试撤消操作,然后无论撤消操作是否成功代码都会继续执行。 假设有以下情况发生:你向数据库插入了一个好友,但是向内存中插入数据失败,因此你必须从数据库删除刚才插入的数据。但是从数据库中删除的操作也有可能失败,这将导致非常糟糕的情况。 通常,您应该在您确信能够成功撤销的操作上设置 ``guard`` 。 通过引入传入参数 ======================================== 使用 ``ScopeGuard`` 让我们开心了一会,但是,假设你遇到了这种情况: .. code-block:: cpp 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`` 。这种繁琐的工作当您为多个参数实现类时,情况会变得很糟糕。 我们确定的解决方案使用一个辅助类,它将引用转换为值: .. code-block:: cpp template class RefHolder{ T& ref_; public: RefHolder(T& ref) : ref_(ref) {} operator T& () const{ return ref_; } }; template inline RefHolder ByRef(T& t) { return RefHolder(t); } ``RefHolder`` 及其辅助函数 ``ByRef`` 很巧妙地将值换成它的引用,并允许 ``ScopeGuardImpl1`` 在不进行任何修改的情况下使用引用。您所要做的就是将引用封装到对 ``ByRef`` 的调用中: .. code-block:: cpp void Decrement(int& x) { --x; } void UseResource(int refCount){ ++refCount; ScopeGuard guard = MakeGuard(Decrement, ByRef(refCount)); } 我们发现这个解决方案非常有表现力和启发性。 此部分最好的部分是引用支持 ``ScopeGuardImpl1`` 中的 ``const`` 修饰字。这里是上文的摘录: .. code-block:: cpp template class ScopeGuardImpl1 : public ScopeGuardImplBase{ private: Fun fun_; const Parm parm_; }; 这个小小的 ``const`` 很重要。编译期和运行时使用 ``非const`` 引用。换句话说,如果您忘记将``ByRef`` 与函数一起使用,将无法通过编译。 客官请留步,这里有更多技巧 ======================================== 现在你已经不必苦苦思索如何编写异常安全的代码了。但是有时候为这些 ``guard`` 起名字却很痛苦,你只是希望一个临时变量,它无需具有什么特殊意义的名字。 ``ON_BLOCK_EXIT`` 宏让你可以写出下面这种富有表现力的代码: .. code-block:: cpp { 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 联系到他。