异常是一种能够处理非正常行为的机制,也是异常产生端和异常处理端通信的一种方式。在软件开发时,通常的情况是,一方知道会发生何种异常但不知道如何处理,另一方不知道是否会发生异常,但在发生异常时它能够进行处理。所以,C++提供了异常处理机制。允许库的开发者在发生异常时抛出异常,而库的用户捕捉异常并进行合理的处理。
C++中与异常处理相关的关键字有:
throw:抛出异常。
try:测试某个程序块是否会抛出异常。
catch:对异常进行捕捉,然后处理。
因此,一般的异常处理程序的框架是:
库的开发者端:
void throw_test()
{
if(something_is_bad) {
throw exception;
}
}
void catch_test()
{
try {
throw_test();
} catch(exception & e) {
// exception handler
}
}
1 捕捉端是否要使用引用
读者看到上面捕捉异常时使用的是引用,那么,是否一定要使用引用呢?
一般传递参数的方式有三种:传递指针、传递值、传递引用。其实传递指针和传递值是类似的。
在捕捉异常时,如果采用指针传递,抛出异常后,就会脱离当前作用域,因此,就必须保证指针所指向的对象依然存在,可以有三种方式来保证:static对象、全局对象、堆上的对象。如果采用static和全局变量,这通常是不符合程序员的习惯,因为每次传递一个异常都必须声明一个static和全局变量,这当然不是良好的编程风格。如果采用堆上的对象,在抛出端进行new,那何时进行delete呢?用户必须保证在最后进行处理之后对对象进行delete,这对程序员产生了极大的负担。
在捕捉异常时,如果采用值传递,就必须产生两次拷贝,而且没有多态行为。具体的请看第2部分。
因此,在抛出对象时,应该尽量使用引用传递。采用引用传递没有上面的问题。
2 throw之后究竟发生了什么?
采用引用传递,通常在抛出端会有下面两种形式:
derived d;
throw d;
throw derived();
当throw一个对象时,编译器会在堆上用抛出的对象复制构造一个临时的对象(G++的实现),因此,就算离开了当前作用域,在堆上同样有一个对象是抛出对象的副本。此时,根据捕捉端会有不同的行为:
由于传递指针在异常处理中通常是不合习惯的,因此,这里并不讨论传递指针。
如果捕捉端是以catch by value捕捉异常:
void catch_test()
{
try {
throw_test();
} catch(exception e) {
// exception handler
}
}
如果捕捉端是以catch by reference捕捉对象:
void catch_test()
{
try {
throw_test();
} catch(exception& e) {
// exception handler
}
}
而如果以值来捕捉异常,抛出的是派生类的对象,捕捉时用的是基类的对象,此时,会发生对象切割,没有多态行为。(跟对象的参数传递类似)
这也验证了第1部分中最好以引用捕捉异常。
3 异常说明
异常说明是对一个函数抛出的异常的类型进行说明的一种方式,它能够表明该函数抛出哪些类型,在处理端就能够预料需要处理哪些异常。
异常说明通常有三种形式:
void func() throw (); //不抛出异常
void func() throw (type1, type2); //抛出类型为type1和type2的异常
void func(); //抛出任何类型的异常,网上有些文章说抛出任何类型的异常可以是void func() throw (...);但是在g++编译不通过
因此,在编写函数的时候,如果确定某个函数会抛出某个类型的异常,就可以加上异常说明来说明该函数抛出的异常类型。
当然,如果一切正常工作当然很好,函数抛出某种类型的异常,调用端就处理什么样的异常,但是,如果函数抛出的异常与函数的异常说明不符会发生什么情况呢?
如果函数抛出的类型与异常说明不符,就会调用一个特殊的函数unexpected(),它的默认行为是调用terminate(),而terminate()的默认行为是abort(),也就是如果一个函数违反了自己的异常说明,抛出与异常说明不符的异常,程序就会直接结束。
但是,异常说明通常是一个提示作用,告诉程序员该函数会抛出哪些异常,程序员在调用端需要处理哪些异常,如果函数常常抛出与异常说明不符的异常,那么,异常说明就没有用了,因为它完全起不到提示程序员的作用。
如下面的例子:
void catch_test(int x) throw (int)
{
if(x == 0) {
throw 3.0f;
}
throw 0;
}
解决的办法有三种:
(1)修改异常说明。
(2)调用set_unexpected()修改函数抛出异常与异常说明不一致时的回调函数,将不一致的异常修改为统一的异常。
class unexpected_exception {
};
void convert_unexpected()
{
cout << "throw unexpected exception" << endl;
throw unexpected_exception();
}
void catch_test(int x) throw (int, unexpected_exception)
{
if(x == 0) {
throw 3.0f;
}
}
int main()
{
set_unexpected(convert_unexpected);
try {
catch_test(0);
} catch(int x) {
cout << x << endl;
} catch(unexpected_exception &x) {
cout << "catch unexpected exception" << endl;
}
}
在主函数中对catch_test()进行异常检查,当catch_test()的参数是0时会抛出float类型的异常,与异常说明的int不一致,因此,当调用catch_test(0)时,会调用convert_unexpected()函数,它会重新抛出unexpected_exception异常,在catch时可以对它进行处理。
这里要注意的是,在函数的异常声明中同样要加上unexpected_exception类型,因此,这里只是将unexpected()进行了一个替换,做了一个转换而已。
(3)在替换unexpected函数的函数中重新抛出该异常,该异常会被作为标准异常bad_exception重新抛出。
class unexpected_exception {
};
void convert_unexpected()
{
cout << "throw unexpected exception" << endl;
throw;
}
void catch_test(int x) throw (int, bad_exception)
{
if(x == 0) {
throw 3.0f;
}
}
int main()
{
set_unexpected(convert_unexpected);
try {
catch_test(0);
} catch(int x) {
cout << x << endl;
} catch(bad_exception &x) {
cout << "catch unexpected exception" << endl;
}
}
4 关于异常需要注意的问题:
(1)在大型项目中,函数的调用层次比较深,程序员根本无法预料某个函数会抛出哪些异常,因为,它所调用的函数抛出的异常也会转嫁到它自己。在这种情况下,调用unexpected()的几率大大增加,如果一个函数认为自己不抛出异常,但是它调用的函数会抛出异常,那么,就会有3中的不一致问题。
(2)将异常说明与模板结合不是一个好主意,因为,模板的行为多变,更加不可预料。
(3)使用异常还需要考虑异常的成本,使用try语句进行异常的测试,当离开try时某些对象要进行销毁,因此,程序必须记住try语句开始和结束的位置,以便进行合理的处理。使用throw抛出一个异常会在堆上建立一个临时的异常对象,编译器负责对它进行销毁。这都是异常处理需要考虑的成本。所以,当确定程序并不抛出异常时,坚决不用异常处理,只在万不得已的情况下,才使用异常处理。
参考资料:
More Effective C++ 第三章:异常