Fork me on GitHub

读书笔记 effective c++ Item 29 为异常安全的代码而努力

 

异常安全在某种意义上来说就像怀孕。。。但是稍微想一想。在没有求婚之前我们不能真正的讨论生殖问题。

假设我们有一个表示GUI菜单的类,这个GUI菜单有背景图片。这个类将被使用在多线程环境中,所以需要mutex进行并发控制。

 1 class PrettyMenu {
 2 public:
 3 ...
 4 void changeBackground(std::istream& imgSrc); // change background
 5 ... // image
 6 
 7 private:
 8 Mutex mutex;       // mutex for this object
 9 
10 Image *bgImage; // current background image
11 
12  
13 
14 int imageChanges;          // # of times image has been changed
15 
16 };         

                      

 我们看一种PrettyMenu的changeBackground函数的可能实现:

 1 void PrettyMenu::changeBackground(std::istream& imgSrc)
 2 {
 3 
 4 lock(&mutex); // acquire mutex (as in Item 14)
 5 
 6 delete bgImage;          // get rid of old background
 7 
 8 
 9 ++imageChanges; // update image change count
10 bgImage = new Image(imgSrc); // install new background
11 
12 unlock(&mutex);             // release mutex
13 
14 }   

                              

 

1. 异常安全的函数有什么特征

 

从异常安全的角度来说,这个函数很糟糕。对于异常安全来说有两个要求,上面的实现没有满足任何一个。

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

  • 不会泄露资源。上面的代码不会通过这个测试,因为如果”new Image(imgSrc)”表达式产生一个异常,unlock就永远不会被调用,当前线程会一直拥有锁。
  • 不允许数据结构被破坏。如果”new Image(imgSrc)”抛出异常,bgImage就会指向一个被销毁的对象。此外,imageChanges却被增加了,但真实的情况是新图片没有被装载。(从另一个方面来说,旧图片被完全清除掉了,所以我猜测你会争辩图片已经被“修改”过了)

处理资源泄露问题很容易,因为Item 13解释过了如何使用对象来管理资源,Item 14引入了Lock类来确保mutex能够被实时的释放掉:

1 void PrettyMenu::changeBackground(std::istream& imgSrc)
2 {
3 Lock ml(&mutex); // from Item 14: acquire mutex and
4 // ensure its later release
5 delete bgImage;
6 ++imageChanges;
7 bgImage = new Image(imgSrc);
8 }

 

使用像Lock一样的资源管理类的一个极大的好处是它通常使函数更短小。看一下为什么不再需要对unlock的调用了?作为一个通用的规则,代码越少越好,因为对代码做改动时,出错和理解错误的可能性变低了。

 

2. 异常安全的三种保证级别

 

接下来让我们看一看数据结构被损坏的问题。这里我们要做出选择,但是在我们可以进行选择之前,必须对定义这些选择的术语做一下比较。

异常安全的函数提供了如下三种保证的一种:

  • 基本的保证(basic guarantee),这样的函数在抛出异常的时候,程序中的所有东西仍然保持在有效状态。没有对象或者数据被损坏,并且所有对象或者数据保持一个内部一致的状态(例如所有类的约束条件继续被满足)。然而,程序的正确状态可能不能够被预测出来。例如,我们可以实现changeBackground,一旦异常被抛出,PrettyMenu对象可以继续拥有旧的背景图片,但是客户不能够预测拥有的是哪一个。(为了能够找出这个图片,大概需要调用一些成员函数,来告诉它们当前的背景图片是哪一个)
  • 强保证(strong guarantee)。这样的函数在异常被抛出时,程序的状态不会被改变。对这些函数的调用是原子性(atomic)的,意思是如果调用成功了将会完全成功,但如果失败了,程序的状态就像没有被调用一样。

使用提供强保证的函数比只提供基本保证的函数要更加容易,因为调用提供强保证的函数之后,只可能有两种程序状态:函数被正确执行后的状态,或者函数被调用之前的状态。而如果在调用只提供基本保证的函数的时候抛出异常,程序可以进入任何有效状态。

  • 无异常保证(nothrow guarantee)。这样的函数永远不会抛出异常,因为它们总能做到它们所承诺的。所有在内建类型上进行的操作都是无异常的。这是异常安全代码的一个关键的构建基础。

认为带有空异常明细(empty exception specification)的函数是无异常的,这可能看上去是合理的,但事实上不是这样。举个例子,考虑下面的函数:

       

1       int doSomething() throw();   // note empty exception spec.

 

这并不是说doSomething永远不会抛出异常。它的意思是如果soSomething抛出异常,就会是一个严重的错误,并且会调用意料不到的函数。事实上,doSomething没有提供任何异常安全保证。这个函数的声明(如果有异常明细,也包含异常明细)并没有告诉你这个函数是否是正确的,可移植的或者效率高的,也没有为你提供任何异常安全保证。所有这些特性都由函数的实现来决定,而不是声明。

 

