问题实例
假设存在一个class表示具备背景图案的GUI菜单。该class作用于多线程环境下,所以它有一个互斥器(mutex)作为并发控制(concurrency control)之用:1
2
3
4
5
6
7
8
9
10class PrettyMenu{
public:
...
void changeBackground(istream& imgSrc);
...
private:
Mutex mutex;
Image* bgImage;//原始图片资源
int imageChanges;//改变次数
};
下面是PrettyMenu的changeBackgroun的可能实现(糟糕实现):1
2
3
4
5
6
7void PrettyMenu:changeBackground(istream& imgSrc){
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
异常安全有两个条件,而该函数没有满足其中任何一个条件。
异常安全的条件
当异常被抛出时,带有异常安全性的函数必须:
- 不泄露任何资源
上述实例中如果new操作抛出异常,那么资源无法被unlock,也就是无法得到释放 - 不允许数据破坏
上述实例中如果new抛出异常,bgImage将永久性地空悬。
确保异常安全
确保资源不被泄露
确保资源不被泄露的最佳方式是以RAII。(详见Effective C++ 资源管理篇)
确保数据不被破坏
异常安全保证
在解决数据破坏问题之前,必须要了解一些相关术语。首先是异常安全保证。
异常安全提供以下三个保证之一:
- 基本承诺
如果异常被抛出,程序的任何事物仍然保持有效状态。没有任何对象和数据结构因此遭到破坏,所有对象均满足内部前后一致(class约束条件仍然有效),但程序的现实状态不可预料。
以实例为背景,我们可以确保在更换背景时如果抛出异常,对象会拥有原背景图案,或者对象会更改为某个默认背景图案,用户无法预期究竟会发生哪种情况。 - 强烈保证
如果运行期间存在异常被抛出,程序状态不改变。也就是说,运行成功则完全成功,失败则函数调用前的状态。
这比基本承诺所提供的性能要强,因为它确保了程序只有两种状态(调用成功,调用前),而基本承诺具备多个多态的可能(调用成功,调用前,以及任一合法状态)。 - nothrow保证(C++11引入noexcept修饰符)
最高异常安全保证,确保程序在运行期间不会抛出任何异常,永远可以完成预期功能。所有内置类型都提供了nothrow保证(这也就是Effective C++ 26中认为swap处理内置类型及等价为异常安全的原因)。
也许有人会认为具备空白异常明细(empty exception specification)者必为nothrow函数,其实并非如此,举例而言:int dosth() throw();
这并非是说dosth不会抛出异常,而是说如果dosth抛出异常,将会执行set_unexpected函数(对程序而言是严重错误)。函数的声明(包括异常明细)并不能提供任何异常保证,所有性质均由实现来决定。
Exception-safe code必须提供上述三种保证之一,否则则不具备异常安全性。
异常安全保证的选择
nothrow自然是异常安全的最高追求,但很难做到。因为只要我们使用了动态内存,则不可避免地会接触bad_alloc
异常。对于大部分函数而言,我们往往只在基本保证和强烈保证之间。
异常安全实例
仍以changeBackground为例,我们提供强烈保证只需要完成以下两点:
- 改用RAII
- 重排语句次序,保证对象的状态只会在过程确实完成后才会被改变。
1 | class PrettyMenu{ |
值得注意的是,这里无需手动delete,且reset中delete执行依赖于新图像的成果创建。也就是说如果new跑出了异常,原本的资源不会遭到破坏,程序依然维持着执行前的状态。
强烈保证的设计策略(copy and swap)
copy and swap能够很轻松的实现强烈保证,其原理很简单:当为你所需要修改的对象构造一份副本,然后在副本上执行修改,修改完成后swap副本与原件。如果在修改期间发生了异常,我们可以保证原件并没有遭到破坏。
其具体实现手法是:将所有“隶属于对象的数据”从原对象放入另一个对象,然后赋予原对象一个指针指向具体实现对象。(pimpl设计模式,详见Effective C++ 32)。对于PrettyMenu而言,其具体写法如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18struct PMImpl{
shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu{
...
private:
Mutex mutex;
shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(istream& imgSrc){
using std::swap;//详见Effective C++ 26
Lock m1(&mutex);
shared_ptr<PMimpl> pNew(new PMImpl(*pImpl));//构造副本
pNew->bgImage.reset(new Image(imgSrc));//修改副本
++pNew->imageChanges;
swap(pImpl,pNew);//置换
}
以下逐步分析本例中copy and swap的具体操作:
- RAII要求了原始资源Image需要被一个智能指针所持有,所以bgImage是一个智能指针对象。
- 与PrettyMenu直接有关的数据除了背景图之外,还有背景图变换次数int imageChanges。
- 将bgImage与imageChanges打包,封装成为一个新的具体实现对象PMimpl。
- 在PrettyMenu中存有指向具体实现的指针shared_ptr
pImpl.具体实现对象PMimpl同样是一种资源,所以再次使用了RAII,该指针pImpl为private。 - pImpl的私有性保证了PMImpl无法被客户直接访问,因此将其设为strcut并不影响数据封装性。
- 在copy动作中,最先执行的是new PMimpl(*pImpl)语句。该语句在heap上建立了一个PMImpl对象,调用了PMImpl默认拷贝构造函数。
- PMImpl的默认拷贝构造函数对于int数据采取了逐bit拷贝,对于shared_ptr对象则调用了其默认拷贝构造函数。
- shared_ptr<Image>默认拷贝构造函数执行,计数器自增,此时有两个智能指针指向原始图像资源。至此,PMImpl构造完毕。
- pNew执行构造函数,接受一个指向heap中的PMImpl对象,RAII执行完毕,资源被封存入pNew中进行管理。
pNew作为原有数据的副本,其raw pointer指向PMImpl对象,PMImp对象内部存有一个int与一个shared_ptr,该智能指针指向原始图像资源,该资源的引用计数为2。 - 通过new Image语句构造了一个位于heap中的Image对象,将其作为reset的实参。
- pNew通过解引用访问PMimpl对象的bgImage,对bgImage执行reset操作。
- bgImage并非唯一指向原始资源的智能指针,因此原始图像资源并未析构,计数器自减。bgImage指向新构造的Image对象。
- pNew中的背景更换次数自增,执行swap(pImpl,pNew);
- pImpl和pNew交换了彼此所指向的对象,此时pImpl指向了一个位于heap中的PMImpl对象,其bgImage指向了新构建的Image对象,且其int数据已发生过自增。
- 控制流离开changeBackground程序块,位于stack中的pNew对象开始析构。由于pNew是唯一指向原有PMImpl对象的智能指针,原有PMImpl执行析构函数。
- 原有PMImpl中的int内置类型没有析构函数,bgImage开始析构。
- bgImage为唯一指向原有原始图像资源的智能指针,原有原始图像资源析构。至此copy and swap执行完毕。
强烈保证的连带效应
使用copy and swap能够做到令对象状态保证“成功或回退”,但一般而言它并不保证整个函数具备强烈保证,举例而言,假设Func是一个执行了copy and swap的函数:1
2
3
4
5
6void Func(){
...
f1();
f2();
...
}
显然,如果f1或者f2其异常安全性低于强烈保证,那么很难保证Func具备强烈保证。就算f1与f2都具备强烈保证,Func也未必具备强烈保证。举例而言,假若f1成功执行,f2执行失败,对象状态回退至f1执行之后f2执行之前,显然此时Func不具备强烈保证。
copy and swap引发的效率问题
copy and swap的精要在于copy被修改的对象,为此我们需要付出构造与析构的成本。pimpl模式下的copy and swap消耗极低,但这并不意味着你每一次都只是构造一个指针而已。
基本保证
当强烈保证不切实际之时,我们应该考虑基本保证。基本保证是异常安全的最后一道屏障,如果无法满足则无法被称为异常安全。
异常安全并没有局部与整体的概念,只要某一处不具备异常安全性,即可视作整个程序不具备异常安全性。
总结
- 异常安全函数即使发生异常也不会导致资源泄露或者数据破坏。其函数提供分为三种可能的保证:基本、强烈、不抛异常。
- copy and swap可能实现强烈保证,但其并非对所有函数都可实现或具备实现意义。
- 异常安全具备全局性,其等于最弱的局部异常保证。