apollolegend 回复于:2003-08-19 13:29:04 |
2. 异常的语法
在这里我们只讨论一些语法相关的问题。 2.1 try try总是与catch一同出现,伴随一个try语句,至少应该有一个catch()语句。try随后的block是可能抛出异常的地方。 2.2 catch catch带有一个参数,参数类型以及参数名字都由程序指定,名字可以忽略,如果在catch随后的block中并不打算引用这个异常对象的话。参数类型可以是build-in type,例如int, long, char等,也可以是一个对象,一个对象指针或者引用。如果希望捕获任意类型的异常,可以使用“...”作为catch的参数。 catch不一定要全部捕获try block中抛出的异常,剩下没有捕获的可以交给上一级函数处理。 2.3 throw throw后面带一个类型的实例,它和catch的关系就象是函数调用,catch指定形参,throw给出实参。编译器按照catch出现的顺序以及catch指定的参数类型确定一个异常应该由哪个catch来处理。 throw不一定非要出现在try随后的block中,它可以出现在任何需要的地方,只要最终有catch可以捕获它即可。即使在catch随后的block中,仍然可以继续throw。这时候有两种情况,一是throw一个新类型的异常,这与普通的throw一样。二是要rethrow当前这个异常,在这种情况下,throw不带参数即可表达。例如: try{ ... } catch(int){ throw MyException("hello exception"); // 抛出一个新的异常 } catch(float){ throw; // 重新抛出当前的浮点数异常 } 2.4 函数声明 还有一个地方与throw关键字有关,就是函数声明。例如: void foo() throw (int); // 只能抛出int型异常 void bar() throw (); // 不抛出任何异常 void baz(); // 可以抛出任意类型的异常或者不抛出异常 如果一个函数的声明中带有throw限定符,则在函数体中也必须同样出现: void foo() throw (int) { ... } 这里有一个问题,非常隐蔽,就是即使你象上面一样编写了foo()函数,指定它只能抛出int异常,而实际上它还是可能抛出其他类型的异常而不被编译器发现: void foo() throw (int) { throw float; // 错误!异常类型错误!会被编译器指出 ... baz(); // 正确!baz()可能抛出非int异常而编译器又不能发现! } void baz() { throw float; } 这种情况的直接后果就是如果baz()抛出了异常,而调用foo()的代码又严格遵守foo()的声明来编写,那么程序将abort()。这曾经让我很恼火,认为这种机制形同虚设,但是还是有些解决的办法,请参照“使用技巧”中相关的问题。 |
apollolegend 回复于:2003-08-19 13:32:05 |
3. 异常使用技巧
3.1 异常是如何工作的 为了可以有把握的使用异常,我们先来看看异常处理是如何工作的。 3.1.1 unwinding stack 我们知道,每次函数调用发生的时候,都会执行保护现场寄存器、参数压栈、为被调用的函数创建堆栈这几个对堆栈的操作,它们都使堆栈增长。每次函数返回则是恢复现场,使堆栈减小。我们把函数返回过程中恢复现场的过程称为unwinding stack。 异常处理中的throw语句产生的效果与函数返回相同,它也引发unwinding stack。如果catch不是在throw的直接上层函数中,那么这个unwinding的过程会一直持续,直到找到合适的catch。如果没有合适的catch,则最后std::unexpected()函数被调用,说明发现了一个没想到的异常,这个函数会调用std::terminate(),这个terminate()调用abort(),程序终止(core dump)。 在“简介”中提到的longjmp()也同样会unwinding stack,但是这是一个C函数,它就象free()不会调用对象的析构函数一样,它也不知道在unwinding stack的过程中调用栈上对象的析构函数。这是它与异常的主要区别。 3.1.2 RTTI 在unwinding stack的过程中,程序会一直试图找到一个“合适”的catch来处理这个异常。前面我们提到throw和catch的关系很象是函数调用和函数原型的关系,多个catch就好象一个函数被重载为可以接受不同的类型。根据这样的猜测,好象找到合适的catch来处理异常与函数重载的过程中找到合适的函数原型是一样的,没有什么大不了的。但实际情况却很困难,因为重载的调用在编译时刻就可以确定,而异常的抛出却不能,考虑下面的代码: void foo() throw (int) { throw int; } void bar() { try{ foo(); } catch(int){ ... } catch(float){ ... } } void baz() { try{ foo(); } catch(int){ ... } catch(float){ ... } } foo()在两个地方被调用,这两次异常被不同的catch捕获,所以在为throw产生代码的时候,无法明确的指出要由哪个catch捕获,也就是说,无法在编译时刻确定。 仍然考虑这个例子,让我们来看看既然不能在编译时刻确定throw的去向,那么在运行时刻如何确定。在bar()中,一列catch就象switch语句中的case一样排列,实际上是一系列的判断过程,依次检查当前异常的类型是否满足catch指定的类型,这种动态的,在运行时刻确定类型的技术就是RTTI(Runtime Type Identification/Information)。深度探索C++对象模型[1]中提到,RTTI就是异常处理的副产品。关于RTTI又是一个话题,在这里就不详细讨论了。 3.2 是否继承std::exception? 是的。而且std::exception已经有了一些派生类,如果需要可以直接使用它们,不需要再重复定义了。 3.3 每个函数后面都要写throw()? 尽管前面已经分析了这样做也有漏洞,但是它仍然是一个好习惯,可以让调用者从头文件得到非常明确的信息,而不用翻那些可能与代码不同步的文档。如果你提供一个库,那么在库的入口函数中应该使用catch(...)来捕获所有异常,在catch(...)中捕获的异常应该被转换(rethrow)为throw列表中的某一个异常,这样就可以保证不会产生意外的异常。 3.4 guard模式 异常处理在unwinding stack的时候,会析构所有栈上的对象,但是却不会自动删除堆上的对象,甚至你的代码中虽然写了delete语句,但是却被throw跳过,导致内存泄露,或者其它资源的泄露。例如: void foo() { ... MyClass * p = new MyClass(); bar(p); ... delete p; // 如果bar()中抛出异常,则不会运行到这里! } void bar(MyClass * p) { throw MyException(); } 对于这种情况,C++提供了std::auto_ptr这个模板来解决问题。这个常被称为“智能指针”的模板原理就是,将原来代码中的指针用一个栈上的模板实例保护起来,当发生异常unwinding stack的时候,这个模板实例会被析构,而在它的析构函数中,指针将被delete,例如: void foo() { ... std::auto_ptr<MyClass> p(new MyClass()); bar(p.get()); ... // delete p; // 这句不再需要了 } void bar(MyClass * p) { throw MyException(); } 不论bar()是否抛出异常,只要p被析构,内存就会被释放。 不光对于内存,对于其他资源的管理也可以参照这个方法来完成。在ACE[2]中,这种方式被称为Guard,用来对锁进行保护。 3.5 构造函数和析构函数 构造函数没有返回值,很多地方都推荐通过抛出异常来通知调用者构造失败。这是肯定是个好的办法,但是也不很完美。主要是因为在构造函数中抛出异常并不会引发析构函数的调用,例如: class foo { public: ~foo() {} // 这个函数将被调用 }; class bar { public: bar() { c_ = new char[10]; throw -1;} ~bar() { delete c_;} // 这个函数不会被调用! private: char * c_; foo f_; }; void baz() { try{ bar b; } catch(int){ } } 在这个例子中,bar的析构函数不会被调用,但是尽管如此,foo的析构函数还是可以被调用。危险的是在构造函数中分配空间的c_,因为析构函数没有被调用而变成了leak。最好的解决办法还是auto_ptr,使用auto_ptr后,bar类的声明变成: class bar { public: bar() { c_.reset(new char[10]); throw -1;} ~bar() { } // 不需要再delete c_了! private: auto_ptr<char> c_; foo f_; }; 析构函数中则不要抛出异常,这一点在Thinking In C++ Volume 2[3]中有明确表述。如果析构函数中调用了可能抛出异常的函数,则应该在析构函数内部catch它。 3.6 什么时候使用异常 到现在为止,我们已经讨论完了异常的大部分问题,可以实际操作操作了。实际应用中遇到的最让我头疼的问题就是什么时候应该使用异常,是否应该用异常全面代替“简介”中提到的其它错误处理方式呢? 首先,不能用异常完全代替返回值,因为返回值的含义不一定只是成功或失败,有时候是一个可选择的状态,例如: if(customer->status() == active){ ... } else{ ... } 在这种情况下,不论返回值是什么,都是程序可以接受的正常的结果。而异常只能用来表达“异常”-- 也就是错误的状态。这好象是显而易见的事情,但是实际编程的过程中有很多更加模棱两可的时候,遇到这样的情况,首先要考虑的就是这个原则。 第二,看看在特定的情况下异常是否会发挥它的优点,而这个优点正好又不能使用其他技术达到(或者简单的达到)。比如,如果你正在为电信公司写一个复杂计费逻辑,那么你当然希望在整个计算费用的过程中集中精力去考虑业务逻辑方面的问题,而不是到处需要根据当前返回值判断是否释放前面步骤中申请的资源。这时候使用异常可以让你的代码非常清晰,即使你有100处申请资源的地方,只要一个地方集中释放他们就好了。例如: bool bar1(); bool bar2(); bool bar3(); bool foo() { ... char * p1 = new char[10]; ... if(!bar1()){ delete p1; return false; } ... char * p2 = new char[10]; ... if(!bar2()){ delete p1; // 要释放前面申请的所有资源 delete p2; return false; } ... char * p3 = new char[10]; ... if(!bar2()){ delete p1; // 要释放前面申请的所有资源 delete p2; delete p3; return false; } } 这种流程显然不如: void bar1() throw(int); void bar2() throw(int); void bar3() throw(int); void foo() throw (int) { char * p1 = NULL; char * p2 = NULL; char * p3 = NULL; try{ char * p1 = new char[10]; bar1(); char * p2 = new char[10]; bar2(); char * p3 = new char[10]; bar3(); } catch(int){ delete p1; // 集中释放资源 delete p2; delete p3; throw; } } 第三,在Thinking In C++ Volume 2[3]中列了一个什么时候不应该用,什么时候应该用的表,大家可以参考一下。 最后,说一个与异常无关的东西,但也跟程序错误有关的,就是断言(assert),我在开发中使用了异常后,很快发现有的人将应该使用assert处理的错误定义成了异常。这里稍微提醒一下assert的用法,非常简单的原则:只有对于那些可以通过改进程序纠正的错误,才可以用assert。返回值、异常显然与其不在一个层面上,这是C的入门知识。 |
linzi_tj 回复于:2003-08-20 17:56:28 |
很好的文章。
我也迷惑异常与返回值的使用区分。 异常:程序中可能出现的错误。有不确定性,如分配内存失败,磁盘空间不足。异常很大程度上依赖于运行时的环境。 返回值:可以预计发生的情形。 |
threehair 回复于:2003-08-20 18:26:52 |
好! |
蓝色键盘 回复于:2003-08-23 19:00:53 |
程序的重要的一个部分是处理可能各种错误和异常 |
andysen 回复于:2004-12-05 20:58:28 |
很久以前就搞不醒豁异常和错误的瓜葛了,拜谢!忘再接再厉! |