异常安全的代码必须提供上面三种保证的一种。如果没有提供,它就不是异常安全的。你的选择决定了为你所实现的函数提供哪种保证。除了在处理异常不安全的旧代码时不需要提供异常安全保证之外,异常不安全的代码只有在下面一种情况下才会需要:你的团队做需求分析时发现有对资源泄露和在破环的数据结构上运行程序的需要。

 

作为普通标准,提供最强异常安全保证是实际的想法。从异常安全的角度来说,不抛出异常的函数才是完美的。但在C++的C部分中很难不去调用可能会抛出异常的函数。使用动态分配内存的任何东西(例如,所有的STL容器)如果发现没有足够的内存可供分配都会抛出一个bad_alloc异常(Item 49)。如果能提供不抛出异常的函数更好,更多的情况是在基本保证强保证之间做出选择。

 

3. 提供异常安全的两种方法

3.1 使用智能指针

 对于changeBackground来说,提供强保证不是多难的事。首先,我们将PrettyMenu的bgImage数据成员的类型从内建的Image*指针替换为一种资源管理智能指针(见 Item 13)。说真的,对于防止资源泄露来说这绝对是一个好方法。它帮我们提供强异常安全保证的事实只是简单对Item 13中的论述(使用对象管理资源是好的设计的基础)做了进一步的加强。在下面的代码中,我将会展示tr1::shared_ptr的使用,因为当进行拷贝时使用tr1::shared_ptr比使用auto_ptr更加直观,因此更受欢迎。

其次,我们对changeBackground中的语句进行重新排序,达到只有image被修改的时候才会增加imageChnages的目的。作为通用准则,一个对象的状态没有被修改就表明一些事情没有发生。

 

下面是最终的代码:

 

 1 class PrettyMenu {
 2 ...
 3 std::tr1::shared_ptr<Image> bgImage;
 4 ...
 5 };
 6 void PrettyMenu::changeBackground(std::istream& imgSrc)
 7 {
 8 Lock ml(&mutex);
 9 bgImage.reset(new Image(imgSrc)); // replace bgImage’s internal
10 // pointer with the result of the
11 // “new Image” expression
12 ++imageChanges;
13 }

 

注意这里不再需要手动delete旧image,因为这由智能指针在内部处理。并且,销毁操作只有在新image成功创建的时候才会发生。更精确的说,只有在参数(new Image(imgSrc)的结果)被成功创建的时候tr1::shared_ptr::reset函数才会被调用。Delete只在reset函数内部被使用,所以如果reset不被调用,delete永远不会被执行。注意资源管理对象的使用再次削减了changeBackground的长度。

 

正如我所说的,上面的两个修改足以为changeBackground提供强异常安全保证。还有美中不足的就是关于参数imgSrc。如果Image的构造函数抛出异常,输入流的读标记可能会被移动,这个移动致使状态发生变化并且对程序接下来的运行是可见的。如果changeBackground不处理这个问题,它只能提供基本异常安全保证。

 

3.2 拷贝和交换

