处理在程序的运行时刻发生的错误,对于任何一个程序设计者来讲都是不陌生的。对于错误的处理,我们有很多方法,本篇着重介绍的是C++中的错误异常处理。
在介绍C++中的错误异常处理之前,我们先来看一下常用的错误处理方式。
1.返回值 |
可以说这是最常用的错误处理方式之一,但其存在着一个致命的问题。就是返回值的检查与否是由调用者主动控制的。如果调用者不检查返回值,那也没有任何 机制能够强迫他这么做。再一个,考虑在C++中参数表相同而返回值不同的重载情况。在这种情况下,如果调用者不检查返回值的话,编译器根本不清楚应该调用哪个函数。 |
2.全局状态标示符 |
这种办法同返回值一样,也是需要调用者主动检查的。并且由于其是全局的,因此在多线程程序中,还必须保证它的线程安全性,必须要让检查者知道这是谁的返回值。 |
3.setjmp()/longjmp() |
你完全可以将longjmp()当成远程的goto语句进行调用(goto语句只能左右于本地函数里)。但这个函数却存在着很大甚至是致命的危险。暂且放下该函数会破坏结构化程序设计风格不说。其一,longjmp()只能处理int型的异常。其二,也就是最致命的一点就是,longjmp()不会调用析构函数,而C++的 异常处理机制却会完成这个事情。因此,在C++中,千万不要使用setjmp()、longjmp()函数。 |
4.断言 |
对于断言(Assert),其仅仅是在Debug版本中起作用,在Release中其是不存在的。另外断言与我们通常所说的错误处理方式不同,他是用来处理我们可能会发生这个错误,并能够避免的这种情况。 |
在介绍过上面那些存在问题的错误处理方式后,现在让我们来看看C++中的异常机制是如何处理错误的。首先说,C++的异常处理不会像上面提到的那些方法一样,必须是调用着主动检查。因为在C++中,一旦抛出(throw)一个异常,而程序不捕获(catch)的话,那么最终的结果就是abort()函数被调用,使得程序被终止。
下面我们来看一下C++异常处理(以下称EH)的基本语法和语意。 其引入了3个关键字,分别是:
catch, throw, try
throw
异常由throw抛出,其格式为
函数在定义时通过异常规格申明定义其会抛出什么类型的异常,其格式为:
type-ID-list是一个可选项,其中包括了一个或多个类型的名字,它们之间以逗号分隔。
例如:
void func() throw(int, some_class_type) |
则表明会抛出int和some_class_type类型异常。
对于一个空的异常规格申明,表示不抛出任何异常。 如:
而如果函数没有异常规格申明,则表示会抛出任何类型的异常。 不过这里存在一种情况,例如:
void func() throw(int) //指明抛出int型异常 { ... subfunc(); //但可能从这里抛出非int型异常 ... } |
try -- catch
try块中的异常处理函数对异常进行捕获。其可以包含一个或多个处理函数,其形式如下:
catch (exception-declaration) compound-statement |
处理函数的异常申明指明了其要捕获什么类型的异常。 对于异常申明其可以是无名的,例如:catch(char *),其表明会捕获一个char *类型异常,但由于是无名的,因此不能对其进行操作。另外异常申明也可以存在如下形式:catch(...),其表明会捕获任何类型的异常。
举例:
void func() throw(int, some_class_type) { int i; ........ throw i; ........ }
int main() { try { func(); } catch(int e) { //处理int型异常 } catch(some_class_type) { //处理some_class_type型异常 } ....... return 0; }
|
从上面的例子可以看出,当函数抛出异常时,throw后面要带一个抛出的对象。但这并不是必须的,例如:
catch(int e) { ....... throw; } |
throw后面没有接任何对象,这表明throw会再次抛出已存在的异常对象,因此其必须位于catch块中。
下面介绍一些C++提供的标准异常
namespace std { //exception派生 class logic_error; //逻辑错误,在程序运行前可以检测出来
//logic_error派生 class domain_error; //违反了前置条件 class invalid_argument; //指出函数的一个无效参数 class length_error; //指出有一个超过类型size_t的最大可表现值长度的对象的企图 class out_of_range; //参数越界 class bad_cast; //在运行时类型识别中有一个无效的dynamic_cast表达式 class bad_typeid; //报告在表达试typeid(*p)中有一个空指针p
//exception派生 class runtime_error; //运行时错误,仅在程序运行中检测到
//runtime_error派生 class range_error; //违反后置条件 class overflow_error; //报告一个算术溢出 class bad_alloc; //存储分配错误 }
|
在C++标准库头文件<exception>中申明了几个EH类型和函数,它们是:
namespace std { //EH类型 class bad_exception; class exception;
typedef void (*terminate_handler)(); typedef void (*unexpected_handler)();
// 函数 terminate_handler set_terminate(terminate_handler) throw(); unexpected_handler set_unexpected(unexpected_handler) throw();
void terminate(); void unexpected();
bool uncaught_exception(); }
|
exception |
是所有标准库抛出的异常的基类。 |
uncaught_exception() |
函数在异常被抛出却没有被捕获时返回true,其它情况返回false |
terminate() |
在异常处理陷入了不可恢复状态,如:重入时被调用。 |
unexpected() |
在函数抛出一个没有在“异常规格申明”中申明的异常时被调用。 |
运行库提供了缺省terminate_handler()和unexpected_handler()函数处理对应的情况。你可以通过set_terminate()和set_unexpected()函数替换库的默认版本。这两个函数,其可以获取不带输入输出参数的函数,并且该函数会返回原terminate或者unexpected函数的地址指针。以便在使用中调用或者以后的恢复。另外,在terminate ()中。其必须不返回或者抛出异常。
在介绍了EH的基本知识后让我们来看看EH是如何工作的。
一般来说当发生函数调用的时候,都会进行诸如,保存寄存器值,参数压栈,创建被调函数堆栈等保护现场的工作,而在函数返回的时候则会进行与此相反的恢复现场的工作。
这样,当一个异常发生时,程序会在异常点处停止,然后开始搜索异常处理函数,其过程同函数返回相同,延调用栈向上搜索,直到找到一个与异常对象类型像匹配的异常申明,并进行相应的异常处理函数,在异常处理结束后,程序跳到异常处理函数所在try快最接近的下面一条语句开始执行。如果没有找到合适的异常申明,则最终会调用std :: unexpected(),并在其中调用std:terminate()直到abort(),程序被终止。
这也就意味着C++对于异常处理的模式始终是终止的。
例如:
#include <iostream.h> static void func(int n) { if (n) throw 100; }
extern int main() { try { func(1); cout<<"程序不会执行到这里"<<endl; } catch(int) { cout<<"捕获一个int型异常"<<endl; } catch(...) { cout<<"捕获任意类型异常"<<endl; }
cout<<"继续执行"<<endl;
return 0; }
|
该程序在运行时会打印如下信息:
捕获一个int型异常 捕获任意类型异常
至于异常处理的另一种模式恢复模式。可以通过循环检测直到结果满意为止。但在实际中,往往产生异常的地方与异常处理函数距离可能会比较远,在这种情况下恢复模式就不那么可行了。
虽然,在异常处理延调用栈向上走的过程中回析构所有栈上的对象,但其并不会对堆中的对象进行处理,这样将会引起严重的资源泄露问题。
例如:
void func() { testclass *p = new testclass(); ... test(p); //这里会抛出异常 ... delete p; //在抛出异常后,这里不会被执行,因此会导致内存泄露问题。 }
|
为了解决这个问题,C++提供了std::auto_ptr模板。其原理就是,将指针用一个栈上的模版实例保护起来,当发生异常的时候,模版会被析构,在析构函数中指针也就被delete了。
例如:
void func() { std::auto_ptr<testclass> p(new testclass()); ... test(p.get()); ... } |
另外,在构造函数中抛出异常并不会引发析构函数。这一点要十分注意。因为这也会产生资源泄露问题。
例如:
class test { public: test() { c = new char[10]; throw -1;} ~test() {delete c;}
private: char *c; };
void proc() { try{ test t; } catch(int) { ....... } }
|
由于异常是在test的构造函数中产生的,因此其不会引发其析构函数的调用。于是就如程序所示,产生了内存泄露问题。对于这种问题,最好的解决办法还是使用auto_ptr。
对于析构函数,则不要在其中抛出异常。其原因在于析构函数会在其他异常抛出时被调用,这样就会引发异常的重入问题,进而导致terminate()被调用。如果在析构函数中真要抛出异常,如:析构函数调用的函数会抛出异常等,则必须在该析构函数内将其捕获。
前面说到要“找到一个与异常对象类型像匹配的异常申明”。事实上,这种匹配并不要求的十分准确。 考虑如下例子:
#include <iostream.h>
class base { public: virtual void what() { cout << "base" << endl; } };
class derived: public base { public: void what() { cout << "derived" << endl; } };
void f() { throw derived(); }
main() { try { f(); } catch(base b) { b.what(); }
try { f(); } catch(base& b) { b.what(); } }
|
其显示结果为:
base derived
为什么会这样呢。因为如果异常抛出一个派生类对象,而恰好又其基类所捕获到。那么该对象会被做"切片"处理。也就是说相对于基类,派生元素会被割下。在例子中derived的vptr会被设为base的virtual table。因此虚函数what就会呈现出这种行为。而当通过引用捕获时,得到的仅仅是其地址,对象不会被做切片处理。vptr因此也就不会发生变化,所以what仍然呈现出来derived的行为。
因此,这也就提醒我们将基类处理放在最后,在实际中更有意义。因为这样可以尽可能的在前面的处理中保存信息。
|