Effective C++读书笔记(18)

条款29:为“异常安全”而努力是值得的

Strive for exception-safe code

假设我们有一个类,代表带有背景图像的GUI菜单。这个类被设计成在多线程环境中使用,所以它有一个用于并行控制(concurrencycontrol)的互斥体(mutex):

class PrettyMenu {
public:
void changeBackground(std::istream& imgSrc); // 改变背景图案
...

private:
Mutex mutex; // mutex
Image *bgImage; // 目前背景图案
int imageChanges; // 背景图案被改变次数
};

考虑这个PrettyMenu的changeBackground函数的一个可能实现:

voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // 取得互斥器
delete bgImage; // 删除旧背景
++imageChanges; // 更新修改次数
bgImage = new Image(imgSrc); // 新背景
unlock(&mutex); // 释放互斥器
}

从异常安全的观点看,这个函数没有满足异常安全两条要求中的任意一条。

当异常被抛出,异常安全的函数应该:

·    没有资源泄露:上面的代码没有做到这一点,因为如果 "newImage(imgSrc)"导致异常,对unlock的调用就永远不会执行,而那个互斥体也将被永远挂起。

·    不允许数据败坏:如果 "newImage(imgSrc)"抛出异常,bgImage就指向一个已被删除的对象,imageChanges也已被增加,但新图像未被成功加载。

解决资源泄漏比较容易,我们使用对象管理资源,用条款14里的Lock类作为一种确保互斥体被释放的方法:

voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}

使用像Lock这样的资源管理类,最好的事情之一是它们通常会使函数变短。这里就不用再调用unlock了。通常更少的代码就是更好的代码,因为出错机会比较少。

下面我们解决数据败坏,先来看一些术语。

异常安全函数提供下述三种保证之一:

·    基本保证(the basic guarantee):允诺如果一个异常被抛出,程序中的任何事物都处于合法状态。没有对象或数据结构被破坏,且所有对象都处于内部前后一致状态(所有的类的约束条件都继续被满足)。然而,程序的现实状态可能是不可预期的。

·    强烈保证(the strong guarantee):允诺如果一个异常被抛出,程序的状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功;如果函数失败,程序会恢复到调用函数之前的状态。

调用提供强烈保证的函数之后仅有两种可能的程序状态:像预期一样成功执行了函数,或回到函数被调用前的状态。与之相比,如果调用只提供基本承诺的函数引发了异常,程序可能处于任何合法的状态。

·    不抛掷保证(the nothrow guarantee):允诺决不抛出异常,因为它们总是能够完成它们原先承诺的功能。所有对内置类型(如ints,指针等等)的操作都提供nothrow保证。这是异常安全代码中必不可少的关键基础构件。

异常安全函数必须提供上述三种保证中的一种。如果它没有提供,它就不是异常安全的。于是,选择就在于决定你写的每一个函数究竟要提供哪种保证。一般而言你应该提供实际可达到的最强力的保证。从异常安全的观点看,nothrow函数很棒,但是在C partof C++领域中,我们很难保证完全不调用任何一个可能抛出异常的函数。任何使用动态分配内存的东西(例如所有STL 容器)如果不能找到足够的内存来满足需求,通常它就会抛出一个bad_alloc异常。只要你能做到就提供nothrow保证,但是对于大多数函数,选择是在基本保证和强烈保证之间的。

对于changeBackground,提供差不多的强烈保证并不困难。首先,我们将PrettyMenu的 bgImage数据成员类型从一个内建的Image*指针改为智能资源管理指针中的一种,来预防资源泄漏。在下面的代码中,我们使用了tr1::shared_ptr。其次我们重新排列 changeBackground中的语句,使得在图像发生变化后才增加imageChanges。这是一个很好的策略:直到某件事情真正发生了,再改变一个对象的状态来表示某事已经发生。

这就是修改之后的代码:

class PrettyMenu {
std::tr1::shared_ptr<Image> bgImage;
...
};

voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(newImage(imgSrc));
// 以"new Image"的执行结果设定bgImage内部指针
++imageChanges;
}

注意这里不再需要手动删除旧的图像,因为在智能指针内部已经被处理了。此外,只有当新的图像被成功创建,删除行为才会发生。更准确地说,只有当tr1::shared_ptr::reset函数的参数("new Image(imgSrc)" 的结果)被成功创建了,这个函数才会被调用。Delete只在reset函数内被使用,所以如果这个函数从来不曾进入,delete就不曾使用。同样请注意changeBackground的长度再次被缩短。

 

