C与C++中的异常处理(1)1.异常和标准C对它的支持(前言略)1.1异常分类基于Dr.GUI的建议,我把我的第一个专栏投入到“程序异常”的系列上。我认识到,“exception”这个术语有些不明确并和上下文相关,尤其是C++标准异常(C++standardexceptions)和Microsoft的结构化异常(structuredexceptionhandling)。不幸的的是,“异常”一词太常见了,随时出现在语言的标准和常见的编程文献中。因为不想创造一个新名词,所以我将尽力在此系列的各部分中明确我对“异常”的用法。lPart1概述通常意义上的异常的性质,和标准C库提供的处理它们的方法。lPart2纵览Microsoft对这些标准C库方法的扩展:专门的宏和结构化异常处理。lPart3及其余将致力于标准C++异常处理体系。(C语言使用者可能在Part2后放弃,但我鼓励你坚持到底;我所提出的许多点子同样适用于C,虽然不是很直接。)本质上看,程序异常是指出现了一些很少发生的或出乎意料的状态,通常显示了一个程序错误或要求一个必须提供的回应。不能满足这个回应经常造成程序功能削弱或死亡,有时导致整个系统和它一起down掉。不幸的是,试图使用传统的防护方法来编制健壮的代码经常只是将一个问题(意外崩溃)换成了另外一个问题(更混乱的设计和代码)。太多的程序员认为这个交换抵不上程序意外崩溃时造成的烦恼,于是选择了生活在危险之中。认识到这一点后,C++标准增加了一个优雅并且基本上不可见的“异常体系”到语言中;就这样,这个方法产生了。如同我们在Part4的开始部分将要看到的,这个方法大部分情况下很成功,但在很微妙的情况下可能失败。1.2异常的生命阶段在这个系列里,我将展示C和C++处理异常体系运行于异常整个生命期的每一阶段时的不同之处:l阶段1:一个软件错误发生。这个错误也许产生于一个被底层驱动或内核映射为软件错误的硬件响应事件(如被0除)。l阶段2:错误的原因和性质被一个异常对象携带。这个对象的类型可以简单的整数值到繁杂的C++类对象。l阶段3:你的程序必须检测这个异常对象:或者轮询它的存在,或者由其主动上报。l阶段4:检测代码必须决定如何处理异常。典型的方法分成三类。a忽略异常对象,并期望别人处理它。b在这个对象上干些什么,并还允许别人再继续处理它。c获得异常的全部所有权。l阶段5:既然异常已经处理了,程序通常恢复并继续执行。恢复分成两种:a恢复异常,从异常发生处继续执行。b终止异常,从异常被处理处继续执行。当在程序外面(由运行期库或操作系统)终止异常时,恢复经常是不可能的,程序将异常结束。我故意忽略了硬件错误事件,因为它们完全是底层平台范围内的事。取而代之,我假定一些软件上的可检测错误已经发生,并产生了一个处于第一阶段的软件异常对象。1.3C标准库异常处理体系C标准库提供了几个方法来处理异常。它们也全部在标准C++中有效,只是相关的头文件名字变了:老的C标准头文件name.h映射到了新的C++标准头文件cname。(头文件名的前缀“C”是个助记符,暗示着这些全是C库头文件。)虽然基于向后兼容性,老的C头文件也被C++保留,但我建议你尽可能使用新的头文件。对于绝大部分实际使用而言,最大的变化是在新的头文件中,申明的函数被包含在命名空间std内。举个例子,C语言使用#includestdio.hFILE*f=fopen(blarney.txt,r);在C++中被改成#includecstdiostd::FILE*f=std::fopen(blarney.txt,r);或更C风格的#includecstdiousingnamespacestd;FILE*f=fopen(blarney.txt,r);不幸的是,Microsoft的VisualC++没有将这些新的头文件包含在命名空间std中,虽然这是C++标准所要求的(subclauseD.5)。除非VisualC++在这些头文件中已经正确地支持了std,我将一直在我的专栏中使用老式的C风格命名。(象MIcrosoft这样的运行库卖主这么做是合理的,正确地实现这些C程序库的头文件极可能要求维护和测试两份完全不同的底层代码,这是不可能受欢迎的也不值得多花力气的工作。)1.4无条件终止仅次于彻底忽略一个异常,大概最容易的异常处理方法是程序自我毁灭。有时,最懒的方法事实上是最正确的。在你开始嘲笑以前,应该认识到,一些异常表示的状况是如此严重以致于怎么也不可能合理恢复的。也许最好的例子就是malloc时返回NULL。如果空闲堆管理程序不能提供可用的连续空间,你程序的健壮性将严重受损,并且恢复的可能性是渺茫的。C库头文件stdlib.h提供了两个终止程序的函数:abort()和exit()。这两个函数运行于异常生命期的4和5。它们都不会返回到其调用者中,并都导致程序结束。这样,它们就是结束异常处理的最后一步。虽然两个函数在概念上是相联系的,但它们的效果不同:labort():程序异常结束。默认情况下,调用abort()导致运行期诊断和程序自毁。它可能会也可能不会刷新缓冲区、关闭被打开的文件及删除临时文件,这依赖于你的编译器的具体实现。lexit():文明地结束程序。除了关闭文件和给运行环境返回一个状态码外,exit()还调用了你挂接的atexit()处理程序。一般调用abort()处理灾难性的程序故障。因为abort()的默认行为是立即终止程序,你就必须负责在调用abort()前存储重要数据。(当我们谈论到signal.h时,你可以使得abort()自动调用cleanup代码。)相反,exit()执行了挂接在atexit()上的自定义cleanup代码。这些代码被按照其挂接的反序执行,你可以把它们当作虚拟析构器。通过必要的cleanup代码,你可以安全地终止程序而没有留下尾巴。例如:#includestdio.h#includestdlib.hstaticvoidatexit_handler_1(void){printf(within''''atexit_handler_1''''\n);}staticvoidatexit_handler_2(void){printf(within''''atexit_handler_2''''\n);}intmain(void){atexit(atexit_handler_1);atexit(atexit_handler_2);exit(EXIT_SUCCESS);printf(thislineshouldneverappear\n);return0;}/*Whenrunyieldswithin''''atexit_handler_2''''within''''atexit_handler_1''''andreturnsasuccesscodetocallingenvironment.*/(注意,即使是程序从main()正常返回而没有明确调用exit(),所挂接的atexit()代码仍然会被调用。)无论abort()还是exit()都不会返回到它的调用者中,且都将导致程序结束。在这个意义上来说,它们都表现为终止异常的最后一步。1.5有条件地终止abort()和exit()让你无条件终止程序。你还可以有条件地终止程序。其实现体系是每个程序员所喜爱的诊断工具:断言,定义于assert.h。这个宏的典型实现如下所示:#ifdefinedNDEBUG#defineassert(condition)((void)0)#else#defineassert(condition)\_assert((condition),#condition,__FILE__,__LINE__)#endif如定义体所示,当宏NDEBUG被定义时断言是无行为的,这暗示了它只对调试版本有效。于是,断言条件从不在非调试版本中被求值,这会造成同样的代码在调试和非调试版本间有奇妙的差异。/*debugversion*/#undefNDEBUG#includeassert.h#includestdio.hintmain(void){inti=0;assert(++i!=0);printf(iis%d\n,i);return0;}/*Whenrunyieldsiis1*/现在,通过定义NDEBUG,从debug版变到release版:/*releaseversion*/#defingNDEBUG#includeassert.h#includestdio.hintmain(void){inti=0;assert(++i!=0);printf(iis%d\n,i);return0;}/*Whenrunyieldsiis0*/要避免这个差异,必须确保断言表达式的求值不会包含有影响的副作用。在仅供调试版使用的定义体中,断言变成呼叫_assert()函数。我起了这个名字,而你所用的运行库的实现可以调用任何它想调用的内部函数。无论它叫什么,这个函数通常有以下形式:void_assert(inttest,charconst*test_image,charconst*file,intline){if(!test){printf(Assertionfailed:%s,file%s,line%d\n,test_image,file,line);abort();}}所以,失败的断言在调用abort()前显示出失败情况的诊断条件、出错的源文件名称和行号。我在这里演示的诊断机构“printf()”相当粗糙,你所用的运行库的实现可能产生更多的反馈信息。断言处理了异常的阶段3到5。它们实际上是一个带说明信息的abort()并做了前提条件检查,如果检查失败,程序中止。一般使用断言调试逻辑错误和绝不可能出现在正确的程序中的情况。/*''''f''''nevercalledbyotherprograms*/staticvoidf(int*p){assert(p!=NULL);/*...*/}对比一下逻辑错误和可以存在于正确程序中的运行期错误:/*...getfile''''name''''fromuser...*/FILE*file=fopen(name,mode);assert(file!=NULL);/*questionableuse*/这样的错误表示异常情况,但不是bug。对这些运行期异常,断言大概不是个合适的处理方法,你应该用我下面将介绍的另一个体系来代替。1.6非局部的跳转与刺激的abort()和exit()相比,goto语句看起来是处理异常的更可行方案。不幸的是,goto是本地的:它只能跳到所在函数内部的标号上,而不能将控制权转移到所在程序的任意地点(当然,除非你的所有代码都在main体中)。为了解决这个限制,C函数库提供了setjmp()和longjmp()函数,它们分别承担非局部标号和goto作用。头文件setjmp.h申明了这些函数及同时所需的jmp_buf数据类型。原理非常简单:lsetjmp(j)设置“jump”点,用正确的程序上下文填充jmp_buf对象j。这个上下文包括程序存放位置、栈和框架指针,其它重要的寄存器和内存数据。当初始化完jump的上下文,setjmp()返回0值。l以后调用longjmp(j,r)的效果就是一个非局部的goto或“长跳转”到由j描述的上下文处(也就是到那原来设置j的setjmp()处)。当作为长跳转的目标而被调用时,setjmp()返回r或1(如果r设为0的话)。(记住,setjmp()不能在这种情况时返回0。)通过有两类返回值,setjmp()让你知道它正在被怎么使用。当设置j时,setjmp()如你期望地执行;但当作为长跳转的目标时,setjmp()就从外面“唤醒”它的上下文。你可以用longjmp()来终止异常,用setjmp()标记相应的异常处理程序。#includesetjmp.h#includestdio.h