第29条: 力求使代码做到“异常安全”
异常安全看上去像是孕育生命,但是请您先把这种观点暂时放在脑后。因为在一对恋人结婚之前,讨论生育问题还为时尚早。
假设我们正在设计一个表示 GUI 菜单的类,这种菜单是有背景图片的,这个类用于多线程环境中,所以它拥有一个互斥锁来确保正常的并发控制:
public:
...
void changeBackground(std::istream& imgSrc); // 更改背景图片
...
private:
Mutex mutex; // 本对象使用的互斥锁
Image *bgImage; // 当前的背景图片
int imageChanges; // 图片更改的次数
};
下面是 PrettyMenu 的 changeBackground 函数实现的一个备选方案:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // 请求上锁 ( 同第 14 条 )
delete bgImage; // 删除旧的背景图片
++imageChanges; // 更新图片改变的次数
bgImage = new Image(imgSrc); // 装载新的背景图片
unlock(&mutex); // 解锁
}
从异常安全的角度来说,这个函数简直一无是处。异常安全的两个基本要求,这个函数完全没有考虑到。
当抛出异常时,异常安全的代码应该做到:
l 不 要泄漏资源。 上面的代码没有进行这项检测,这是因为如果 “ new Image(imgSrc) ” 语句产生了异常 ,那么对 unlock 的调用则永远不会兑现,这样互斥锁将永远不会被解开。
l 不能让数据结构遭到破坏。 如果“ new Image(imgSrc) ”抛出异常, bgImage 就会指向一个已经销毁的对象。另外,无论新的图形是否装载成功, imageChanges 的数值都会增加。(从另一个角度说,旧的图形肯定是被删除了,那么你又怎么能确保图形被“改变”了呢。)
处理资源泄漏问题还是比较简单的,因为第 13 条中介绍过如何使用对象来管理资源,第 14 条中介绍过如何通过 Lock 类确保互斥锁在适当的时候被解开:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex); // 来自第 14 条的经验:
// 申请一个互斥锁,并适时解锁
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
能够让函数变得更短,是诸如 Lock 这样的资源管理类最让人兴奋的事情之一。你是否注意到:这里甚至不需要调用 unlock 。更短的代码就是更优秀的代码,因为代码越短,它带来错误和误解的机会就越少。这是一条通用的准则。
把资源泄漏问题放在一旁,让我们把注意力集中在数据结构破坏的问题上。这里我们可以进行一次选择,但是在进行选择之前,我们首先要了解所需要的几个术语。
异常安全函数做出的保证可以总结为三个层面:
l 提供基本保证的函数会做出这样的承诺:如果抛出了一个异常,那么程序中的一切都将保持合法的状态。没有任何对象或数据结构会遭到破坏,所有的对象的内部都保持协调的状态(比如说所有类不变式都得到满足)。然而,程序的具体状态可能是无法预知的。比如说,我们可以这样编 写 changeBackground :如果某一时刻抛出一个异常,那么 PrettyMenu 对象可能继续使用旧的背景图片,也可以用某个默认的背景图片来代替。但是客户端程序员无法做出任何预测。(为了找到答案,客户端程序员大概会调用某个能告诉他们当前背景图片是什么的成员函数。)
l 提供增强保证的函数会做出这样的承诺:如果抛出了一个异常,那么函数的状态将保持不变。这样的函数看上去与原子有些相像,因为如果调用成功了,它就会大获全胜;一旦出了差错,那么就会一败涂地,程序状态将显示它从来没有被调用过。
使用提供强保证的的函数要比使用仅提供基本保证的函数简单一些,这是因为在调用一个提供强保证的函数之后,程序只可能存在两种状态:一、按预期进行,函数成功执行;二、程序将保持函数调用前的状态。反观提供基本保证的函数,如果在调用它时抛出了一个异常,那么程序可能会处于任何合法的状态。
l 提供零异常保证的函数承诺程序决不会抛出异常,因为它们永远都会按部就班的运行。所有的内建数据类型 ( int 、指针,等等)的操作都是零异常的(提供零异常保证)。这是异常安全代码必不可少的一个因素。
我们可以假设包含空异常规范 [1] 的函数为零异常的,这样做看上去是合理的,但是事实并不一定是这样的,请看下面的函数:
int doSomething() throw(); // 记录空异常规范
这并不是说 doSomething 将永远不会抛出异常,这只是说,如果 doSomething 抛出了异常,那么此时就出现了一个严重的错误,同时程序应该调用一个名为 unexpected 的函数 [2] 。实际上, doSomething 可能根本不会提供任何异常保证。函数的声明(包括它的异常规范,如果有的话)并不会告诉你它是否正确、是否小巧、是否高效,同时也不会告诉你它他提供了哪个层面上的异常安全保证。所有那些特性都要在函数实现中确定下来,而不是声明中。
异常安全的代码必须要提供上述三个层面的保证中的一种。否则它就不是异常安全的。那么你要做的就是:对于所写的每一个函数都要确定使用哪一层面保证。除非我们正在处理没有做到异常安全的糟糕代码(这一点我们稍候再提)。只有当你的“优秀”的需求分析小组提出:你的程序需要泄露资源,并且需要使用破坏的数据结构时,不提供任何异常安全保证也许才是一个可行的选择。
作为一个通用的准则,你会希望提供可行范围内最强化的保证。从异常安全的角度来说,零异常的函数是美妙的,但是如果不去调用抛出异常的函数,你是很难逾越 C++ 中 C 这一部分的。只要涉及动态内存分配(比如所有的 STL 容器),如果无法寻找到足够的内存来满足当前的要求,那么通常程序都会抛出一 个 bad_alloc 异常(参见第 49 条)。在可行的时候你应该为函数提供零异常保证,但是对于大多数函数而言,悬在是介于基本保证和增强保证之间的。
对于 changeBackground 而言,或多或少地提供增强保证并不是件难事。首先,我们可以改变 PrettyMenu 的 bgImage 数据成员的类型,从一个内建的 Image* 指针类型转变为智能资源管理指针(参见第 13 条)。坦白的说,单独从防止资源泄漏理论的角度上说,这是一个非常好的设计方案。事实上它简单地通过使用对象(比如智能指针)来管理资源(也就是遵循了 13 条中的建议,这是优秀设计的基本要求),帮助我们提供了增强的异常安全保证。在下面的代码中,我将使用 tr1::shared_ptr ,这是因为它的行为更直观,在进行复制操作时比 auto_ptr 更合适。
其次,我们从新编排了 changeBackground 中语句的顺序,从而使 imageChanges 直到图像改变以后才进行自加。作为一个通用的准则,直到一个事件真真切切地发生了,才去改变对象的状态来描述这个事件。
下面是改进后的代码:
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc)); // 将 bgImage 的内部指针替换为
//”new Image” 表达式的结果
++imageChanges;
}
请注意这里不需要手动删除旧图片,因为这件事情完全由智能指针代劳了。而且,只有在新图像成功创建之后,删除操作才有意义。更精确地说, tr1::shared_ptr::reset 函数只有在其参数( ”new Image(imgSrc)” 的结果)成功创建以后才会得到调用。由于只有在调用 reset 过程中才会使用 delete ,因此如果从未进入该函数,就永远不会用到 delete 。注意:使用对象( tr1::shared_ptr )来管理资源(动态分配的 Image ),再次精简了 changeBackground 。
如前所述,这两项改变或多或少地使 changeBackground 满足了增强的异常安全保证。可是 白璧微瑕, imgSrc 参数 还有 一个小问题。 如果 Image 的构造函数抛出了一个异常,那么输入流的读标记很可能会被移动,这样的移动可能会造成状态的变化,而这种变化对程序其它部分来说是可见的。在 changeBackground 指明这一问题之前,它仅仅提供基本的异常安全保证。
然而,让我们把这个问题暂时放在一旁,假装 changeBackground 确实可以提供增强保证。(我相信你可以想出一个办法来,可以通过改变参数的类型:从输入流变为包含图像信息的文件名。)有一个一般化的设计方案,可以使函数做到增强保证,了解这种放案十分重要。这一方案一般称为“复制并交换。”从理论上来讲,它非常简单。为需要修改的对象做一个副本,然后将所有需要的改变应用于这个副本之上。如果期间任一个修改操作抛出了异常,那么原始的对象依然纹丝未动。在所有改变顺利完成之后,通过一次不抛出异常的操作将修改过的对象与原始对象相交换即可。
上述方案通常这样实现:将对象的所有数据从“真实的”对象复制到一个独立实现的对象中,然后为真实对象创建一个指针,将其指向这个实现对象。这通常称为“ pimpl idiom ”(指向实现的指针),第 31 条中将将解它的一些细节。对于 PrettyMenu 而言,典型的实现是这样的:
struct PMImpl { // PMImpl = PrettyMenu 的实现
std::tr1::shared_ptr<Image> bgImage;
int imageChanges; // 下文将介绍它为什么是结构体
};
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap; // 参见第 25 条
Lock ml(&mutex); // 上锁
std::tr1::shared_ptr<PMImpl> // 复制 对象数据
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNew->imageChanges;
swap(pImpl, pNew); // 交换 新数据就位
} // 解锁
在这个示例中,我做出了这样的选择: PMImpl 是一个结构体而不是类,这是因为 PrettyMenu 数据的封装性是通过 pImpl 确定为私有的。将 PMImpl 实现为类不但不会带来便利,而且效果也不好。(它同样使面向对象的偏执狂陷入绝境。)如果需要,可以把 PMImpl 放置在 PrettyMenu 的内部,但是打包问题与编写异常安全代码的问题似乎没有什么联系,这不是我们当前所关注的。
有些对象状态修改的操作,要求要么是完全修改,要么完全不变,此时复制并交换策略是完美的。但是,一般情况下,它并不能确保整个函数都做到增强保证。请看下面 changeBackground 的一个抽象—— someFunc ,它使用了复制并交换策略,但是它包含了 2 个其它函数的调用:
这里应该很清楚了:如 果 f1 或者 f2 没有达到增强保证的要求,那么 someFunc 就很难满足增强保证。比如,假设 f1 仅提供了基本保证,为了让 someFunc 能满足增强保证,就必须要为其编写额外的代码,用于调用 f1 之前确定整个程序的状态,捕获 f1 抛出的所有异常,然后恢复原始的状态。
如果 f1 和 f2 都满足了增强保证,那么事情也不会好到哪去。如果 f1 运行完成,那么程序的状态可能经历了任意的修改过程,因此,如果 f2 在此时抛出了一个异常,那么程序的状态就可能会与 someFunc 被调用时不一致,即使 f2 没有做任何修改操作。
这个问题是个侧面效应。只要函数操作仅仅针对本地的状态(比如说, someFunc 仅仅影响到它所调用对象的状态),提供增强保证就相对简单些。当函数对于非本地数据存在这一侧面效应时,则更加困难些。比如说,如果调用 f1 引入的侧面效应是数据库被修改了,那么让 someFunc 满足异常安全的增强保证就比较困难。一般来说,已经被系统接受的数据库修改很难恢复,这是因为其它的数据库用户已经看到了数据库的新状态。
不管你情愿与否,诸如这样的问题会为你在编写增强保证的函数时设置重重障碍。另一个问题是:效率。复制并交换策略的核心思想就是修改对象副本的数据,然后通过一个不会抛出异常地操作交换修改后的数据。这需要为每个需要修改的对象创建出一个副本,这样做显然会浪费时间和空间,你也许不会情愿使用这一策略,现实条件有时也会阻止你。增强保证是我们良好的预期目标,只要可行你就应该提供,但是现实中它并不总是可行的。
在增强保证不可行时,你应该提供基本保证。从实用角度说,如果你发现你可以为某些函数提供增强保证,但是由此带来的效率和复杂度问题使得增强保证变得得不偿失。只要你在必要的时候做出了努力使适当的函数满足了增强保证,那么对于一些函数仅提供基本保证就是无可厚非的。对于大多数函数而言,基本保证已经是合理的、完美的选择了。
如果你正在编写一个完全不提供异常安全保证的函数,那么就是另一番景象了。因为在这里完全可以在未证明你无罪之前假定你有罪。你本应该编写异常安全代码。但是你也可以为自己做出强有力的辩解。请再次考虑一下 someFunc 的实现,它调用了两个函数: f1 和 f2 ,假设 f2 完全没有提供异常安全保证,即使基本保证也没有,这就意味着一旦 f2 抛出一个异常,程序可能会在 f2 的内部发生资源泄露。这意味着 f2 中可能会有破损的数据结构,比如:排好序的数组可能不再按顺序排列,在两个数据结构之间转送的对象也可能会丢失数据,等等。这样 someFunc 也无力回天。如果 someFunc 函数调用了没有提供异常安全保证的函数,那么 someFunc 自身就无法做出任何保证。
让我们回到本节开篇时所说的“孕育生命”的问题。一位女性要么就是怀孕,要么就是没有,绝没有“部分怀孕”的状态。类似的,一个软件系统要么是异常安全的,要么就不是。没有所谓的“部分异常安全”的状态存在。在一个系统中,即使只有一个单独的函数不是异常安全的,那么整个系统也就不是异常安全的。遗憾的是,许多较为古老的 C++ 代码在编写的时候完全没有考虑到异常安全问题,因此当今许多系统便不是异常安全的。新系统中混杂着异常不安全的编写习惯。
没有理由去维持现状。当编写新代码或者修改现有代码的时候,要认真考虑一下如何使之做到异常安全。首先,使用对象管理资源。(依然参见第 13 条。)这将有效地防止资源泄露。然后对于你要编写的每个函数确定你要使用哪一层面的异常安全保证,只有在调用古老的、没有异常安全保证的代码时才放弃异常安全保证,因为你别无选择。记录下你的选择,这即是为了你的客户端程序员,也是为了今后的维护人员。函数的异常安全保证位于接口的可见部分,因此你应该认真规划它,就像你认真规划接口其它部分一样。
四十年前,人们迷信充斥着 goto 的代码是完美的,现在我们却为了编写结构化控制流而努力。二十年前,全局的完全可访问的数据也是高踞神坛,然而当今我们却在提倡封装数据。十年前,编写函数时不去考虑异常的影响的做法倍受追捧,但是今天,我们坚定不渝的编写异常安全代码。
岁月荏苒,我们在学习中不断进步……
铭记在心
l 异常安全的函数即使在异常抛出时,也不会带来资源泄露,同时也不允许数据结构遭到破坏。这类函数提供基本的、增强的、零异常的三个层面的异常安全保证。
l 增强保证可以通过复制并交换策略来实现,但是增强保证并不是对所有函数都适用。
l 函数所提供的异常安全保证通常不要强于其调用的函数中保证层次最弱的一个。
作者:明翼(XGogo)
-------------
公众号:TSparks
微信:shinelife
扫描关注我的微信公众号感谢
-------------