这两天要处理一个异常的问题,刚好查了些相关的资料。在网上看到了一个不错的贴子,就转了过来,方便本人,以及来此旅游的朋友学习。源地址:www.host01/Print.html?91983,1 异常处理的基本思想是简化程序的错误代码,为程序键壮性提供一个标准检测机制。 也 许我们已经使用过异常,但是你会是一种习惯吗,不要老是想着当我打开一个文件的时候才用异常判断一下,我知道对你来说你喜欢用return value或者是print error message来做,你想过这样做会导致Memory Leak,系统退出,代码重复/难读,垃圾一堆…..吗?现在的软件已经是n*365*24小时的运行了,软件的健壮已经是一个很要考虑的时候了。 自序: 对写程序来说异常真的是很重要,一个稳健的代码不是靠返回Error Message/return Value来解决的,可是往往我们从C走过来,习惯了这样的方式。 仅 以本文献给今天将要来临的流星雨把,还好我能在今天白天把这写完,否则会是第4个通宵了;同时感谢Jeffrey大师,没有他的SEH理论这篇文章只能完 成一半,而且所有SEH列子的构想都来自他的指导;另外要感谢Scott Meyers大师,我是看他的书长大的;还要感谢Adamc / Darwin / Julian ,当然还有Nick的Coffee 内容导读: (请打开文档结构图来读这篇文章。) 本文包括2个大的异常实现概念:C++的标准异常和SHE异常。 C++ 标准异常:也许我们了解过他,但你有考虑过,其实你根本不会使用,你不相信,那我问你:垃圾回收在C++中怎么实现?其实不需要实现,C++已经有了,但 是你不会用,那么从<构造和析构中的异常抛出>开始看把。也许很高兴看到错误之后的Heap/Stack中对象被释放,可是如果没有呢?有或 者试想一下一个能解决的错误,需要我们把整个程序Kill掉吗? 在C++标准异常中我向你推荐这几章:<使用异常规格编程> <构造和析构中的异常抛出> <使用析构函数防止资源泄漏> 以及一个深点的<抛出一个异常的行为> SHE异常: 我要问你你是一个WIN32程序员吗?如果不是,那么也许你真的不需要看 这 块内容了,SHE是Windows的结构化异常,每一个WIN32程序员都应该要掌握它。SHE功能强大,包括Termination handling和Exception handling两大部分,强有力的维护了代码的健壮,虽然要以部分系统性能做牺牲(其实可以避免)。在SHE中有大量的代码,已经在Win平台上测试过 了。 这里要提一下:在__finally处理中编译器参与了绝大多数的工作,而Exception则是OS接管了几乎所有的工作,也许我没有提到 的是:对__finally来说当遇到ExitThread/ExitProcess/abort等函数时,finally块不会被执行。另,我们的代码 使用软件异常是比return error message好2**32的方法。 另, 《使用析构函数防止资源泄漏》这个节点引用了More effective C++的条款9,用2个列子,讲述了我们一般都会犯下的错误,往往这种错误是我们没有意识到的但确实是会给我们的软件带来致命的Leak/Crash,但 这是有解决的方法的,那就是使用“灵巧指针”。
如果对照<More effective C++>的37条条款,关于异常的高级使用,有以下内容是没有完成的: l 使用构造函数防止资源Leak(More effective C++ #10) l 禁止异常信息传递到析构Function外 (More effective C++ #11) l 通过引用捕获异常 (More effective C++ #13) l 谨慎使用异常规格 (More effective C++ #14) l 了解异常处理造成的系统开销 (More effective C++ #15) l 限制对象数量 (More effective C++ #26) l 灵巧指针 (More effective C++ #28) [声明:节点:<使用析构函数防止资源泄漏> 和 节点:<抛出一个异常的行为>中有大量的关于More effective C++的条款,所以本文挡只用于自我阅读和内部交流,任何公开化和商业化,事先声明与本人无关。] C++异常 C++引入异常的原因 C++ 新增的异常机制改变了某些事情,这些改变是彻底的,但这些改变也可能让我们不舒服。例如使用未经处理的pointer变的很危 险,Memory/Resource Leak变的更有可能了(别说什么Memory便宜了,那不是一个优秀的程序员说的话。),写出一个具有你希望的行为的构造函数和析构函数也变的困难(不 可预测),当然最危险的也许是我们写出的东东狗屁了,或者是速度变慢了。 大 多数的程序员知道Howto use exception 来处理我们的代码,可是很多人并不是很重视异常的处理(国外的很多Code倒是处理的很好,Java的Exception机制很不错)。异常处理机制是解 决某些问题的上佳办法,但同时它也引入了许多隐藏的控制流程;有时候,要正确无误的使用它并不容易。 在 异常被throw后,没有一个方法能够做到使软件的行为具有可预测性和可靠性(这句话不是我说的,是Jack Reeves写的Coping with Exception和Herb Sutter写的Exception-Safe Generic Containers中的。)一个没有按照异常安全设计的程序想Run 正常,是做梦,别去想没有异常出现的可能, 对 C程序来说,使用Error Code就可以了,为什么还要引入异常?因为异常不能被忽略。如果一个函数通过设置一个状态变量或返回错误代码来表示一个异常状态,没有办法保证函数调用 者将一定检测变量或测试错误代码。结果程序会从它遇到的异常状态继续运行,异常没有被捕获,程序立即会终止执行。 在 C程序中,我们可以用int setjmp( jmp_buf env );和 void longjmp( jmp_buf env, int value );这2个函数来完成和异常处理相识的功能,但是MSDN中介绍了在C++中使用longjmp来调整stack时不能够对局部的对象调用析构函数,但是 对C++程序来说,析构函数是重要的(我就一般都把对象的Delete放在析构函数中)。 所以我们需要一个方法:①能够通知异常状态,又不能忽略这个通知,②并且Searching the stack以便到异常代码时,③还要确保局部对象的析构函数被Call。而C++的异常处理刚好就是来解决这些问题的。 有 的地方只有用异常才能解决问题,比如说,在当前上下文环境中,无法捕捉或确定的错误类型,我们就得用一个异常抛出到更大的上下文环境当中去。还有,异常处 理的使用呢,可以使出错处理程序与“通常”代码分离开来,使代码更简洁更灵活。另外就是程序必不可少的健壮性了,异常处理往往在其中扮演着重要的角。 C++使用throw关键字来产生异常,try关键字用来检测的程序块,catch关键字用来填写异常处理的代码。异常可以由一个确定类或派生类的对象产生。C++能释放堆栈,并可清除堆栈中所有的对象。 C++的异常和pascal不同,是要程序员自己去实现的,编译器不会做过多的动作。 throw异常类编程 抛出异常用throw, 如: throw ExceptionClass(“my throw“); 例句中,ExceptionClass是一个类,它的构造函数以一个字符串做为参数。也就是说,在throw的时候,C++的编译器先构造一个ExceptionClass的对象,让它作为throw的值抛出去。同时,程序返回,调用析构。看下面这个程序: #include <iostream.h> class ExceptionClass{ char* name; public: ExceptionClass(const char* name="default name") { cout<<"Construct "<<name<<endl; this->name=name; } ~ExceptionClass() { cout<<"Destruct "<<name<<endl; } void mythrow() { throw ExceptionClass("my throw"); } } void main(){ ExceptionClass e("Test"); try{ e.mythrow(); } catch(...) { cout<<”*********”<<endl; } } 这是输出信息: Construct Test Construct my throw Destruct my throw **************** Destruct my throw (这里是异常处理空间中对异常类的拷贝的析构) Destruct Test ====================================== 不过一般来说我们可能更习惯于把会产生异常的语句和要throw的异常类分成不同的类来写,下面的代码可以是我们更愿意书写的: ……….. class ExceptionClass{ public: ExceptionClass(const char* name="Exception Default Class"){ cout<<"Exception Class Construct String"<<endl; } ~ExceptionClass(){ cout<<"Exception Class Destruct String"<<endl; } void ReportError() { cout<<"Exception Class:: This is Report Error Message"<<endl; } }; class ArguClass{ char* name; public: ArguClass(char* name="default name"){ cout<<"Construct String::"<<name<<endl; this->name=name; } ~ArguClass(){ cout<<"Destruct String::"<<name<<endl; } void mythrow(){ throw ExceptionClass("my throw"); } }; _tmain() { ArguClass e("haha"); try { e.mythrow(); } catch(int) { cout<<"If This is Message display screen, This is a Error!!"<<endl; } catch(ExceptionClass pTest) { pTest.ReportError(); } catch(...){ cout<<"***************"<<endl; } } 输出Message: Construct String::haha Exception Class Construct String Exception Class Destruct String Exception Class:: This is Report Error Message Exception Class Destruct String Destruct String::haha 使用异常规格编程 如果我们调用别人的函数,里面有异常抛出,用去查看它的源代码去看看都有什么异常抛出吗?这样就会很烦琐。比较好的解决办法,是编写带有异常抛出的函数时,采用异常规格说明,使我们看到函数声明就知道有哪些异常出现。 异常规格说明大体上为以下格式: void ExceptionFunction(argument…) throw(ExceptionClass1, ExceptionClass2, ….) 所有异常类都在函数末尾的throw()的括号中得以说明了,这样,对于函数调用者来说,是一清二楚的。 注意下面一种形式: void ExceptionFunction(argument…) throw() 表明没有任何异常抛出。 而正常的void ExceptionFunction(argument…)则表示:可能抛出任何一种异常,当然,也可能没有异常,意义是最广泛的。 异常捕获之后,可以再次抛出,就用一个不带任何参数的throw语句就可以了。 构造和析构中的异常抛出 这是异常处理中最要注意的地方了 先看个程序,假如我在构造函数的地方抛出异常,这个类的析构会被调用吗?可如果不调用,那类里的东西岂不是不能被释放了? #include <iostream.h> #include <stdlib.h> class ExceptionClass1 { char* s; public: ExceptionClass1(){ cout<<"ExceptionClass1()"<<endl; s=new char[4]; cout<<"throw a exception"<<endl; throw 18; } ~ExceptionClass1(){ cout<<"~ExceptionClass1()"<<endl; delete[] s; } }; void main(){ try{ ExceptionClass1 e; }catch(...) {} try catch的使用方法} 结果为: ExceptionClass1() throw a exception 在这两句输出之间,我们已经给S分配了内存,但内存没有被释放(因为它是在析构函数中释放的)。应该说这符合实际现象,因为对象没有完整构造。 为了避免这种情况,我想你也许会说:应避免对象通过本身的构造函数涉及到异常抛出。即:既不在构造函数中出现异常抛出,也不应在构造函数调用的一切东西中出现异常抛出。 但是在C++中可以在构造函数中抛出异常,经典的解决方案是使用STL的标准类auto_ptr。 其 实我们也可以这样做来实现:在类中增加一个 Init(); 以及 UnInit();成员函数用于进行容易产生错误的资源分配工作,而真正的构造函数中先将所有成员置为NULL,然后调用 Init(); 并判断其返回值/或者捕捉 Init()抛出的异常,如果Init();失败了,则在构造函数中调用 UnInit(); 并设置一个标志位表明构造失败。UnInit()中按照成员是否为NULL进行资源的释放工作。 那么,在析构函数中的情况呢?我们已经知道,异常抛出之后,就要调用本身的析构函数,如果这析构函数中还有异常抛出的话,则已存在的异常尚未被捕获,会导致异常捕捉不到。 标准C++异常类 C++有自己的标准的异常类。 ① 一个基类: exception 是所有C++异常的基类。 class exception { public: exception() throw(); exception(const exception& rhs) throw(); exception& operator=(const exception& rhs) throw(); virtual ~exception() throw(); virtual const char *what() const throw(); }; ② 下面派生了两个异常类: logic_erro 报告程序的逻辑错误,可在程序执行前被检测到。 runtime_erro 报告程序运行时的错误,只有在运行的时候才能检测到。 以上两个又分别有自己的派生类: ③ 由logic_erro派生的异常类 domain_error 报告违反了前置条件 invalid_argument 指出函数的一个无效参数 length_error 指出有一个产生超过NPOS长度的对象的企图(NPOS为size_t的最大可表现值 out_of_range 报告参数越界 bad_cast 在运行时类型识别中有一个无效的dynamic_cast表达式 bad_typeid 报告在表达式typeid(*p)中有一个空指针P ④ 由runtime_error派生的异常 range_error 报告违反了后置条件 overflow_error 报告一个算术溢出 bad_alloc 报告一个存储分配错误 使用析构函数防止资源泄漏 这部分是一个经典和很平常就会遇到的实际情况,下面的内容大部分都是从More Effective C++条款中得到的。 假 设,你正在为一个小动物收容所编写软件,小动物收容所是一个帮助小狗小猫寻主人的组织。每天收容所建立一个文件,包含当天它所管理的收容动物的资料信 息,你的工作是写一个程序读出这些文件然后对每个收容动物进行适当的处理(appropriate processing)。 完成这个程序一个合理的方法是定义一个抽象类,ALA("Adorable Little Animal"),然后为小狗和小猫建立派生类。一个虚拟函数processAdoption分别对各个种类的动物进行处理: class ALA { public: virtual void processAdoption() = 0; ... }; class Puppy: public ALA { public: virtual void processAdoption(); ... }; class Kitten: public ALA { public: virtual void processAdoption(); ... }; 你需要一个函数从文件中读信息,然后根据文件中的信息产生一个puppy(小狗)对象或者kitten(小猫)对象。这个工作非常适合于虚拟构造器(virtual constructor),在条款25详细描述了这种函数。为了完成我们的目标,我们这样声明函数: // 从s中读动物信息, 然后返回一个指针 // 指向新建立的某种类型对象 ALA * readALA(istream& s); 你的程序的关键部分就是这个函数,如下所示: void processAdoptions(istream& dataSource) { while (dataSource) { // 还有数据时,继续循环 ALA *pa = readALA(dataSource); file://得到下一个动物 pa->processAdoption(); file://处理收容动物 delete pa; file://删除readALA返回的对象 } } 这个函数循环遍历dataSource内的信息,处理它所遇到的每个项目。唯一要记住的一点是在每次循环结尾处删除ps。这是必须的,因为每次调用readALA都建立一个堆对象。如果不删除对象,循环将产生资源泄漏。 现在考虑一下,如果pa->processAdoption抛出了一个异常,将会发生什么? processAdoptions 没有捕获异常,所以异常将传递给processAdoptions的调用者。转递中,processAdoptions函数中的调用 pa->processAdoption语句后的所有语句都被跳过,这就是说pa没有被删除。结果,任何时候 pa->processAdoption抛出一个异常都会导致processAdoptions内存泄漏。 堵塞泄漏很容易, void processAdoptions(istream& dataSource) { while (dataSource) { ALA *pa = readALA(dataSource); try { pa->processAdoption(); } catch (...) { // 捕获所有异常 delete pa; // 避免内存泄漏 // 当异常抛出时 throw; // 传送异常给调用者 } delete pa; // 避免资源泄漏 } // 当没有异常抛出时 } 但 是你必须用try和catch对你的代码进行小改动。更重要的是你必须写双份清除代码,一个为正常的运行准备,一个为异常发生时准备。在这种情况下,必须 写两个delete代码。象其它重复代码一样,这种代码写起来令人心烦又难于维护,而且它看上去好像存在着问题。不论我们是让 processAdoptions正常返回还是抛出异常,我们都需要删除pa,所以为什么我们必须要在多个地方编写删除代码呢? 我 们可以把总被执行的清除代码放入processAdoptions函数内的局部对象的析构函数里,这样可以避免重复书写清除代码。因为当函数返回时局部对 象总是被释放,无论函数是如何退出的。(仅有一种例外就是当你调用longjmp时。Longjmp的这个缺点是C++率先支持异常处理的主要原因) 具 体方法是用一个对象代替指针pa,这个对象的行为与指针相似。当pointer-like(类指针)对象被释放时,我们能让它的析构函数调用 delete。替代指针的对象被称为smart pointers(灵巧指针),下面有解释,你能使得pointer-like对象非常灵巧。在这里,我们用不着这么聪明的指针,我们只需要一个 pointer-lik对象,当它离开生存空间时知道删除它指向的对象。 写 出这样一个类并不困难,但是我们不需要自己去写。标准C++库函数包含一个类模板,叫做auto_ptr,这正是我们想要的。每一个auto_ptr类的 构造函数里,让一个指针指向一个堆对象(heap object),并且在它的析构函数里删除这个对象。下面所示的是auto_ptr类的一些重要的部分: template<class T> class auto_ptr { public: auto_ptr(T *p = 0): ptr(p) {} // 保存ptr,指向对象 ~auto_ptr() { delete ptr; } // 删除ptr指向的对象 private: T *ptr; // raw ptr to object }; auto_ptr 类的完整代码是非常有趣的,上述简化的代码实现不能在实际中应用。(我们至少必须加上拷贝构造函数,赋值operator以及下面将要讲到的 pointer-emulating函数),但是它背后所蕴含的原理应该是清楚的:用auto_ptr对象代替raw指针,你将不再为堆对象不能被删除而 担心,即使在抛出异常时,对象也能被及时删除。(因为auto_ptr的析构函数使用的是单对象形式的delete,所以auto_ptr不能用于指向对 象数组的指针。如果想让auto_ptr类似于一个数组模板,你必须自己写一个。在这种情况下,用vector代替array可能更好) auto_ptr template<class T> class auto_ptr { public: typedef T element_type; explicit auto_ptr(T *p = 0) throw(); auto_ptr(const auto_ptr<T>& rhs) throw(); auto_ptr<T>& operator=(auto_ptr<T>& rhs) throw(); ~auto_ptr(); T& operator*() const throw(); T *operator->() const throw(); T *get() const throw(); T *release() const throw(); }; 使用auto_ptr对象代替raw指针,processAdoptions如下所示: void processAdoptions(istream& dataSource) { while (dataSource) { auto_ptr<ALA> pa(readALA(dataSource)); pa->processAdoption(); } } 这个版本的processAdoptions在两个方面区别于原来的processAdoptions函数。 第一, pa被声明为一个auto_ptr<ALA>对象,而不是一个raw ALA*指针。 第二, 在循环的结尾没有delete语句。 其余部分都一样,因为除了析构的方式,auto_ptr对象的行为就象一个普通的指针。是不是很容易。 隐藏在auto_ptr后的思想是:用一个对象存储需要被自动释放的资源,然后依靠对象的析构函数来释放资源,这种思想不只是可以运用在指针上,还能用在其它资源的分配和释放上。想一下这样一个在GUI程序中的函数,它需要建立一个window来显式一些信息: // 这个函数会发生资源泄漏,如果一个异常抛出 void displayInfo(const Information& info) { WINDOW_HANDLE w(createWindow()); 在w对应的window中显式信息 destroyWindow(w); } 很 多window系统有C-like接口,使用象like createWindow 和 destroyWindow函数来获取和释放window资源。如果在w对应的window中显示信息时,一个异常被抛出,w所对应的window将被丢 失,就象其它动态分配的资源一样。 解决方法与前面所述的一样,建立一个类,让它的构造函数与析构函数来获取和释放资源: file://一个类,获取和释放一个window 句柄 class WindowHandle { public: WindowHandle(WINDOW_HANDLE handle): w(handle) {} ~WindowHandle() { destroyWindow(w); } operator WINDOW_HANDLE() { return w; } // see below private: WINDOW_HANDLE w; // 下面的函数被声明为私有,防止建立多个WINDOW_HANDLE拷贝 file://有关一个更灵活的方法的讨论请参见下面的灵巧指针 WindowHandle(const WindowHandle&); WindowHandle& operator=(const WindowHandle&); }; 这 看上去有些象auto_ptr,只是赋值操作与拷贝构造被显式地禁止(参见More effective C++条款27),有一个隐含的转换操作能把WindowHandle转换为WINDOW_HANDLE。这个能力对于使用WindowHandle对象 非常重要,因为这意味着你能在任何地方象使用raw WINDOW_HANDLE一样来使用WindowHandle。(参见More effective C++条款5 ,了解为什么你应该谨慎使用隐式类型转换操作) 通过给出的WindowHandle类,我们能够重写displayInfo函数,如下所示:
// 如果一个异常被抛出,这个函数能避免资源泄漏 void displayInfo(const Information& info) { WindowHandle w(createWindow()); 在w对应的window中显式信息; } 即使一个异常在displayInfo内被抛出,被createWindow 建立的window也能被释放。 资 源应该被封装在一个对象里,遵循这个规则,你通常就能避免在存在异常环境里发生资源泄漏。但是如果你正在分配资源时一个异常被抛出,会发生什么情况呢?例 如当你正处于resource-acquiring类的构造函数中。还有如果这样的资源正在被释放时,一个异常被抛出,又会发生什么情况呢?构造函数和析 构函数需要特殊的技术。你能在More effective C++条款10和More effective C++条款11中获取有关的知识。 抛出一个异常的行为 个人认为接下来的这部分其实说的很经典,对我们理解异常行为/异常拷贝是很有帮助的。 条款12:理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异 从语法上看,在函数里声明参数与在catch子句中声明参数几乎没有什么差别: class Widget { ... }; file://一个类,具体是什么类 // 在这里并不重要 void f1(Widget w); // 一些函数,其参数分别为 void f2(Widget& w); // Widget, Widget&,或 void f3(const Widget& w); // Widget* 类型 void f4(Widget *pw); void f5(const Widget *pw); catch (Widget w) ... file://一些catch 子句,用来 catch (Widget& w) ... file://捕获异常,异常的类型为 catch (const Widget& w) ... // Widget, Widget&, 或 catch (Widget *pw) ... // Widget* catch (const Widget *pw) ... 你因此可能会认为用throw抛出一个异常到catch子句中与通过函数调用传递一个参数两者基本相同。这里面确有一些相同点,但是他们也存在着巨大的差异。 让 我们先从相同点谈起。你传递函数参数与异常的途径可以是传值、传递引用或传递指针,这是相同的。但是当你传递参数和异常时,系统所要完成的操作过程则是完 全不同的。产生这个差异的原因是:你调用函数时,程序的控制权最终还会返回到函数的调用处,但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方。 有这样一个函数,参数类型是Widget,并抛出一个Widget类型的异常: // 一个函数,从流中读值到Widget中 istream operator>>(istream& s, Widget& w); void passAndThrowWidget() { Widget localWidget; cin >> localWidget; file://传递localWidget到 operator>> throw localWidget; // 抛出localWidget异常 } 当 传递localWidget到函数operator>>里,不用进行拷贝操作,而是把operator>>内的引用类型变量w指 向localWidget,任何对w的操作实际上都施加到localWidget上。这与抛出localWidget异常有很大不同。不论通过传值捕获异 常还是通过引用捕获(不能通过指针捕获这个异常,因为类型不匹配)都将进行lcalWidget的拷贝操作,也就说传递到catch子句中的是 localWidget的拷贝。必须这么做,因为当localWidget离开了生存空间后,其析构函数将被调用。如果把localWidget本身(而 不是它的拷贝)传递给catch子句,这个子句接收到的只是一个被析构了的Widget,一个Widget的“尸体”。这是无法使用的。因此C++规范要 求被做为异常抛出的对象必须被复制。 即使被抛出的对象不会被释放,也会进行拷贝操作。例如如果passAndThrowWidget函数声明localWidget为静态变量(static), void passAndThrowWidget() { static Widget localWidget; // 现在是静态变量(static); file://一直存在至程序结束 cin >> localWidget; // 象以前那样运行 throw localWidget; // 仍将对localWidget } file://进行拷贝操作 当 抛出异常时仍将复制出localWidget的一个拷贝。这表示即使通过引用来捕获异常,也不能在catch块中修改localWidget;仅仅能修改 localWidget的拷贝。对异常对象进行强制复制拷贝,这个限制有助于我们理解参数传递与抛出异常的第二个差异:抛出异常运行速度比参数传递要慢。 |
发表评论