异常安全代码的目标
当函数抛出异常时,具有“异常安全”性的代码应该实现以下两点:
- 不泄露任何资源。
- 不破坏数据。 也就是说,系统处于前后一致的状态。
比如下面这个代码:
class Image;
//多线程环境下使用
class Menu
{
private:
std::mutex mtx; //互斥器
Image* bgImg; //当前的背景图案
int imgChanges; //图案被改变的次数
public:
//改变背景图像
void ChangeBackground(std::istream& imgSrc)
{
mtx.lock();
delete bgImg;
imgChanges++;
bgImg = new Image(imgSrc);
mtx.unlock();
}
};
- 泄露了 mutex 资源。一旦
new Image(imgSrc)
导致异常,对unlock
的调用就绝对不会执行,于是互斥器就永远被把持住了。 - 破坏了原始数据。如果
new Image(imgSrc)
抛出异常,bgImg
就会指向一个已经删除了的对象,而且imgChanges
被错误的累加了,然而新的图像并没有成功加载。
异常安全代码的三种实现等级
对于数据被破坏的问题,异常安全的函数通常有三种不同的实现等级:
- 基本的保证。 函数在抛出异常的时候,程序中的所有东西仍然保持为有效状态,但是却可能无法预测具体的状态。
- 强烈保证。 函数在抛出异常的时候,程序的状态不会改变。对于该函数的调用,具有原子性。也就是说,一旦抛出异常,就像没有调用过这个函数一样。
- 不抛异常。 函数永远不会抛出异常,它总是能够完成交付给它的任务。
异常安全的代码必须提供以上三种保证的一种。
异常安全代码的实现方案
- 对于资源泄露问题,可以使用 对象来管理资源。比如说引入
Lock
类来确保mutex
能够被实时释放掉(离开对象作用域时,对象析构函数自动释放资源):
class Lock
{
public:
explicit Lock(mutex* pm) : pMutex(pm)
{
pMutex->lock();
}
~Lock()
{
pMutex->unlock();
}
private:
std::mutex* pMutex;
};
离开 m
的作用域时,mutex
资源利用 m
的析构函数被自动释放,解决了资源泄露问题。
void ChangeBackground(std::istream& imgSrc)
{
Lock m(&mtx);
delete bgImg;
imgChanges++;
bgImg = new Image(imgSrc);
}
利用资源管理类 Lock
,也减少了代码量,减小了可能出错的机会。(客户端无需调用 unlock
函数)
- 对
ChangeBackground
提供强烈保证也不难。
- 将
bgImg
的数据类型从普通的Image*
指针,改为智能指针。 - 调整语句
imgChanges++
的顺序。
class Menu
{
private:
std::mutex mtx; //互斥器
std::shared_ptr<Image> bgImg; //当前的背景图案
int imgChanges; //图案被改变的次数
public:
//改变背景图像
void ChangeBackground(std::istream& imgSrc)
{
Lock m(&mtx);
bgImg.reset(new Image(imgSrc));
imgChanges++;
}
};
改进之后:
- 不需要再手动
delete
旧的image
,这已经由智能指针的reset
函数内部处理了。 - 销毁操作只有在
new Image(imgSrc)
被成功创建之后才会发生。更确切的说,delete
函数只在reset
函数内被调用。只有new Image(imgSrc)
被成功创建后,delete
才会被调用。 - 还有一个问题。关于参数
imgSrc
,如果Image
的构造函数抛出异常,输入流的读标记可能会被移动,这个移动致使状态发生变化并且对程序接下来的运行是可见的。这是一种对整个系统的副作用,类似的副作用还有对数据库的操作。我们也没有一种通用的操作撤销对数据库的操作。这一点暂且可以忽略,我们还是认为ChangeBackground
提供了强烈的保证。
copy and swap
copy and swap 常用来实现强烈保证。首先将原来的对象复制一份,然后所有的修改动作都是在这个拷贝对象上进行。如果任何修改操作抛出异常,只是这个拷贝对象发生了改变,原来的对象仍然保持原来的状态。一旦修改成功,将源对象和修改后的对象进行不会抛出异常的交换就可以了。(写一个不抛异常的 swap 函数)
为了实现 copy and swap,往往会把类的数据成员放到一个单独的对象之中,然后提供一个指向这个数据对象的指针:
struct MImpl
{
std::shared_ptr<Image> bgImg; //当前的背景图案
int imgChanges; //图案被改变的次数
};
//多线程环境下使用
class Menu
{
private:
std::mutex mtx; //互斥器
std::shared_ptr<MImpl> pImpl;
public:
//改变背景图像
void ChangeBackground(std::istream& imgSrc)
{
using std::swap;
Lock m(&mtx);
std::shared_ptr<MImpl> copy(new MImpl(*pImpl)); //copy original data
copy->bgImg.reset(new Image(imgSrc));
copy->imgChanges++;
swap(copy, pImpl); //swap
}
};
- 这里使用
struct
而没有使用class
,这是因为Menu
的数据成员的封装性已经由pImpl
的private
属性保证,所以可以不必将MImpl
定义为class
。如果需要,可以将struct MImpl
定义在Menu
类的内部,但是这样又会带来打包问题。 - copy and swap 是处理异常安全的常用办法,但是一般情况下,它不能保证所有的函数都是强异常安全型的。比如说下面这样:
void ChangeBackground(std::istream& imgSrc)
{
//...
func1();
func2();
//...
}
如果函数 func1()
和函数 func2()
不是强异常安全的,那么 ChangeBackground
就很难是异常安全的。所以,一个函数的异常安全级别,不会高于它所调用的所有函数中的最低安全级别。
- copy and swap 还存在一个效率问题。你需要为原有的数据进行拷贝,然后再拷贝对象上进行数据改动,最后还要进行交换动作,这就需要时间和空间。