在C++中,定义了标准异常类,我们可以使用它们来进行异常处理。它们的关系如下:基类是exception,他有4个派生类:bad_alloc,bad_cast,runtime_error,logic_error;其中runtime_error有3个派生类:overflow_error、underflow_error、range_error;logic_error有个派生类:domain_error、invalid_argument、length_error以及out_of_range。
异常处理的基本流程是:由待检测的部分抛出一个对象,此后,程序不会执行throw后面的语句,(如果你在throw前面new了一个指针,而在throw后才delete,那么就悲剧了!这个问题我们稍后再讨论)而是转到匹配的catch语句处理。catch处理完以后,执行与try相关的最后一个catch后面的语句。
先看一个简单的例子:
Sales_item operator+(const Sales_item& lhs,const Sales_item& rhs) { if(!lhs.same_isbn(rhs)) throw runtime_error("data must refer to same ISBN "); Sales_item ret(lhs); ret += rhs; return ret; } int main() { Sales_item trans1("aaa",10,200); Sales_item trans2("bbb",5,100); Sales_item trans3; try{ trans3 = trans1 + trans2; }catch(const runtime_error &e) { cerr<<e.what()<<" try agian! "<<endl; } cout<<trans3; return 0; }
异常处理有3个关键点:抛出异常时会发生什么,捕获异常时会发生什么,以及用来传递错误对象的含义。在这里,抛出的是一个runtime_error类的异常,并用string对象来初始化这个异常。然后程序将跳转到对应的catch语句处(后面再讨论如何跳转),然后在多条catch语句中寻找第一条匹配的,然后通过传递给catch语句的实参,进行异常处理。这里的处理很简单,打印runtime_error对象的what函数的返回值,这个返回值是初始化runtime_error的C风格的字符串。
注意:在处理异常时,抛出异常的块中的局部存储不存在了。而被抛出的对象不是局部储存的,而是用throw表达式初始化一个异常对象,这个对象由编译器管理,驻留在可能被激活的任意catch都能访问的空间。由于异常抛出时都进行了一次副本拷贝,因此异常对象必须是可以复制的。
通常抛出的类型是任意的,只要我们在catch捕获相同的类型,你完全可以写成:
throw string("data must refer to same ISBN ");
而在catch中:
catch(const string &e) { cerr<<e<<" try agian! "<<endl; }
但有一点要注意:抛出类型的静态类型决定了异常对象的类型。通常这并没有任何问题,但是当你抛出的是对指针的解引时,问题就来了。根据动态绑定,假设你指向的是一个派生类成员,但是指针的类型却是基类,那么就只能抛出基类部分。如果你抛出的不是指针的解引,而是指针本身,问题就更大了:因为抛出指向局部对象的指针时后,并不能保证这个对象还存在。
那么到底是如何查找到catch的呢?是通过一种成为栈展开的方式:先检查throw是不是在try的内部,如果是,检查与该try相关的catch句子,看有没有匹配的,如果有,处理该异常。举一个例子:
try{ if(!lhs.same_isbn(rhs)) throw string("data must refer to same ISBN "); } catch(const string &e) { cerr<<e<<" try agian! "<<endl; }
但这种情况其实比较少见,因为我们定义异常处理的本意,就是将异常的检测和处理过程分离,要不然的话,我们可以直接:
if(!lhs.same_isbn(rhs)) { cerr<<"data must refer to same ISBN"<<endl; exit(0); }
如果找不到,就退出当前函数(释放资源、撤销局部对象),在调用函数中查找。这就是我们例子中的方式。
在栈展开的过程,会自动撤销资源。但是如果一个块直接分配资源,并在释放之前异常,那么栈栈展开期间都不会释放资源。如果释放的类类型,那么会调用该类的析构函数,如果析构函数也发生异常,那么会怎么样呢?这会导致调用标准库的terminate函数,而这个函数将调用abort函数,导致整个程序非正常退出。
具体是哪个catch呢?这取决于异常说明符的类型与throw类型的匹配。如果有多个符合条件的匹配,则选择第一个catch(而不是最匹配的catch)。其实这种匹配是非常严格的,不能存在隐式类型转化,只存在const与非const之间的转化,数组与指针的转换,以及派生类到基类的转化。正因为如此,当catch的异常说明符是派生类的引用时,也可以捕获基类的异常对象,从而发生匹配。所以,当我们写catch时,一定要把派生类异常说明符的catch写在前面,而把基类的写在后面,否则每次catch到异常,都会交由异常说明符为基类类型引用的catch来处理,举一个例子,对于原始的例子,发出的是throw runtime_error("data must refer to same ISBN ");如果异常处理程序这么写:
try { trans3 = trans1 + trans2; } catch(exception e) { cerr<<"exception "<<endl; } catch(const runtime_error &re) { cerr<<" runtime_error "<<endl; } catch(overflow_error eobj) { cerr<<" overflow_error "<<endl; }
那么打印的结果是第一条语句,虽然第二条更加匹配,但是编译器总是选择匹配的第一条语句。在在这种情况下,我们通常是把派生类类型的异常说明符写在前面,而把基类的写在后面。
如果有的异常在这个层次中不能处理,那么我们可以通过throw;语句重新抛出该异常。注意:异常的类型是原来异常的对象,不是catch的形参:
try{ try { trans3 = trans1 + trans2; } catch(exception e) { cerr<<"exception "<<endl; throw; } } catch(const runtime_error &re) { cerr<<" runtime_error "<<endl; }
通常由于我们不知道到底会抛出什么异常,可以通过catch(...)来捕获所有异常:
try{ try{ try { trans3 = trans1 + trans2; } catch(exception e) { cerr<<"exception "<<endl; throw; } } catch(const overflow_error eobj) { cerr<<" overflow_error"<<endl; } } catch(...) { cout<<"error"<<endl; }
程序有一点需要注意:程序触发的异常是runtime_error,catch(exception e) 会捕获它并重新发出该异常,这个异常catch(const overflow_error eobj)处理不了,而是通过catch(...)来处理的。显而易见的,当使用多个catch时,catch(...)必须放在最后一个。
通常,类的构造函数容易发生异常。对于构造函数,很可能发生异常,而对于构造函数列表中初始化的变量,检测异常需要一些特殊的格式,举一个例子:
template <class T> class Foo { public: Foo(const T& v) //构造函数初始化列表 try:val(v),ptr(new T(v)) { //构造函数体 cout<<"构造函数"<<endl; }catch(const std::bad_alloc &e){cout<<"初始化失败"<<endl;} ~Foo(){delete ptr;} void get(){cout<<val<<"\t"<<*ptr<<endl;} private: T val; T *ptr; };
因为构造函数列表是执行在构造函数体之前的,所以要把try放在列表之前,这样的话就可以从整个构造函数中抛出异常了。
通常我们可以定义自己异常类,也可以从标准异常类中继承下来:
class isbn_mismatch: public std::logic_error { public: string left,right; explicit isbn_mismatch(const string &s):logic_error(s){} isbn_mismatch(const string &s,const string &lhs,const string &rhs): logic_error(s),left(lhs),right(rhs){} virtual ~isbn_mismatch() throw(){} };
那么重载+操作符需要修改为:
if(!lhs.same_isbn(rhs)) throw isbn_mismatch("ISBN mismatch ",lhs.book(),rhs.book());
在main函数中修改为:
try{ trans3 = trans1 + trans2; }catch(const isbn_mismatch &e) { cerr<<e.what()<<": left ISBN: "<<e.left<<" right ISBN: "<<e.right <<endl; }
我们前面提到过,如果我们在析构函数中释放了资源,那么即使出现异常,在撤销类的时候会调用析构函数而释放资源。如果new了一个指针,而在delete之前发生了异常,那么这个指针将不会被撤销。这促使我们使用更安全的编程策略:异常安全的编程技术—即使异常发生,程序也能正确操作。我们通过一个类来封装资源的分配和释放,而不是传统的把数据直接暴漏出来。这样即使出现异常,类的析构函数也能释放资源。
在C++标准库中,定义auto_ptr类,这个类就完成了上述工作,它在memory头文件中定义先看一个简单的例子:
void f1() { int *ptr = new int(1024); throw std::runtime_error (" 故意引起的错误"); delete ptr; } void f() { auto_ptr<int> pi(new int(1024)); throw std::runtime_error (" 故意引起的错误"); }
通过对比它的使用就一目了然了,但是我们需要注意:
1.auto_ptr只能管理new 的指针,不能管理动态分配的数组。更不能用它保存指向静态对象的指针。因为当auto_ptr被撤销时,会试图删除这个指针,从而发生错误。
2.必须使用初始化的方法将auto_ptr对象与指针绑定auto_ptr<int> pi(new int(1024));而不能使用赋值操作符:auto_ptr<int> pi = new int(1024)。
3.这个类重载了解引和箭头操作符,很方便我们的使用:
auto_ptr<Sales_item> pi(new Sales_item("aaa",10,20)); pi->book();
4.有一点需要特别注意:auto_ptr对象的复制和赋值是破坏性操作:这与内置的指针有很大的区别。当复制auto_ptr对象或者用它给别的auto_ptr对象赋值时,基础对象的所有权从原来的对象转化成了新的对象,原来的对象处于未绑定状态。对于赋值操作,不仅将基础对象的所有权从右操作数传给了左操作数,还删除了左操作的基础对象。因此,我们不能将它储存在容器中,容器要求存储的类型在复制(或者赋值)后,两个对象的指针要相等,而auto_ptr会删除其中的一个对象。
5.必须使用get函数来测试auto_ptr是否绑定,不能直接if(p)来判断指针是否有效,;必须使用reset给对象重新赋值不能直接 p = new int (1024);将地址赋给auto_ptr对象,需要使用reset函数:
int main() { auto_ptr<int> p (new int (1024)); auto_ptr<int> p1 = p; //if(p) if(p.get()== 0) // p = new int (1024) p.reset( new int( 1000)); cout<<*p1<<endl; cout<<*p<<endl; return 0; }
异常说明:如果函数抛出异常,被抛出的异常将是包含在该说明中的一种。我们可以在形参列表之后加上throw,再跟上(可能为空的)异常类型列表:
void divide(int dividend = 0,int divisor = 0) throw(std::runtime_error) { if(! divisor) throw runtime_error("divisor must not equal to 0! "); cout<<dividend/divisor<<endl; }
这表明该函数如果抛出一个异常,那么这个异常将是runtime_error或者是它的派生类。如果没有异常说明,则表示他可以抛出任何类型的异常;如果列表为空,则不抛出任何异常。
如果程序抛出了没有在异常列表中列出的异常,那么就会调用标准库中的unexpected函数,这个函数调用terminate函数终止程序。编译器不会检查异常说明与抛出的异常是否匹配。这样看来异常说明有点鸡肋,但是有一种情况却可以使用它:如果函数可以确保不抛出任何异常时,我们将throw后面的异常类型为设为空。此时,这个函数就可以执行被可能抛出异常代码所抑制的优化。
注意,当一个函数提供了异常说明后,异常说明也是这个函数类型的一部分,在指向函数的指针中会用到。此时,除了形参,返回值之外,指针抛出的类型也是衡量匹配的标准之一,我们要确保:指针抛出的类型必须更加宽松:指针声明为可以抛出任何异常,函数声明为抛出runtime_error,这样是可以的。如果指针声明不抛出异常,那么函数就不能抛出异常了,否则无法用这个函数给指向函数的指针赋值。同样的情况出现在指针的赋值中:源指针的异常说明必须至少与目标指针一样严格。
在处理继承关系时,我们要确保,派生类的异常说明类型不能比基类“宽松”:基类函数抛出的异常是派生类的超集。举个例子:
class Base { public: virtual double f1(double) throw(); virtual int f2(int) throw(std::logic_error); virtual string f3()throw (std::logic_error,std::runtime_error); }; class Derived:public Base { public: //错误,基类不抛出,而派生类抛出 double f1(double) throw(std::underflow_error); //正确:二者级别一样 int f2(int) throw(std::logic_error); //正确:派生类的是基类的特例 string f3()throw (std::logic_error); };