有一种设计策略很典型地会产生强烈保证,很值得熟悉它。这个策略被称为 "copy and swap"。它的原理很简单:先做出一个你要改变对象的拷贝,然后在这个拷贝上做出全部所需的改变。如果改变过程中的某些操作抛出了异常,最初的对象保持不变。在所有的改变完全成功之后,将被改变的副本和最初的对象在一个不会抛出异常的操作中进行交换。

这通常通过下面的方法实现:将所有隶属对象的数据从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(即副本)。这通常被称为"pimpl手法",对于 PrettyMenu来说,典型写法如下:

struct PMImpl { // PMImpl = "PrettyMenu
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};

class PrettyMenu {
...
private:
    Mutex mutex;
    std::tr1::shared_ptr<PMImpl> pImpl;
};

voidPrettyMenu::changeBackground(std::istream& imgSrc)
{
    using std::swap;
    Lock ml(&mutex); // 获得mutex的副本数据
    std::tr1::shared_ptr<PMImpl>pNew(new PMImpl(*pImpl));
    pNew->bgImage.reset(newImage(imgSrc)); // 修改副本
    ++pNew->imageChanges;
    swap(pImpl, pNew); // 置换数据,释放mutex
}

 

copy-and-swap策略是一种对对象状态做出“全有或全无”改变的一个很好方法,但一般而言,它不能保证全部函数都是强烈异常安全的。考虑changeBackground的一个抽象概念:someFunc,它使用了copy-and-swap,但是它包含了对另外两个函数(f1和f2)的调用:

void someFunc()
{
... // 对local状态做一份副本
f1();
f2();
... // 将修改后的状态置换过来
}

很显然,如果f1或f2异常安全性比强烈保证低,someFunc就很难成为强烈异常安全的。例如,假设f1仅提供基本保证。为了让someFunc提供强烈保证,它必须写代码在调用f1之前获得整个程序的状态,并捕捉来自f1的所有异常,然后恢复原状态。

即使 f1 和 f2 都是强力异常安全的,事情也好不到哪去。如果f1运行完成,程序的状态已经有所改变,所以如果随后f2抛出一个异常,甚至即使f2没有改变任何东西,程序的状态也已经和调用someFunc时不同。

问题之一在于“连带影响”(side effect)。只要函数仅对局部状态起作用(例如someFunc只影响其调用者对象的状态),它提供强烈保证就相对容易。当函数对非局部数据有连带影响时,它就会困难得多。一般情况下在数据库修改动作送出后,没有什么做法可以取消并恢复数据库旧貌,因为其他数据库客户可能已经看见了数据库的新状态。

问题之二在于效率。copy-and-swap需要做出每一个要改变对象的拷贝,这可能会用到你不能或不情愿动用的时间和空间。

在实践中,你可能会发现你能为某些函数提供强烈保证,但是效率和复杂度的成本使得它难以支持大量的其它函数。当强烈保证不切实际时,你就必须提供基本保证。

 

一个女性或者怀孕或者没有,部分怀孕是绝不可能的。与此相似,一个软件或者是异常安全的或者不是,没有类似局部异常安全的系统这样的东西。一个系统即使只有一个函数不是异常安全的,那么系统作为一个整体就不是异常安全的,因为调用那个函数可能发生泄漏资源和败坏数据。不幸的是,很多C++的遗留代码在写的时候没有留意异常安全,所以现在很多系统都不是异常安全的,因为它们并入了一些并非异常安全的代码。

当书写新代码或修改旧代码时,要仔细考虑如何使它异常安全。首先以使用对象管理资源,(还这样可以防止资源泄漏。然后决定三种异常安全保证中的哪一种实施于你所写的每一个函数身上,你应该挑选现有条件下的最强烈等级。将你的决定写成文档,既是为你的函数用户着想,又是为将来的维护着想,函数的“异常安全性保证”是其可见接口的一部分。

四十年前,到处都是goto的代码被尊为最佳实践,现在我们为书写结构化控制流而奋斗;二十年前,全局数据被尊为最佳实践。现在我们为封装数据而奋斗;十年前,写函数时不必考虑异常的影响被尊为最佳实践,现在我们为代码的异常安全而奋斗。

·    异常安全函数即使当异常被抛出时也不会泄露资源或数据败坏。这样的函数区分为三种可能的保证:基本的,强烈的,或者nothrow保证。

·    强烈保证经常以copy-and-swap实现,但是强烈保证并非对所有函数都具备现实意义。

·   一个函数通常能提供的保证不会强于他所调用的函数中最弱的保证。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值