前言:
本篇有关于C++异常安全性,但对异常安全性的讨论并不只限制于C/C++语言.
分析一下下面的代码
class PrettyMenu{
public:
...
//改变背景图像
void changeBackground(std::istream& imgSrc);
...
private:
//互斥器
Mutex mutex;
//目前的背景图像
Image* bgImage;
//记录背景图像改变次数
int imageChange;
}
PrettyMenu类用来表现背景图案的GUI菜单单,互斥器作并发控制(多线程环境下).
下面是PrettyMenu的changeBackground(std::istream& imgSrc)函数的一个实现(分析安全性缺陷,实际存在多种实现可能).
void PrettyMenu::changeBackground(std::istream& imSrc){
lock(&muex); //取得互斥器
delete bgImage; //资源释放 释放旧背景
++imageChange; //递增改动次数(前置优于后置不解释)
bgImage=new Image(imgSrc); //安装新的背景图像(输入流参数传入)
unlock(&mutex); //操作完成释放互斥器
}
在分析这个程序是否有错误之前,可以先介绍一下异常安全性的函数的基本特性。
当异常被抛出时,带有异常安全性的函数通常会有如下表现:
- 不泄露任何资源。(指资源无法及时释放产生的资源泄露)
- 不允许数据败坏。(指数据对象由于异常抛出而卡在了一半更新而一半未更新的尴尬境地)
已上述例子为例:
分析一下,假设new Image(imgSrc)抛出异常
(因为存在内存耗尽的可能 但这里可以用new(nothrow)Image(imSrc)来避免抛出std::bad alloc异常 使bgImage获得一个空指针 但为了分析问题 我们先假设他会抛出异常)
那么这里的异常抛出会使 unlock(&mutex)这行代码永远不被执行,对吧,那么造成了互斥器永远被把持的问题.这里存在资源泄露问题.
同样还是这个异常,观察一下.递增计数语句在抛出异常语句的之前,异常的产生导致了新的图片无法更新,但图片次数递增了.这里是资源败坏.(数据次数与实际更新结果不一致)
可以说这个程序毫无安全性可言.
整理下思路,互斥器资源泄露如何解决?可以将互斥器的获得释放整合为一个资源类Lock如下(资源管理类)
void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock ml(&mutex); //资源管理类Lock统一了互斥器的获取和释放
delete bgImage;
++imageChange;
bgImage=new Image(imgSrc);
}
class Lock的设计如下
class Lock{
public:
explicit Lock(Mutex* pm)//单参构造函数 抑制隐式类型转换
:mutexPtr(pm) //构造函数初始值列表(避免了赋值开销)
{lock(mutexPtr);} //构造后获得互斥器
~lock(){unlock(nutexPtr);}//析构前释放互斥器
private:
Mutex *mutexPtr; //私有数据成员 指向Mutex指针
}
1.这样假设不抛出内存不足异常,互斥器通过Lock类的构造获得,函数结束,出作用域自动调用析构函数释放.
假设不幸存在异常抛出 ,获取不说了,异常抛出前就能获取,异常抛出后我们也能做到将资源很好的释放(异常处理导致析构函数调用),解决了资源泄露的问题.(这里笔者觉得类似于STL的shared_ptr的删除器)
资源泄露解决了,那么在解决资源败坏的问题之前,我们可以先了解一下异常安全函数的相关术语.
异常安全函数(Exception-safe funciton)提供以下三个保证之一:
-
基本承诺:如果异常被抛出,程序内的任何事物仍保持在有效状态下.(一般指状态改变前后一致.例如不存在类似不男不女的可能,但无法对究竟是何种可能做任何假设)
-
强烈保证:如果异常被抛出,程序状态不改变.(函数调用成功,就是完全成功.调用失败,程序保证回复到调用函数之前状态.类似于转账成功,完全成功。转账失败,回到转账之前的状态。)
-
保证不抛出异常(nothrow):承诺绝不抛出异常。(这个可以通过关键字noexcept实现)目前可知的是内置类型都有nothrow的保证。
那么很显然,(3)类的级别最高,但现实中除非你能保证你计算机一定能满足每次new的内存需求(如STL容器添加数据),否则bad_alloc异常你将无法解决。而且现如今我们也很难在C Part of C++领域中不调用一个完全不抛出异常的函数。
但利用noexcept关键字可以在部分情况下,提升代码执行效率。(如pimpl手法置换swap函数的异常安全性保证)。
回到问题!
通过修改代码的执行顺序(先改后计数,而不是先计数后改),以及将PrettyMenu的bgImage的内置指针改为shared_ptr,让这个函数提供强烈保证(内置指针潜在问题,智能指针通过引用计数,可以自动析构释放).
内置指针修改如下:
class PrettyMenu{
...
std::tr1::shared_ptr<Image>bgImage;//class与struct的唯一区别就在默认private,和默认继承private.
...
}
void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock m1(&mutex); //不作改动 资源管理类Lock
bgImage.reset(new Image(imgSrc));//注意这里的改动
++imageChange; //这里改变了执行顺序
}
注意一下代码的第二行
首先分析执行顺序
1.new Image(imgSrc)
引用输入流imgSrc做参数分配内存空间 这里返回一个临时的内置指针imgSrc*;
2.bgImage.reset(imgSrc*)
shared_ptr的单参数reset函数 将bgImage改为imgSrc*(实际将的智能指针与图片指针做了替换,这里智能指针不能直接被内置指针赋予).
可以看出
原先的旧图像并不需要delete,这个操作会由智能指针内部完成(delete在reset内部完成)。
程序执行的先后顺序也保证了reset函数只会在new成功之后执行.
执行流程如下
new失败->reset不执行->不会delete
new成功->reset执行->reset内部delete
出作用域shared_ptr会递减计数并判断,若计数为零自动析构.
这里唯一存在的问题是istream&可能会抛出异常 这个异常可能是由于Image的构造函数异常导致读取记号移走 (构造失败导致读取失败,所以这里可以改数据类型以提供更好的异常安全性).
这里介绍第二种方式实现异常安全性的强烈保证:
copy and swap
我们不希望修改原数据的同时会抛出异常来导致数据败坏或数据泄露.
那么可以不修改原数据,将原数据复制一份副本,对副本进行修改不会对原数据产生影响,
就可以保证数据不败坏。修改副本(新数据)之后再与原数据做swap置换。
下面我们用pimpl idiom手法实现(将原PrettyMenu的数据放入另一个对象,在PrettyMenu中添加指向该对象的指针)
使用这个手法用于降低文件间的编译依存关系 条款31详述
实现如下:
//新对象放入PrettyMenu的shared_ptr指针,和递增数
struct PMImpl{
std::tr1::shared_ptr<Image>bgImage;
int imageChanges;
}
class PrettyMenu{
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl>pImpl;//注意这里的shared_ptr指向
}
changeBackground实现改变:
void PrettyMune::changeBackground(std::istream& imgSrc){
using std::swap;//以前讲过这个代码作用
Lock m1(&mutex);//资源管理类 互斥器
//这里是复制原数据作副本
std::tr1::shared_ptr<PMImpl>pNew(new PMImpl)(*pImpl); //pImpl指向资源数据 解引用获得数据并分配内存空间
pNew->bgImage.reset(new Image(imgSrc));//通过函数参数获得修改图片 并替换原图片 这里异常安全性不分析了上面有
//这样pNew这个shared_ptr指针将副本获得并修改图片数据 下面做计数递增
++pNew—>imageChanges;
//这里新旧替换 旧的会换入PNew指针指向的对象内
swap(pImpl,pNew);
//那么由于pNew是shared_ptr 出作用域析构 这里pNew指向对象是唯一一个 所以计数递减至零 自动析构
//资源类互斥器也同样会析构
}
这里数据封装选用struct的原因是PrettyMune的数据封装性已由于pImpl是private提供了保证,class有时不太方便(class默认private)
关于copy and swap的使用缺陷
考虑下面这个环境
void someFunc(){
... //对local状态做一份副本
f1(); //这里可能存在改动
f2();//这里同样也可能存在改动
...//改动结束 对象副本与原对象置换
}
首先介绍术语:
根据只对调用者产生影响或对调用者以外的数据产生影响可以分为局部性数据和非局部性数据。
对于局部性数据(local state),列如someFunc只对其调用者产生影响(假设),那么我们容易提供强烈安全性保证.
但如果该函数对非局部性数据(non-local data)有连带影响(side effects)时,很难提供强烈安全性保证.
比如这里的f1如果是影响某个数据库改动的,那么当我们完成这一系列操作时,数据库用户很可能已经看到了数据的改动。
那么根据强烈安全性保证,函数调用成功或失败只能有其中一种情况的结果产生,但若f1执行后的异常抛出,我们做不到恢复数据库旧观.(指调用函数前的状态,因为数据库已经改了且有可能已看到)
我们有时无法对类似f1函数调用导致数据影响做恢复,即便函数内部真的出现了异常.
同样f1,f2函数如果不提供异常安全保证,对于调用他们的我们也可能会产生资源泄露和数据败坏问题.
在另一个方面,效率.
copy and swap 首先需要复制对象的副本,那么对象增大同样也会导致副本复制的时间增长以及产生内存的使用空间增大.复制的问题同样会对swap置换产生极大影响(swap是template 详见STL源码剖析)。
最后作为程序设计者 理应在设计前考虑该为程序提供三种里哪种异常安全保证(尤其是C++程序员更应考虑全面,资源泄露导致的问题数不胜数).接口如果使用传统代码,同样会使我们的程序"无任何异常安全保证".函数的异常安全性保证也理应是可见接口的一部分,应该慎重选择!
上述设计理念出自EffectiveC++
byCore