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