把上面的问题放到一边,我们假设changeBackground能够提供强异常安全保证。(你应该能想出一个好的办法来提供强异常安全保证,也许可以将参数类型从istream变为包含image数据的文件的名字。)有一种普通的设计策略也能提供强保证,熟悉它很重要。这个策略叫做“拷贝和交换”(copy and swap。它是很简单的:先对你想要修改的对象做一份拷贝,然后在拷贝上进行所有需要的改动。如果任何修改操作抛出了异常,源对象仍然保持未修改状态。一旦修改完全成功,将源对象和修改后的对象进行不会抛出异常的交换即可(Item 25)。

 

这往往会把真实的对象数据放入到一个单独的实现对象中,然后提供一个指向这个实现对象的指针。也即是指向实现的指针(pimpl idiom),Item 31中会进行详细描述。PrerttyMenu的实现如下:

 1 struct PMImpl { // PMImpl = “PrettyMenu
 2 std::tr1::shared_ptr<Image> bgImage; // Impl.”; see below for
 3 int imageChanges; // why it’s a struct
 4 };
 5 class PrettyMenu {
 6 ...
 7 private:
 8 Mutex mutex;
 9 std::tr1::shared_ptr<PMImpl> pImpl;
10 };
11 void PrettyMenu::changeBackground(std::istream& imgSrc)
12 {
13 using std::swap; // see Item 25
14 Lock ml(&mutex); // acquire the mutex
15 
16 std::tr1::shared_ptr<PMImpl>          // copy obj. data
17 
18 pNew(new PMImpl(*pImpl));        
19 
20 
21 pNew->bgImage.reset(new Image(imgSrc)); // modify the copy
22 ++pNew->imageChanges;
23 
24 swap(pImpl, pNew); // swap the new
25 // data into place
26 
27 }                           // release the mutex

 

在这个例子中,我选择将PMImpl定义为一个结构体而不是类,因为PrettyMenu的封装性通过pImpl的私有性(private)来保证。把PMImpl定义成类会至少和结构体一样好,虽然有一些不方便。如果需要,PMImpl可以被放在PrettyMenu中,但是打包问题(packaging)是我们所关心的。

 

拷贝和交换策略是处理对象状态问题的卓越方法(状态要么全变要么都不变),但是一般情况下,它不能够保证所有的函数是强异常安全的。想知道为什么,考虑对changeBackground做的一个抽象,someFunc,这个函数使用拷贝和交换策略,但也包含对其它两个函数的调用,f1和f2:

 1 void someFunc()
 2 {
 3 
 4 ...                            // make copy of local state
 5 
 6 f1();                      
 7 
 8 f2();                      
 9 
10  
11 
12 ...                                                 // swap modified state into place
13 
14 }            

                                 

 这里如果f1或者f2不是强异常安全的,someFunc就很难是强异常安全的。举个例子,假设f1只提供了基本保证。如果someFunc要提供强保证,必须写代码在调用f1之前确定整个程序的状态,然后捕获f1中的所有异常,如果发生异常则恢复原始状态。

 

即使f1和f2是强异常安全的,情况也没有任何改观。因为如果f1运行完成后,程序的状态发生了变化,这时候如果f2抛出了异常,程序的状态和调用someFunc之前已经不一样了,即使f2没有修改任何东西。

 

4. 不能提供强异常安全保证的两种情况

 

当函数操作只影响本地状态(例如,someFunc只影响调用此函数的对象的状态),提供强异常安全保证是相对容易的。当函数对非本地数据也产生副作用时,提供强保证就相当困难了。例如,如果调用f1的副作用是数据库会被修改,很难让someFunc提供强异常安全。通常来说,对于已经提交的数据库改动,没有方法对其进行回退。其它的数据库客户端可能已经看到了数据库的新状态。

 

即使你想提供强异常安全保证,上述问题也会阻止你。另外一个问题是效率问题。拷贝和交换的关键点在于首先对对象拷贝进行修改,然后将源数据和修改后的数据进行无异常的交换。这需要对每个要修改的对象都做一份拷贝,这需要时间和空间,你可能不能够或不愿意为其提供这些资源。大家都想获得强异常安全保证,你应该在实际的时候提供它,但不是100%的情况下都是实际可行的。

5. 至少为代码提供基本异常安全保证(遗留代码除外)

如果不切实际,你必须提供基本保证。在实际情况中,你可能发现你可以为一些函数提供强保证,但是在效率和复杂度方面的开销使其变得不再实际。只要你为提供强异常安全保证的函数做出努力了,没有人会因为你提供基本保证而批评你。对许多函数来说,基本保证是最合理的选择。

 

如果你实现一个函数不提供任何异常安全的保证,事情就不一样了,因为你会一直内疚下去直到证明你是无辜的。所以你应该实现异常安全的代码。但是你可能有所抵触。再考虑一下someFunc的实现,它调用了函数f1和函数f2。假设f2没有提供异常安全保证,连基本保证也没有提供。这就意味着如果在f2内部抛出异常,资源泄露就可能会发生,也可能会出现被破坏的数据结构,例如,有序的数组不再有序,从一个数据结构传递到另一个数据结构的对象被丢失等等。someFunc没有任何方法能够对这些问题做出补偿。如果函数someFunc调用了没有提供异常安全的函数,someFunc自己也不能提供任何保证。

 

让我们回到怀孕的话题。一个女性要么怀孕了要么没有怀孕。不可能部分的怀孕把。类似的,一个软件系统要么是异常安全的,要么不是。也没有部分异常安全的系统。如果一个系统中有一个没有提供异常安全的函数,那么整个系统也就不是异常安全的,因为对这个函数的调用会导致资源泄露和数据结构的破坏。不幸的是,许多C++遗留代码并没有被实现为异常安全的,所以如今太多的系统都不是异常安全的。

 

没有任何理由维持这种状态。所以当写新代码或修改现有代码时,对如何使其变得异常安全需要进行仔细的考虑。首先使用对象管理资源(Item 13),这能防止资源泄露。然后为每个函数从三种异常安全保证中选取实际并且最强的那一个,只有在调用遗留代码时让你无可选择的情况下才能勉强接受无安全保证。为函数的使用者和将来的维护人员将你做的决定记录在文档中。一个函数异常安全保证是接口的可见部分,所以在你选择异常安全保证部分时,你应该像选择函数接口的其它方面一样谨慎。

 

40年前,goto语句被认为是好的实践。现在我们却努力实现结构化控制流(structured control flows)。20年前,全局访问数据被认为是好的实践。现在我们却努力对数据进行封装。10年前,实现出不用考虑异常影响的函数被认为是好的实践。现在我们努力写出异常安全的代码。

 

与时俱进。活到老,学到老。

 

6. 总结 

    • 异常安全函数不会造成资源泄露,也不允许数据结构被破坏,即使在抛出异常的情况下也如此。这样的函数提供基本的,强的和不抛出异常三种保证。
    • 强保证通常通过拷贝和交换来实现,但为所有函数都提供强保证是不切实际的。
    • 一个函数提供的最强异常安全保证不会强于它所调用函数中提供的最弱异常安全保证。
posted @ 2017-03-07 21:14  HarlanC  阅读(756)  评论(0编辑  收藏  举报