昨天对C++异常处理的机制进行了整理,今天再把异常机制的一些注意事项给整理一下,希望牢记于心,写出正确高效的代码。
C++ 增加了异常机制之后,深远而根本的改变了许多事情。比如,原始指针的使用竟成为一种高风险行为,资源泄漏的机会大大增加等。所以如何撰写异常安全的程序,需要我们有很多的学习。
这个问题就是老生常谈了,前面C++资源管理已经整理过了,在这里就不赘述了。
因为C++只会析构已构造完成的对象,如果一了对象在构造的时候抛出异常,则此对象的析构函数将不会被调用。对于对象的内部的非指针成员变量,会随着栈展开而进行资源的释放。但是对于构造函数中申请的资源,有可能造成资源的泄漏。如下:
class Myclass
{
public:
Myclass()
{
m_pImage = new Image();
m_pAudioClip = new AudioClip(); //如果这里抛出异常,则m_pImage资源泄漏
}
~Myclass()
{
delete m_pImage;
delete m_pAudioClip;
};
private:
string m_strname; //姓名
Image* m_pImage; //照片
AudioClip* m_pAudioClip; //一段录音
};
由于C++ 不会自动清理那些在构造期间抛出异常的对象,所以我们必须设计自己的构造函数在抛出异常时,能够自我清理。我们可以使用try块,进行异常捕捉,执行某种清理工作,然后重新抛出异常,使它继续传播。但这种写法比较麻烦。
最好的办法是使用前面介绍的RAII方法,把类的指针成员变量改成智能指针的形式,这样既预防了资源的泄漏,而且析构函数什么也不用做。如下:
class Myclass
{
public:
Myclass()
{
m_Image = make_share();
m_AudioClip = make_share();
}
~Myclass() { };
private:
string m_strname; //姓名
std::share_ptr m_Image; //照片
std::share_ptr m_AudioClip; //一段录音
};
两种情况下 析构函数会被调用,一种是对象在正常状态下被销毁,也就是当它离开了它的生存空间或是被明确地删除;第二种是对象随着异常传播过程的栈展开而销毁。
我们知道异常抛出之后不会执行后面正常逻辑,如果我们在析构函数中需要释放一堆资源,但是在释放第一个时抛出了异常,那么这个析构函数就不能执行完全,后面的资源造成了内存泄漏。
还有一种原因是之前整理的不让异常逃出析构函数中说明的数组问题,数组存放相同的对象,如果第一个抛出异常,后面再有一个抛出异常,会导致程序的崩溃。
解决办法是:
1、析构函数中捕捉任何异常,然后吞下他们(不传播)或结束程序。(这个并不是很好)
2、 如果需要在运行期间对抛出的异常做出反应,则应该提供一个普通函数(而非在析构函数中)执行该操作
有时我们会觉得“从抛出端传递一个异常到catch子句”,基本上和“从函数调用端传递一个自变量到函数参数”是一样的。其实,它们之间有相同之处,也有重大的不同之处。
相同之处在于,函数参数和异常对象的传递方式都有3种方式:值传递、指针传递、引用传递。
不同之处在于,一个对象被作为异常对象被抛出时,总会发生复制行为 ,即使被抛出的对象并没有销毁的危险。这就是为啥异常对象必须要有可访问拷贝函数的原因。
例如:
void passAndThrowWidget()
{
static Widget localWidget; //一个静态局部变量,声明周期直到程序结束
......
throw localWidget; //虽然localWidget没有销毁的危险,也会进行复制操作
}
异常的复制动作,其结果会产生一个临时对象,存放在系统中。在用catch捕捉的时候,其参数就是是以什么方式来使用这个临时变量。如下:
catch(Widget w) { ... } //值捕获,对临时变量进行复制
catch(Widget& w) { ... } //引用捕捉,对临时变量引用使用
通过上面我们可以得出,值捕捉需要有两个副本的构造代价,而引用捕捉只需要一次复制行为,所以引用捕捉的效率高一些。
对于指针捕捉,其实就是传递指针的副本,也会发生两次复制。但是需要注意的是千万不要抛出局部对象的指针,因为局部对象在作用域之外会析构。
还需要注意的是,异常对象复制行为是该对象的“静态类型”,而非“动态类型”。这个我们前面整理过,下面看一个例子:
class Widget { ... }
class SpecialWidget: public Widget { ... }
void passAndThrowWidget()
{
SpecialWidget localSpecialWidget;
......
Widget& rw = localSpecialWidget; //rw代表一个SpecialWidget
throw rw; //抛出一个Widget类型的异常
}
这里抛出的是一个Widget类型对的异常,虽然rw代表一个SpecialWidget类型,但编译器不关心这个事实,它只关心rw的静态类型。
下面看这个例子,看这两者有什么差异:
catch(Widget& w) //捕捉Widget异常
{
...
throw;
}
catch(Widget& w)
{
...
throw w;
}
差别有两点:
1、第一个抛出的异常,不会再产生临时对象,而第二个会在产生一个临时对象
2、第一个抛出的异常,是传入进来的异常实际类型,而第二个抛出的异常是Widget的静态类型
先来看一下其它方式的捕捉存在的问题。
指针捕捉,虽然有时指针捕捉效率是最高的,比如,异常对象占用内存很大时。但是它不能保证异常对象在抛出异常之后是否依然存在。如果我们能保证异常对象存在,我们还需要考虑异常对象释放的问题,容易造成内存的泄漏。
值捕捉,会进行两次的复制,效率低,而且它会引起切割问题,因为派生类对象被捕捉视为基类对象的,将失去派生类成分。
通过以上分析,引用捕捉是最好的方式,它不会发生对象不存在问题,而且还不会发生切割问题,对象仅被复制一次,是一个最优的选择。
为了能够在运行期处理异常,程序必须做大量薄记工作。在每一个执行点,它们必须能够确认“如果发生异常,哪些对象需要析构”,它们必须在每一个try语句块的进入点和离开点做记号,针对每个try语句块它们必须记录对应的catch子句及能够处理的异常类型。这些都需要成本。
如果抛出一个异常,和正常函数返回动作相比,其速度可能比正常情况下慢3个数量级。虽然慢但异常,只是少数发生的情况,所以可以接受。
虽然异常处理会有成本,但有时是不可避免的,所以只能尽量写出高效,健壮的代码。
好了异常就整理到这里了。
感谢大家,我是假装很努力的YoungYangD(小羊)。
参考资料:
《C++ primer》
《More Effective C++》