Windows程序调试----第一部分 调试策略----第5章 使用异常和返回值

本书由铁文手打整理,仅为方便个人查阅摘录

如喜欢本书,请购买正版

 

 

5章使用异常和返回值

    C++程序中,你可以使用异常或返回值来返回状态信息,在C语言程序出现的早期,返回一个函数状态的最好方法就是它的返回值。使用这个函数的程序员不得不检查返回值来确定这个函数是不是按照预想的正常工作。基于C语言的Windows API使用这种方式的另一种变体,那就是API函数返回一个特殊值来指明一个错误,而且这个特殊的错误值可以通过调用GetLastError API函数得到。

    这种状态处理机制很简单,但它有两个明显的错误。第一个问题出在程序员有可能并不想看返回值。例如,考虑一下下面的代码:

    _tprintf(_T("This program might fail without warning!\n"));

    显然,函数_tprintf将输入文本格式化并写入标准输出流。那如果标准输出流己满而不能再接收字符了怎么办呢?_tprintf函数可以处理这祌情况,它返回己写入的字符数目,或返回一个负值表示出错。但是什么时候你会去查看代码并检査_tprintf的返回值(或其他仵何一种printf形式)呢?这种问题很少发生,因此程序员并不想理会。我当然也不理会。

    你可能会说_tprintf是一个非常微不足道的函数,大多数情况下,即使这个函数失败,你也不怎么在意。这是一个好的理由,但Windows API函数呢?当然还有很多Windows API函数,如果它们失败的话,会出现真正的问题。现在看一个典型的Windows程序并看一看相应的如何检查API返同值(理论上说来,你应该检查所有不返回空的Windows API函数是否出错)。然后再看一下GetLastError如何被频繁调用。考虑到大多数Windows API函数都会出错(仅有一少部分不会),你会发现这些结果很烦人的。

    返回值的第二个问题就是程序员确实想检査返回值。例如,考虑下面的代码:

    if (_tprintf(_T("This code checks all return values. \n")) < 0) {

        // handle problem

        ...

    }

    else if (_tprintf(_T("bust most of it is error handling. \n")) < 0) {

        // handle problem

        ...

    }

    这种方法的问题出在使用了处理错误的代码后,程序变得混乱了,简单说来就是大多数的代码实际上只为了处理错误。这使得代码更加复杂,难于读懂和维护,因此更易出现错误。它也完全依赖程序员严格的编程作风,因为这种情况下任何突发性的错误都会破坏错误处理机制的完整性。程序员必须不断地检査每一处可能出现的异常清况,即使它们从来没有出现过。

    使用返回值处理错误需要程序员严格的编程作风,程序员不论是否有这种习惯,这都是非常不希望的。

    下面介绍异常。C++异常可以让你使用下面的机制解决上面的两个问题:

    try {

        // code that may fail

    }

    catch (...) {

        // handle the failure

    }

    使用异常的一个很明显的好处就是它们可以将程序代码和错误处理代码分开,因此它将程序代码简单化,因为你不用不断地检查函数的返回值。使用异常的另一个好处就是它们不需要严格的编程作风。异常可以发出错误信号让程序不会忽略错误。

    异常通过发出错误信号,可以让程序代碼和错误处理代码分开,而且不会让程序忽略错误“

5.1不正确的错误处理结果

    那么它们和调试有什么关系呢?毕竟,错误处理和断言和跟踪语句不一样,它们很明显就是和调试有关的语句,但是错误处理更像是编程的问题而不是一个调试的问题,是不是?下面一个真实世界的例子可以很好的回答这个问题。

    1996年的64号,Ariane五号运载火箭的第一次发射灾难性失败了。这是从欧洲航天局/CNESAriane 501联合发布的文章,其中报告了失败的原因(重点加注的):

    在发射例程初始化后的40秒钟后,在大约3700米的高度,运载火箭转向偏离了它的飞行轨道,接着坠毁并爆炸。失败的原因很快就缩小在发射的控制系统,特别是内部参考系统,因为它们突然在大约H0+36.7秒时同时停止工作。

    基于呈于董事会的Ariane501失败原因的文档和数据,说明了下面的一系列的事件,并且说明了它们的内部关联和原因。从运载火箭的破坏开始,并逐步跟踪,最终得到了失败的主要原因:

    •运载火箭在大概H0+39秒时,开始遭到破坏而分解,原因是因为遭受到一个角度超过20度的高压气流的袭击,导致负载过重,使得推进器从主机分离,并触发了运载火箭的自我破坏系统。

    •造成袭击角度的原因是固体发动机和Vulcain主发动机的喷口的完全倾斜。

    •这些喷口的倾钭由机上计算机(OBC)软件控制,以活动的惯性参照系(SRI 2)下传送的数据为依据。这些数据的一部分当时并不包括正确的飞行数据,而是显示了SRI 2的计算机的诊断位模式,而这当时被认为是飞行数据。

    •活动的SRI 2没有发送正确状态数据的原因是那个部分声明了一个由于软件的异常导致的错误

    OBC没有切换到备份的SRI 1,因为那个部分已经因为SRI 2同样的原因,而在早期的数据循环(72毫秒的时间)已经停止工作了

    内部的SRI软件异常发生在执行一个数据由64位的浮点数转化为16位的符号整数的过程中。要转换的浮点数的数值比16位符号整数可以表示的数值要大,这导致一个操作符错误。数据转换指令(Ada代码中)并不能防范操作符错误,而代码中同一地方其他类似变量的转换就可以受到保护

    •这个错误发生在软件执行接联的惯性平台校准的部分。这个软件模块仅在离地升空前计算有意义的数据,运载火箭一升空,这个函数就没有任何作用了

    Ariane 5号飞行前3秒时,两个SRI切换到飞行模式,而校准函数在飞行模式开始后执行50秒钟。同时,离地升空后,这个函数持续执行了大约40秒的飞行时间。这种时间序列是基于Ariane 4号的需要,但对Ariane 5号并不适合

    因为未预期的大数值而导致操作符错误,而这个大数值是一个内部校准函数的结果,这个结果称为8H,水平偏差,和平台测到的水平速率有关,计算这个值作为基于时间的校准精确性的指示器。

    BH的值比预期的数值要高的多,这是因为Ariane5号的运行轨道的开始部分和Ariane4号的不同,因此导致水平速率值相当的高,

    综上所述,用于控制火箭飞行的惯性参照系统出现错误,错误原因是因为在它的沴断软件中出现了一个末处理的异常,而这个诊断软件在飞行中没有任何意义。而且这次发射的火箭甚至根本不需要运行这种诊断操作(这是代码复用的失败)。为了避免错误,火箭有两套SRI系统,但是因为它们使用的是相同的输入数据和相同的软件,因此它们同样的失败了。

    (顺便说一句,如果你需要为飞行控制软件编写测试代码,你可能需要考虑在一个分开的进程中运行它,使用这种方法,即使测试进程结束,飞行控制进程自身会继续进行。使用多进程可以提供对关键系统的保护,而多线程软件则不可以。)

    异常可以解决由于使用状态处理的返回值导致的基本问题,但它们本身又出现了一个重要的问题。具体地说,就是进程中抛出的异常必须处理,否则进程就会结束。未处理的异常会使你的进程崩溃,不论其原因重要可否。上面介绍的事件就是个非常真实的例子,它确确实实地说明了异常是一个调试的问题。

    总体上处理错误、特殊地方处理异常是程序错误的一个常见的来源。异常既可以提高程序的可靠性,又可能破坏程序,这完全依赖于你如何使用它们了。设计精当,执行准确的错误处理代码可以防止错误,而不正确的错误处理会导致错误。更复杂的说法是,典型的错误处理通常在程序最易出错的部分。

    有了上面这个事实,你可能会得到结论:使用返回值可能是比较安全的方法。毕竟,使用返回值并不那么容易使程序崩溃,而未处理的异常会使程序很容易的崩溃。其实这种逻辑有几个问题。对于初学者来说,并不是所有的C++函数都有返同值,而且使用这样的函数(例如构造器和重载的操作符,像操作符=一样)C++的面向对象编程中很基本的。使用返回值也会导致错误,这个上面已经介绍过了,而且库代码(例如MFCSTL)甚至C++本身都会抛出异常,因此不管你的程序是否抛出异常,你都要对异常进行处理。因此,正确的异常处理是C++中的一个常识。

5.2策略的需要

    上面的这种情形需要一种基于广泛工程状态处理(project_wide status_handling)的策略。异常可以在极大程度上帮助你避免错误,但是如果没有建立好的并易理解的策略,那么使用异常产生错误的机率和清除错误的一样大。如果一些组员没有抛出错误的异常,那很可能他们那些程序员也不会处理异常,因为他们根本就没想到用异常。对于程序员最坏的情形就是在使用了异常的环境中不能理解异常。

    这有一个很普通的例子,考虑一下,如果你发现程序中有如下的代码,你将如何做呢:

    CSomeVarType *pSomeVar;

    if((pSomeVar = new CSomeVarType) == 0)

        return -1;

    你怎样看待这些代码?正确的答案是你并不知道,因为它决定于new是被设罝为抛出一个异常还是因为创建失败而返回一个空指针。Visual C++的默认情况下,new失败的话返回一个空指针,但是你可以通过使用_set_new_handler(MFC自动完成)安装一个处理器来使new抛出一个异常。如果new抛出异常,即使它的返回值是一个空指针,那也不能说明任何情况;因此这段代码很可能因为未处理的异常而导致程序的崩溃。然而真正的问题是组中的所有程序员必须知道使用了哪一种错误处理方法。如果没有,程序肯定是有错的

    你的状态处理策略必须能达到如下的目的:

    •设计状态处理(通常情况下,状态处理一点都没有设计)

    •决定你的程序什么时候应该使用异常,而什么时候应该使用返问值。

    •确定程序异常安全,并决定你应该支持的异常安全的级别(程序异常安全指存在异常的情况下程序可以正常运行)

    •确定程序应该什么时候抛出界常和程序怎样抛出异常。

    •确定程序应该什么时候捕获异常和程序怎样捕获异常。

    •使进程避免因为不必要的未处理异常崩溃。

    •不要使用异常处理来屏蔽不可重获的错误。

    •确定编程小组中所有人理解策略并能按照它去做。

    本草的目的就是为了帮助你建立这样的策略。

5.3使用异常

    下面该探讨使用哪种异常的详情了。在下面的例子中,异常一般是用来进行错误处理的——这种用法是经过深思熟虑的。我们先来看一下C++异常的基本特性:

    •异常是基于每个线程而提出并处理的。

    •异常不能被线程忽略,必须被处理。

    •未处理的异常会使进程结束,而不仅仅是线程结束。

    •异常处理在释放栈时会释放所有的栈对象,因此避免了资源的漏洞,

    •异常处理需要大量的额外操作,使得它并不适于经常运行的代码。更详细地说,catch块有一些开销(overhead),但是try块有很少的开销;因此只有在抛出异常的时候才会有很多的异常操作开销。

    •你可以抛出任何类型的异常对象,但不包括整数。

    异常是用于错误处理的。

    有了上面的特性,你应该清楚你不要仅将异常用于任何类型的状态信息。异常更适合用于错误处理代码,因为其他类型的状态信息通常都可以忽略,如果不被处理也绝不会终止进程,而且不保证异常的额外开销。一个函数要么正确执行,要么抛出异常,但是异常并不是正常动作中的一部分。

    很好——那么什么是错误呢?错误就是一种条件。在这种条件下,如果不执行额外的处理,线程就不能正常地执行下去。看看下面的硬件/操作系统等类型的异常:

    •访问违例:线程不能继续执行,因为有一个虚拟内存的地址是无效的。

    •栈溢出:线程不能继续执行,因为没有多余的栈空间。

    •非法指令:线程不能继续执行,因为当前的CPU执行是无效的。

    •整数被零除:线程不能继续执行,因为有一个整数值是无意义的。

    •浮点数溢出:线程不能继续执行,因为有一个浮点数是无意义的。

    显然,在上面的条件中,如果不能处理这些问题,线程不能继续执行,然而默认情况下,这些特殊的异常是由Windows指令异常处理系统(Windows Structured Exception Handling, SEH),而不是由C++的异常处理。我将在稍后对此做一些解释。

    程序还可能有其它被认为是错误的情况。下面是几个典型的例子:

    •文件系统错误。例如找不到盘驱动器、文件夹或文件,或者没有多余的空间。

    •内存错误,例如超出了堆空间或虚拟内存空间。

    •网络错误,例如不能进行网络连接。

    •外围设备错误,例如打印机脱机或没有纸。

    •数据错误,例如输入数据无效或没有输入数据。

    •用户错误,例如用户通过无效的输入进入一个对话框。

    在所有这些例子中,如果没有正确地解决问题,线程也不能正确地执行下去。

    如果正确执行,异常处理有下面的特性:

    •异常是特殊请况。异常不是正常的运行结果。经常发生的情况很可能并不是真正的错误。未预料或不寻常的情况如果不会妨碍正常的运行,也不算是错误。

    •异常在返回值无效的情况下使用。这种情况包括没有返回值的构造器和操作符的错误,例如操作符=和引用计算。注意尽管构造器没有返回值,但是构造器却从来不会抛出异常。

    •异常是可靠的。如果正确地使用异常,将是在异常安全的程序中得到信息的最好方法,因为异常不可能被忽略。

    •异常简化错误处理。异常可以让你清楚地分开一般的程序逻辑和错误处理逻辑,因此简化了程序代码。它们也可以使错误处理更加方便。

5.4 使用返回值

    异常虽然很有价值,但却不适合所有的情形。对于初学者来说,在使用WIndows APl编程或带有COM编程时不得不使用返回值,因为这些技术并不使用异常,因此也并不奇怪,在异常不适合的情况下,使用返回值是一个好的方法。下面是返回值的几个基本的特性:

    •返回值可以指示正常和不正常的函数运行,但不能阻止线程的继续运行。

    •返回值很容易被忽略。

    •返同值在典型的情况下是一个整数,通常映射符合于一个预定义的值。

    •返回值能高效地传递和接收。

    因此,返回值最适合用于以下的情形:

    用于非错误的状态信息。所有非错误的状态信息都应该使用返回值。

    用于大多数情况下可以随意忽略而不会出问题的错误。在这种情况下,使用返回值似乎更为方便,因为你并不想捕获一些不重要的错误信息而使他们阻止程序的运行。

    用于更易于出现在循环中的错误。在这种情况下,错误处理必须快速,因为异常的额外开销,所以为了得到更好的性能,使用返回值是一个更好的选择。在这种情况下,如果你真的想要使用异常,你可以创建一个函数来将返回值转化为异常。

    用于中间语言模块中的错误。使用C++异常的一个问题是它们将模块绑在一个特定的编程语言上。一些中间语言模块,例如COM组件,必须使用返回值而不能使用异常。

    返回值用于非错误的状态信息,也用于那些使用异常并不适合的错误环境。

    如果你选择不使用异常进行错误处理,你可以在自己的代码中使用Windows API错误处理机制。也就是说你可以让你的函数返同一个特殊的值来指示不正常的运行,而且你可以使用 API函数SetLastError设置错误代码。调用函数会通过使用GetLastError检査这个特殊的值并获得错误代码。你也可以使用Windows的错误代码映射机制,GetLastErrorSetLastError API函数的使用和Windows的错误代码映射在第6章“使用Windows调试”,都会介绍。我并不喜欢使用这种方法,因为我发现这两种错误处理技术并不方便。

    当使用返同值时,要努力使用这个值来表达尽可能多的信息。Windows错误代码映射机制是一个好的模型,然而对所有的问题都返回-1似乎对解决问题并没有什么帮助。设法对特定的情况下使用特殊的代码。

    设计你使用的返回值。对所有的问题都返回-1并没有什么帮助。

5.5异常和错误

    我己经定义错误是一种状态,在这种状态下,线程如果没有经过特定的处理就不能正常地继续运行下去。程序故障当然也是这个定义;但是程序故障是由代码中的缺点引起的,然而错误是由代码外部的一些问题引起的,例如堆空间溢出等。Ariane5号出事的错误,然是由程序故障引起的。那么故障呢?从C++异常处理的观点来者,故障应该被当作错误吗?正确答案是“yes”——但是必须有捕获。

    在本章的后面,我举了一个例子说明在发行版本中程序应该处理可重获的与故障相关的异常来保证进程的运行,但是不要在调试版本中使用异常来屏蔽故障,在本节中,我的目标是介绍与故障相关的异常问题,并描述在Visual C++中如何处理与故障相关的异常。最后,执行下面的函数后,你认为会发生什么事呢?

    void TestBugException() {

        try {

            int *pInt = 0;

            // set address 0 to 42, causing an access violation

            *pInt = 42;

        }

        catch(...) {

            MessageBox(0, _T("Exception caught!"), _T("Exception Test"), MB_OK);

        }

    }

    可能的答案是:

    a. 处理访问非法异常。

    b. 访问非法异常没有处理。

    c. 访问非法异常有没有处理决定于环境。

    显然,答案应该是a,但是实际上答案是C。事实上,Visual C++的默认情况下,在调试版本中处理异常,而在发行版本中并不进行处理

    问题是就Visual C++而言,C++异常只能由throw语句抛出,或者调用一个函数(而这个函数中很可能有throw语句)引起异常。任何其他的代码都不能接收C++异常,因此调试器在发行版本中将不需要的异常处理代码除去,以使程序得到最优化,在调试版本中不会进行这种处理。但是等一下——代码确定没有异常吗?实际上是有的。它有一个结构异常(structured exception),它是一个操作系统的属性,而不是C++的异常C++的异常是C++编程语言的一种属性。C++异常只是throw语句的结果。这和程序最优化相符合,然而结构异常由访问非法、被零除、栈溢出等等情况产生。省略捕获处理器可以捕获所有类型的异常,包括系统产生的和程序产生的异常

    我刚刚描述的异常被称为同步异常模型,由/GX编译器选项设置,而且是Visual C++ 6.0的默认设置。你也可以选择使用异步异常模型,异步异常模型采取任何指令都会产生异常的机制。你可以使用/Eha编译器选项,而不要使用/GX选项,来设置这种程序动作。使用异步异常模型因为C++异常处理需要一些特殊的代码来跟踪对象在释放栈的生命周期,如果在不需要这些代码时将其删除,可以使程序更小,因此可以使程序运行更快速。如果在发行版本中一些异常处理消失,而程序员不能完全理解这种情况是很令人吃惊的。编译器动作实际上被认为是一种属性,但并不是一种明显的属性。我想如果Visual C++使用异步异常模型作为默认模型的话,可能程序会更安全—些。但前提是程序员不会改变异常模型,除非他们知道自己在做什么。

    你必须使用/Eha调试器选项来捕获使用C++异常操作机制的操作系统异常。

5.6 C++异常和Windows结构异常处理比较

    沿着上面所讨论的话题,我想我们应该比较一些C++异常和Windows结构异常,并看一下如何将它们在C++程序中结合起来。

    我们先看一下Windows结构异常处理,它由如下的几个特性:

    •它使用_try_exception_finally_leave关键字和RaiseException API函数。

    •它由Windows所支持,因此它不适于其他的操作系统,但是你可以使用任何编程语言(理论上来说),包括C语言。

    •它不处理C++对象的析构。

    •它抛出无符号整数值。异常的代码使用和Windows错误代码一样的规范,这将在第6章中介绍。

    •它作为硬件异常(例如访问非法或被零除)或操作系统异常(例如当一个 API函数经过一个非法的处理)的结果被抛出,它也可以作为RaiseException函数的结果被抛出。

    与之对照,C++异常处理有以下的特性:

    •它使用trythrowcatch等关键字。

    •它被C++语言所支持。

    •它处理C++对象的析构。

    •它可以抛出任何类型的C++对象。异常对象可以从标准的异常基类派生。也可以从任何类派生,或者它们也可以是内置的类型。

    •它作为throw语句的结果被抛出。

    有趣的是,Visual C++使用结构异常处理机制实现C++异常

    顺便说一句,MFC提供了第三种异常处理机制。MFC有自己的异常处理宏,因为它在异常被添加到Visual C++之前就己经创建了。这些宏现在也被编译成C++的异常,因此绝对没有必要在新的代码中使用它们。因此,在MFC编程中使用异常的唯一区别就是所有的MFC异常对象都是从CException基类(它有使用起来非常方便的GetErrorMessageReportError成员函数)中派生来的;大多数的MFC异常对象都是动态分配的,而且当可它们被捕获时,必须被删除;而没有被捕获的MFC异常由MFC本身在AfxCallWndProc函数中捕获并删除。

    结构异常处理不能处理对象的析构,因此你应该在C++程序中一直使用C++异常。然而,因为C++异常不能处理硬件和操作系统异常,你的程序需要将结构异常转化为C++异常。你很可不能不会直接在你的C++程序中使用结构异常,但是理解它但是如何工作还是很有帮助的。Jeffrey Richter在他的书《Programming Applications for Microsoft Windows》对结构异常有很精彩的描述。

5.7将结构异常转化为C++异常

    一般通过使用省略捕获处理器来使用C++异常来捕获结构异常。但是,这种方法丢失了结构异常中的信息,其中包括异常代码、异常地址和CPU注册表的值。此外,你也许希望通过避免使用省略捕获处理器来避免屏蔽错误。幸运的是,Visual C++允许你通过使用_set_se_translator函数将结构异常转化为C++异常,如下所示:

    // You must use /EHa when using _set_se_translator

    #include <eh.h> // exception handling header file

    // a C++ exception class thart contains the SEH information

    class CSEHException {

    public:

        CSEHException(UNT Code, PEXCEPTION_POINTERS pep) {

            m_exceptionCode = code;

            m_exceptionRecord = *pep->ExceptionRecord;

            m_context = *pep->ContextRecord;

            _ASSERT(m_exceptionCode == m_exceptionRecord.exceptionCOde);

        }

        operator unsigned int() {return m_exceptionCode;}

 

        //same as exceptionRecord.ExceptionCode

        UINT m_exceptionCode;

        //exception code, crash address, etc,

        EXCEPTION_RECORD m_exceptionRecord;

        // CPU registers and flags

        CONTEXT m_context;

    };

    // the SEH to C++ exception translator

    void _cdecl TranslateSEHtoCE(UINT code, PEXCEPTION_POINTERS pep) {

        throw CSEHException(code, pep);

    }

    int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,

                       LPTSTR lpCmdLine, int nCmdShow) {

        // install the translator

        _set_se_translator(TranslateSEHtoCE);

        ...

    }

    这段代码安装了TranslateSEHtoCE异常转化器,它使用CSEHException对象抛出一个C++异常,这个对象依次包含所有有用的结构异常信息。异常转化器在每个线程的基础上进行工作,因此你需要为每一个线程安装一个转化器。注意你可以因此创建任意的异常类;你甚至可以只抛出异常代码整数。这种异常转化器有一个副作用,你会在输出窗口的Debug标签中看见两个异常跟踪消息:一个用于原来的结构异常,另一个用于转化后的C++异常

    为了正确处理硬件和橾作系统异常,你可以创建自己的异常类并使用    _set_se_translator函数安装一个结构异常向C++异常的转化器。

    有了上面的代码,你现在就可以像捕获C++异常一样捕获结构异常,如下面的例子所示:

    void TestBugException2() {

        try {

            int *pInt = 0;

            *pInt = 42;

        }

        catch(CSEHException& bug) {

            switch(bug)

            {

            case EXCEPTION_ACCESS_VIOLATION:

                ...

                break;

            case EXCEPTION_INT_DIVIDE_BY_ZERO:

                ...

                break;

            case EXCEPTION_STACK_OVERFLOW:

                // stack overflow is unrecoverable, so rethrow

                throw;

            }

        }

    }

    在处理转化后的结构异常时你需要知道一些细节。第一个就是你应该试着只从下面的几个异常中恢复:

    EXCEPTlON_ACCESS_VIOLATION

    EXCEPTION_INT_DIVlDE_BY_ZERO

    EXCEPTlON_FLT_DlVlDE_BY_ZERO

    EXCEPTION_FLT_OVERFLOW

    •EXCEPTION_FLT_UNDERFLOW

    你不应该捕获其他的异常,因为它们好象不是可以恢复的问题。例如,栈溢出异常就是不可恢复的,因为保留的栈空间已经用尽了(然而如果线程发生栈溢出,线程仍然可以使用栈——但只限于一会儿。原因是栈由一个4KB的保护页保护,因此,当一个线程用光了栈空间,并抛出于一个EXCEPTION_STACK_OVERFLOW异常,线程可以继续执行,但是它也必须立即停止运行。线程栈机制将在第9章“内存调试”中介绍)。因此,在上面的TestBugException2例子中,你不可以在捕获块中通过添加一个default分支来安装默认异常处理器。注意如果你想处理所有的界常(甚至包括了不可恢复的异常),例如编写一个日志文件的入口,然后再结束程序,你应该用SetUnhandledException Filter API函数安装一个程序崩溃处理器(crash handler)。此外,记住要捕获结构异常,你需要使用/EHa编译器选项,不管你是不是要转化异常。

    不要捕获那些不能恢复所产生问题的转化后的结构异常。

    最后,要辨别转化后的结构异常是不是可以处理,你可以将异常转化代码修改为如下所示:

    class CRecoverableSEHException : public CSEHException {

    public:

        CRecoverableSEHException(UNT Code, PEXCEPTION_POINTERS pep)

            : CSEHException(Code, pep) {}

    };

 

    class CUnrecoverableSEHException : public CSEHException {

    public:

        CUnrecoverableSEHException(UNT Code, PEXCEPTION_POINTERS pep)

            : CSEHException(Code, pep) {}

    };

 

    // the SEH to C++ exception translator

    void _cdecl TranslateSEHtoCE(UINT code, PEXCEPTION_POINTERS pep) {

        if(code == EXCEPTlON_ACCESS_VIOLATION ||

            code == EXCEPTION_INT_DIVlDE_BY_ZERO ||

            code == EXCEPTlON_FLT_DlVlDE_BY_ZERO ||

            code == EXCEPTION_FLT_OVERFLOW ||

            code == EXCEPTION_FLT_UNDERFLOW)

          throw CRecoverableSEHException(code, pep);

        else throw CUnrecoverableSEHException(code, pep);

    }

5.8异常的性能

    你可以不用选择只基于性能的异常机制,但是记住异常处理也不是隨心所欲的,也是非常重要的。实际上,如果使用错误代价也是非常大的。然而,如果不使用异常代价也是很大的,因为那需要在程序中用更多的错误处理代码。

    我们先看一看异常开销在什么地方开始。当抛出C++异常时,函数调用链将从此回溯搜索,寻找可以处理抛出的这类异常的处理器。当然,如果没有找到合适的处理器,进程将结束。如果处理器没有找到,调用栈将被释放,所有的自动(局部)变量也将释放,然后栈将被整理为异常处理器的上下文相关设备。因此异常开销由维护一个异常处理器目录和一个活动的自动变量表(它需要额外的代码、内存,而且不论异常是否抛出,都会运行),还得加上函数调用链的搜索、自动变量的析构和栈的调整(它只在拋出异常的时候需要执行)组成的。

    我们再来看一下下面的代码的性能,它明显的是一个异常的错误使用:

    int ReturnValueFunction(int i) {

        if(i%2 == 0)

            return i;

        else

            return 0;

    }

 

    int TestReturnValue() {

        int i, result = 0;

        for(i=0; i<1==; i++)

        {

            result += ReturnValueFunction(i);

        }

        return result;

    }

 

    int ExceptionFunction(int i) {

        if(i%2 == 0)

            return i;

        else

            throw 0;

    }

 

    int TestException() {

        int i, result = 0;

        for(i=0; i<1==; i++)

        {

            try {

                result += ExceptionFunction(i);

            }

            catch(...) {

            }

        }

        return result;

    }

    这里TestReturnValue通过使用返回值对0100之间的所有偶数求和,而TestException使用异常计算相同的总和值。在这个例子中,TestExceptionTestReturnValue要慢8000%。显然,在没有错误的情况下使用异常不是一个好主意。

    一个更有趣的测试是在正确使用异常的情况下再来比较异常的性能。

    CIntObject {

    public:

        CIntObject(int arg){m_data = arg;}

        operator int(){return m_data;}

    private:

        int m_data;

    };

 

    int DereferenceWithPointerCheck(int *pInt1, int *pInt2, int *pInt3) {

        if(pInt1 != 0 && pInt2 != 0 && pInt3 != 0) {

            CIntObject int1(*pInt1),int2(*pInt2),int3(*pInt3);

            return int1 + int2 + int3;

        }

        else

            return -1;

    }

 

    int TestWithPointerCheck() {

        int i, result = 0, data1 = 1, data2 = 2, data3 = 3;

        int *p1 = &data1,*p2 = &data2,*p3 = &data3;

        for(i=0; i<100; i++) {

            result += DereferenceWithPointerCheck(p1, p2, p3);

        }

        return result;

    }

 

    int DereferenceWithException(int *pInt1, int *pInt2, int *pInt3) {

        try {

            CIntObject int1(*pInt1),int2(*pInt2),int3(*pInt3);

            return int1 + int2 + int3;

        }

        catch(...) {

            return -1;

        }

    }

 

    int TestWithException() {

        int i, result = 0, data1 = 1, data2 = 2, data3 = 3;

        int *p1 = &data1,*p2 = &data2,*p3 = &data3;

        for(i=0; i<100; i++) {

            result += DereferenceWithException(p1, p2, p3);

        }

        return result;

    }

    这里TestWithPointerCheckTestWithException通过解除参照指针都将123的值相加100次。TestWithPointerCheck通过检查指针是不是为空来计算,而TestWithException并不检查指针的值而是使用异步异常来处理可能的访问非法异常。因为异常中的大部分开销都涉及到跟踪自动变量,在计算中使用ClntObject对象来保证异常处理要跟踪的变量。最后,代码由/Eha编译器选项编译来处理非法访问,并用/02编译器选项最优化。

    奇怪的是,TestWithException的方法实际上比TestWithPointerCheck方法快4%,这说明在没有抛出异常的异常处理开销比检查指针值是否为空的开销要少。TestWithPointerCheck使用/GX编泽而不用/Eha编译的结果也是一样的。尽管不同的代码会有不同的结果,但是这个实验说明在很少抛出异常的情况下使用异常的开销性能并不是很重要,而在排除了那些用来处理并不可能发生的情况的执行代码后,使用这些异常也确实可以改善性能。

    在很少抛出异常的情况下使用异常的代价并不是很大,而且这样做确实可以提高姓能

    查看一下异常处理如何影响可执行文件的大小也是很有意义的。通过检查一个小程序(150KB大小)的变化,我发现使用异步异常(使用/GX编泽器选项)会增加8%20%的可执行文件的大小,而使用同步异常(使用/Eha编译器选项)会增加13%22%的可执行文件的大小。换另一种说法就是使用异步异常比使用同步异常会增加2%5%的执行大小。当然,结果是否完全依赖于程序是不确定的,但是大一些的程序应该会有少一些的额外开销,因此上面的这些数字应该是最坏的情况。

5.9异常策略

    这里介绍的抛出和捕获异常的策略土要集中在避免错误上。尽管在异常抛出和捕获中有很多机会可以说明错误,但是错误发生最多的还是你的程序没有设计异常处理策略,你的小组中的所有程序员没有很好地理解异常处理策略,同时没有使用策略的情况下。因此异常策略中最重要的一部分实际上就是有一个策略。不要在事后弥补。

什么时候抛出

    在这一点上,你必须清楚什么时候抛出异常。抛出异常的时机应该是一个函数发现一个错误时,如果没有一些特殊的操作,这个错误能阻止它正常的运行,而这种操作它自己不能完成。在函数不可能使用返回值时,异常也会被抛出,而在这种情况下函数失败了。如果正确地操作,这些条件应该是异常情况。与此相对的,返回值应该用于并不是错误的状态信息,用于大多数情况下可以随意忽略的错误,用于循环中(这样使得异常的性能太慢而不可接受)发生的错误,以及用于中间语言模块中。

抛出什么

    C++异常可以抛出任何对象,但是抛出一些对象比抛出其他的对象要好得多。在设计抛出什么的时候,考虑一下如何处理异常的。你从教科书中找到的样本代码经常抛出一些如整数、字符串等的对象,徂是这种对象很难处理。

    现在我们可以看一下捕获异常的规则。下面的规则决定了异常如何被捕获:

    •捕获处理器按顺序提供。

    •如果捕获处理器捕获了同一类型或指向同一类型抛出对象的指针,则应捕获异常。

    •如果捕获处理器捕获了一个公共基类或者指向一个公共基类抛出对象的指针,则应捕获异常。

    •一个省略捕获处理器捕获任何类型的异常,因此它总是放在最后。

    constvolatile属性并不影响这些规则,因此CMyException可以被CMyException&或者const CMyException&捕获。

    当异常被捕获时,捕获处理器必须用下面的格式;

    catch(someType &exceptionParam) {

        ...

    }

    其中异常参数是可选的,但是其他的选项都是需要的。也就是说,每一个处理器必须有一个单独的对象类型和复合语句(带有大括号的)。而且,没有办法在一个相同的处理器中将两个不同类型的异常结合起来,除非它们有共同的基类。

    最后要关注的是处理器必须指出一旦捕获了异常应该干什么。这需要异常对象包括足够的关于错误的信息以做出正确的决定。

    为了指出所有这些要关注的事,最好的解决方法就是设计一个异常类的继承体系(hierarchy),这些类都有一个共同的基类。不同的异常使用不同的异常类型,这使得只使用异常类型确定特定的问题更简单一些。使用一个设计好的继承体系可以让你在一个单独的处理器中处理相关的问题,而且如果你加入更多的异常类型,你也不用改变处理器代码。异常对象应该包含关于特定问题的信息,这样才可以正确处理。

    如果并不使用Unicode的话,C++的异常类为程序中的异常提供了一个很好的基类。下面是类的定义(stdexcpt.h中得到)

    typedef const char *__exString;

 

    class _CRTIMP exception

    {

    public:

        exception();

        exception(const __exString&);

        exception(const exception&);

        exception& operator= (const exception&);

        virtual ~exception();

        virtual __exString what() const;

    private:

        __exString _m_what;

        int _m_doFree;

    };

    然而,因为C++自己也抛出这些异常,你应该为你的程序特别创建另一个基本的异常类,如下所示:

    class CProgramException  : public exception {

    public:

        CProgramException(const _exString& _what_arg) : exception(_what_arg){}

    };

    使用CProgramException类使得异常处理更加简单,因为可以通过处理这种基类捕获所有的程序中的异常。如果需要的话,你也可以使用额外的成员数据全面描述特定的问题。

    定义一个异常基类来处理程序代码抛出的异常。

何时捕获

    一个函数什么时候应该捕获异常?确定什么时候捕获异常是一个很有挑战性的问题,因为它需要你权衡几个冲突的问题。

    •当前的函数更有可能确定了问题是什么,而可能不知道如何处理错误。

    •高级函数可能不知道问题所在(如果是信息隐藏的话,这种情况肯定是有的),但是它们可能知道如何处理错误。

    •异常应该由知道该如何处理的函数进行处理。

    •如果异常没有处理,进程将崩溃,这对可恢复的问题是最不应该发生的。你不应该一直寄希望于调用者来处理这个问题。要使进程继续运行,必须在某个地方先解决问题。

    假设异常可以首先抛出,下面是捕获它的一些可能的标准:

    •当函数知道如何处理这个异常时。

    •当这个函数可以合理地处理这个异常而高级的函数却不知道如何处理。

    •当抛出异常可能使进程崩溃时。

    •当函数可以继续执行它的任务时。

    •当需要整理分配好的资源时。

    当然如果一个函数知道如何处理异常时,它应该处理这个异常。如果高级函数也需要解决这个问题,函数可以处理异常然后再抛出同一个异常或另一个异常。然而,有很多时候高级函数并不知道如何处理这个异常,因此函数也应该对它可以处理的异常进行合理的处理,对高级函数不想或不能处理的异常进行处理。这个问题通常和信息隐藏有关。例如,假设你有一个类由链表实现,还有一个私有的成员数据,或使用私有继承。对于真正的信息隐藏,调用者应该没有使用链表的信息,但是任何一个未捕获的和链表相关的异常都可以说明事实。而且只要抛出的异常有可能使进程崩溃,函数应该始终捕获这种异常。例如,从析构函数抛出的异常可以对程序造成很大的影响,这一会儿就能看到。如果函数可以继续执行的话,它也应该处理异常。例如,考虑下面的代码:

    BOOL IsFileFound(LPCTSTR filename) {

        _ASSERTE(filename !=0); // don't forget the assertions

        try {

            // try to find the file

            BOOL isFound = FALSE;

            ...

            return isFound;

        }

        catch(CFileException) {

            return FALSE;

        }

    }

    当这个函数试图找到这个文件时,函数也有可能拋出一种异常的变体;文件名和路径可能是非法的,驱动器也可能不能访问等等。即使是这样,如果这些问题中的任何一种导致一种和文件相关的异常,这个函数通过返回false仍然可以继续执行下去。因此,捕获这个错误并返回错误值比让调用函数处理这个问题要好得多。

    异常处理一个缺点是它有可能导致资源的泄露。当然,防止资源泄露似乎更是保持程序异常安全的一部分。问题是栈释放(stack unwinding)时会自动整理局部变量,但不包括动态分配的变量。考虑下面的代码:

    void LeakyFunction(int arg) {

        CMyObject* pObject = new CMyObject(arg);

        ... // do something that throws an exception

        pObject->MemberFunction();

        delete pObject;

    }

    这个函数的问题就是如果抛出异常的话,会出现内存的泄露。一个解决方法就是捕获这个异常,堵上泄露的地方,再重新抛出这个异常,如下面的代码所示:

    void LeakyFunction(int arg) {

        CMyObject* pObject = 0;

        try {

            pObject = new CMyObject(arg);

            ... // do something that throws an exception

            pObject->MemberFunction();

            delete pObject;

        }

        catch(...) {

            delete pObject;

            throw;

        }

    }

    然而,如果一个函数处理异常只为了释放资源,有一个更好的方法,就是使用auto_ptr或者一个类似的指针类通过限制局部变量的动态分配来自动释放资源:

    void LeakyFunction(int arg) {

        auto_ptr<CMyObject> pObject(new CMyObject(arg));

        ... // do something that throws an exception

              // can still call member functions as normal

        pObject->MemberFunction();

        // no need to delete pObject

    }

    现在仅仅修改了程序代码的两行,资源已经被释放了,而且也抛出了异常,因此排除了捕获异常的需要。可以使用智能指针来保护你的代码在存在异常的情况下不会产生资源泄漏。调试内存和使用auto_ptr将在第9章讨论。注意auto_ptr仅在使用delete释放资源时使用。你可以创建类似的类来释放其他类型的资源,例如第9章讨论的删除GDI画笔(pen)CAutoPen类。

    我们该回答第2章提出的问题了。考虑下面的代码:

    // approach1

    try {

        if(p1->p2->p3->fn()) ...;

    }

    catch(CRecoverableSEHException &e) {

        //handle access violation exception

    }

 

    // approach2

    if(p1 == 0 || p1->p2 == 0 ||p1->p2 ->p3->fn())

        ...; // handle error

    else if(p1->p2->p3->fn()) ...;

    要创建健壮的代码,更好的方让是直接编写代码并使用异常处理来关注任何一个未预料的问题(如上面方法1介绍的),还是通过在使用之前检查所有的变量(如方法2)明确处理任何可能的问题?我相信使用异常处理是一个更好的方法,其原因不外如下:

    异常处理更简单。它的代码更简单而且它完全分开了程序代码和错误处理代码。更简单也表示代码更不容易出现错误而且更易于维护。

    它更可靠。它不依赖于程序员的编程作风,因为不会有使用变量前忘了检査的风险。而且很可能一个非法的指针值并不是空值,因此检査它是不是为空根本是不可靠的。

    它很有效。如我前面介绍的异常处理性能测试可知,对意外的情况使用异常处理和直接检查一样有效,甚至会更有效。

    如果你使用这种方法,你必须使用是/Eha编译器选项编译,如在本章前面讨论的一样。当然,一定要记住你应该只在意外的情况下使用异常处理。如果你认为一个指针应该是空值,这种条件下就直接在代码中检查这个值,而不要使用异常。异常处理应该用在不可能是空值的情况下。

    使用异常处理更简单,更可靠,更有效,可以创建更健壮的代码。然而,你应该只在意外的情况下使用异常处理。如果你认为一个指针应该是空值,这种条件下就直接在代码中检查这个值,而不要使用异常。

如何捕获

    下面是捕获异常的一些规则:

    MFCC++异常应该通过引用来捕获,如本章中一直在介绍的。使用引用捕获异常不需要删除异常对象(因为使用引用捕获的异常会在栈中传送),而且它保留了多态性(因此你捕获的异常对象正是你抛出的异常对象)。使用指针捕获异常需要你删除对象,而使用值捕获对象会导致对象切片,也就是说,将派生的异常对象转化为捕获的数据类型。要知道关于这方面更多的信息,参见《More Effective C++》的策13条。

    MFC异常应该通过指针来捕获。因为它们通常从堆中分配,你处理完异常之后,你需要调用Delete成员函数,如下所示:

    catch(CFiieException*e) {

        // handle fiile exception

        e->Delete(); // required to prevent a memory leak

    }

    因此,你不可以使用省略捕获处理器捕获MFC异常,因为这会导致一个内存泄露。你必须使用Delete成员函数删除MFC异常,而不要使用delete,因为一些MFC异常作为静态对象创建。参见Paul DiLasciaNucrisoft Systems Journal中发表的“C++ Q&A”,其中有C++异常和MFC异常的很好的比较。

    一旦你捕获了异常,你可以通过执行下列典型动作的一些组合来处理它:

    •什么也不要做。

    •修改这个问题并重试代码。

    •修改这个问题但不要重试代码。

    •如果用户需要的话,向用户显示错误信息。

    •如果出现的问题不是程序错误的话,输出一个诊断的跟踪消息。

    •如果出现的问题是程序错误,输出一个断言。

    •在日志文件中记录这个问题。

    •如果异常是不可恢复的,停止进程的运行,

    •整理己分配的资源。

    •重新抛出这个异常,使得高级函数也能处理这个异常,特别是在当前函数不能完全解决的情况下。你可以重新抛出同一个异常对象,或抛出一个新的异常对象。

使用省略捕获处理器

    正如上面提到的,省略捕获处理器可以捕获任何一种类型的异常,包括系统产生的和程序产生的异常。这种异常处理机制功能很强大,使用也很方便,但如果使用不当的话也是有害的,因为你不能确定出现了什么问题。你能知道的也就是在一些不知道的地方出现了一些不知道的问题。因此,你没办法知道是在当前的函数处理这个异常合适,还是在高级函数中处理比较合适,你也不知道究竟处理这个异常是不是合适。注意处理异常不当会破坏你的程序数据,或造成其他类型的破坏,因此你肯定希望不可恢复的异常直接终止进程。这样不但可以防止程序遭到破坏,而且还可以帮助你调试程序。毕竟,崩溃的程序是发现错误的典型标志,但是从来不会崩溃而又有一堆错误的程序是很难调试的。最后要说的就是,不要使用省略捕获处理器来屏蔽程序错误

    然而,在下面两种情况下,使用省略捕获处理器是非常合适的:

    •处理器将重新抛出同一个异常,或抛出另一个异常。省略捕获处理器经常用于整理资源或重新抛出异常。

    •处理器在任何情况下都会结束进程。

    当你想使用省略捕获处理器,必须要确定出现的问题是不可恢复的,而且进程由于某种原因崩溃了,例如一个不可恢复的结构异常。如果省略捕获处理器在这种情况下仍然有意义,你应该可以安全地使用它。如果不能,试着使用特定的异常对象处理器来处理这个问题。

    如果使用省略捕获处理器不当的话,会有不好的影响,因为你不能确定出现了什么问题。仅在你能确定出现的问题是不可恢复的,而且进程由于某种原因崩溃了的情况下使用它。

    在异常处理的过程中抛出异常可以终止进程。更准确的说法就是,在释放栈的过程中抛出异常会导致进程的终止,但是你可以在异常处理器中抛出异常而不会出问题。释放栈涉及到调用析构函数,因此异常不可以从析构函数中抛出。你可以査看使用了省略捕获处理器以避免漏掉异常的析构函数的代码。这种技术在Windows中并不安全,因为它有可能捕获不可恢复的结构异常。在转化结构异常时,可以使用下面的方法作为代替:

    CSomeObject::~CSomeObject() {

        try {

            // destroy the object

            ...

        }

        catch(CUnrecoverableSEHException) {

            throw; // don't suppress unrecoverable structrued exceptions

        }

        catch(...) {

            // now it's safe to suppress the exception

        }

    }

    顺便说一句,注意从析构函数中抛出异常是非常不好的,即使它不会导致进程的终止,因为异常可以阻止调用delete操作符,这样会有资源的泄漏,特别是语句:

    delete pObject;

相当于

    pObjech->~CSomeObject();

    operator delete(pObject);

    因此在析构函数中抛出异常会跳过operator delete调用。

    你可能注意到了我在所有章中使用的都是省略捕获处理器,即使它们使用的并不正确。我选择没有使用特定的异常处理器是为了简化代码,因为异常处理器非常复杂,用在这些例子中会混淆原来的代码。

使用异常规范

    处理异常的一个问题是知道一个函数可以拋出哪一种类型的异常。函数抛出的异赏应该被认为是接口协议的一部分,和它的参数和返回值一样。当然你必须知道一个函数可以拋出什么异常,这样你才能正确地使用它。

    C++为使用提供了异常规范。考虑下面的函数原型:

    // normal C++ function, can throw any exception

    void NormalFunction();

    void NoThrowRunction() throw(); // cannot throw exception

    // can throw only CException-derived objects

    void ThrowFunction() throw(CException);

    其中,NormalFunction可以抛出任何异常,因为它没有异常规范,而NoThrowRunction则不能抛出异常(你还可以在Visual C++中使用_declspec(nothrow)指明函数不能抛出异常)。尽管这两个函数的异常规范看起来是截然相反的(难道没有throw的函数就能抛出异常吗),这种违反直觉的方法用于维护没有异常规范的代码相反的兼容性。最后,ThrowFunction可以抛出任何从CException派生来的异常对象。异常规范还有另一个好处就是使用有层次的异常类,因为一个重载的虚函数可以抛出任何从基类异常规范中的对象派生来的异常,而不会出现问题。

    异常规范看起来很有用,但是它们也有几个问题:

    异常规范也会导致程序的崩溃。如果抛出的异常不是从任何一个异常规范类派生来的,线程会调用unexpected,它会默认地结束进程。这样并不好。为了创建一个完整的函数规范,你需要找到所有由这个函数直接抛出的异常和所有这个函数调用的函数抛出的未处理的异常。

    系统将在运行时检査它们。不幸的是,异常规范并不是可以在编译时刻可以修改的,因此它们必须在运行时才可以检查(注意如果异常规范在编译时候检査的话,修改一个规范可能会使你的程序整个重新编译)。因此,下面的代码编译时不会出现警告:

    void SomeFunction() throw() {

        throw CProgramException(...);

    }

    很容易就会忘了在异常规范中添加一项,而且你只能从进程的崩溃中发现这一点。使用不正确的异常规范还不如干脆不要使用它。

    它们不能和模板混合。因为模板参数可以是任何类型的,你不能知道这种类型的成员函数可以抛出什么异常。

    结构异常是一个问题。如果你使用异步异常模型,实际上任何函数都可以抛出一个转化后的结构异常。

    它们会导致更多的异常操作。异常规范需要额外的操作,因为异常处理代码必须要加强规范。

    有些人可能不这样认为,但我相信不值得为一个不正确的异常规范崩溃。如果一个函数抛出一个未预料到的异常,让这个异常运行自己的部分,并仅在未处理时使程序崩溃。如果这个异常可以被适当处理的话,只是因为一个不完整的异常规范就让进程崩溃看起来是很愚蠢的。尽管你非常想证明这个异常是由你的函数抛出的,但是我希望再次劝你使用异常规范作为一种增强的机制。

    不要使用异常规范。不值得为一个不正确的异常规范崩溃。

    最后,关于异常规范还有一个更重要的问题:Visual C++目前还不支持它们。你可以将它们编写在代码中,但是编译器会略过它们。你可以检査编译器警告C4290得到更多的信息。即使这样,你现在也应该理解异常规范,也许将来Visual C++就会支持了。

5.10使用异常的防御性编程

    在本章的介绍中,我曾提到你的软件不可能完全没有错误,因此如果你的程序还有剩下的程序错误,你应该做如下的工怍:

    避免破坏数据。如果继续运行程序,就有可能破坏数据或破坏由这些数据控制的外部硬件,那么进程应该用一种有序的方式终止运行,不要动那些数据。

    继续运行。如果继续运行程序不会破坏数据或外部硬件,进程应该继续运行(或者在退化的状态下),而且如果问题很严重,应该通知用户。然后用户就可以决定是要终止进程还是要继续运行它。

    在第3章中,我将防御性编程定义为以这样的方式建构一个程序,那就是当某些不该发生的动作实际上却发生了,而程序继续能起作用。防御性编程可以保护自己不受未预期的数据或非法数据的影响,在未预料的情况下能继续运行,如系统失败或有程序错误。本节介绍了如何使用异常来防御性编程以达到目标的几种方法。

只在必须的情況下才关闭程序

    遇到程序错误时,大多数的程序试图通过正常的关闭程序来释放分配的资源并有可能在日志文件写下一些诊断信息。这种方法是安全的而且也很容易做到,它还可以帮助你跟踪调试错误,但是在用户的选择中这并不是必须的。在我看来,正常关闭程序的方法被高估了:以这种方法结束,火箭依旧会崩溃,文档依旧会丢失。我并不是很清楚Ariane运载火箭在它爆炸时是如何从结束它的内部参照系统受益的。

    而且,对于许多类型的程序来说,Windows中不完善的程序关闭也不是完全都有害的。如果你什么都不做的话,Windows很可能总会收回全部的程序资源。一些程序员喜欢安装用户自定义的程序崩溃处理器来做一些事情,比如利用崩溃地址、栈跟踪和注册表值来创建一个日志文件。如果你什么都不做的话,你可以从Dr. Watson日志文件中强制获得相同的信息。我从来没有听到一个用户说:“啊,我真的始终都不介意程序会崩溃并丢失我的文件。只要它能够正常的终止。真正烦人的是标准的Windows崩溃消息框。”

    的确,要保持程序的长期的稳定性,本质上还是需要整理分配的资源。但是如果程序将要关闭,那么长期就并没有什么价值。当然,确实有些情况下正常的关闭进程是很关键的。例如,对于一个运载火箭来说,使用控制的半空爆炸使其“正常的”关闭比失去控制倾斜到一个大的城市恐怕要好得多吧。任何控制硬件的软件都需要确定任何失败的模式都不会伤及生命、肢体和财产。如果一个机器手臂正常的失败是停止在适当的位置,这也要比野蛮地撞毁设备或攻击操作员要好得多,多进程程序需要正常的关闭,这样才可以所有的进程一起关闭。可能最根本的正常关闭是一个在崩溃时可以重新启动自己的进程,这是Windows资源管理器使用的一种技术。

    正常关闭程序的方法被高估了。如果可能的话应该将精力集中在继续执行程序,并在必须的情况下才正常地关闭程序。

    显然,继续执行程序的主要任务,即使在更低的级别下,也远比执行一个正常的关闭动作要重要。相反,如果可能的话应该将精力集中在继续执行程序,并在必须的情况下才正常地关闭程序。关闭程序应该是你的次要选择,而不是首选的。

防御性编程策略

    为了保护数据不被破坏,并保持程序的执行,我们需要理解什么时候真正需要结束进程。因此,我们需要回答下面的基本问题:

    •什么程序错误是可恢复的?

    •什么程序错误是不可恢复的?

    •怎样处理可恢复的程序错误?

    •怎样处理不可恢复的程序错误?

    为了回答这些问题,找们需要确定异常的类型、可能发生异常的情形以及如何确定程序状态等。

    对异常类型,我首先假设你的程序已经处理了由于一般错误引起的异常,例如磁盘空间不够用了,我们将注意力集中在由程序错误引起的异常。我们可以先将和程序错误相关的异常归类为预料到的和未预料到的。可能会有个问题就是也许这些异常都是未预科的,但是肯定有一些比其他的要更容易预料一些,例如,在下面的代码中:

    int MulDiv(int number, int numerator, int denominator) {

        _ASSERTE(denominator != 0);

        return number * numerator / denominator;

    }

    如果这个程序有错误,一个整数被零除异常可能会首先被想到(这也是程序中有一个断言来检查它的原因),而其他类型的异常则不会。名为MulDiv的函数可以处理这个整数被零除的异常,并且确实会出现在代码中的错误很可能会导致一个零分母。与此相对的,这个名为MulDiv的函数不能处理非法指令异常:如果它处理了,会出现很大的错误。因此,进程应该在接收到一条未预料的异常时关闭。

    我们也可以将与错误相关的异常按照它们的对象类型分类,这样就可以决定它们的来源。如果你已经按照前面介绍的异常策略,并且将结构异常也转化了,那么你的程序抛出的所有异常都是从CProgramException(它也是由exception派生来的)派生来的,而且硬件和Windows抛出的所有异常都是从CSEHException派生来的,因此,你可以将异常类型按照它们的基类分解为如下所示:

    CProgramException是由你的程序代码抛出的异常,它是由程序错误引起的,并且,如果没有被捕获将成为一个故障。所有这样的故障实质上都是可恢复的。

    CException是由MFC抛出的异常,或如果你的程序代码使用MFC异常,它也可由你的程序抛出。它是由程序错误引起的,并且,如果没有被捕获将成为一个故障。所有这样的故障实质上都是可恢复的。

    exception是由C++抛出的异常,包括dynamic_cast(它抛出bad_cast)和标准C++(包括STL)。由exception派生来的异常实际上都是故障,而且,如果没有被捕荻的话,当然更是故障。所有这样的故障实质上都是可恢复的。

    CSEHException是由硬件或操作系统抛出的异常。由访问非法和算法错误(被零除、溢出、下溢)引起的异常通常是可预料到的,实质上也是可恢复的;而所有其他的异常(栈溢出、非法指令、没有找到DLL)就不是这样了。

    也要注意C的运行时函数库函数不会抛出C++异常,但可以抛出结构异常

    然后我们可以将与错误相关的异常按照在程序中发生的地点分类。因为程序有可能是一个用户的应用程序(其中用户可以创建或査看文档)或一个服务器(其中系统请求服务),我使用术语client而不是user来处理这两种情形。我也使用术语document来描述任何一个程序执行任务需要的内部数据,它有可能是普通意义上的文档,也有可能不是。下面是与错误相关的异常出现的时机:

    •程序启动和结束。任何在进程启动和结束时候发生的与错误相关的异常都不可以被视为是可恢复的。在启动时出现的一个与错误相关的异常是一个非常不好的现象。而在进程结束时出现的与错误相关的异常不需要恢复,因为进程总是要结束的。因为当一个客户机不依赖于程序时出现的这些状态,客户机不会丢失任何东西除了使用这个程序的能力。

    •程序执行中。任何在程序命令或事务执行过程中出现的与错误相关的异常实际上都是可恢复的,但是恢复的情况依赖于即将发生的任务。执行程序实际任务的函数是非常关键的,而其他的函数则不是。例如一个文字处理程序,任何发生在修改文档过程中的与错误相关的异常都是很关键的,都会导致程序的结束,如果它们是可挽回的,很可能允许用户将文档保存到临时文件中。与此相对的,发生在用户正在处理用户接口时的与错误相关的异常并不是很关键的,而且也不会导致文档的丢失。低级的函数不具有确定发生在它们运行过程中的程序错误是否重要的能力,因此这些函数应该依照高级函数的错误处理方法。

    总而言之,如果一个错误相关的C++异常是可预料的,如果它发生在非关键性的代码中,如果它不是发生在程序启动或结束过程中或一个不可恢复的结构异常的结果中,这个程序就可以从其中恢复

    一旦你的程序可以从与错误相关的异常中恢复,它应该接着检查程序的状态和它的文档。如果程序和文档己经被破坏了,进程也应该终止运行。不然的话,程序需要通知客户机确定动作的过程。如果客户机同意执行可以进行下去,程序应该恢复错误并继续执行。

    利用这种策略,还有很多的情况需要结束进程,但是也有很多的情况程序可以继续运行。有时此时终止和继续运行下去还是有区别的。

防御性编程实例

    我们现在读看一下下面的实例代码把所有的东西总结一下。一个典型的低级函数应该只处理那些可预料的与错误相关的异常,如下面的代码所示:

    void LowLevelFileFunction(LPCTSTR filename) {

        _ASSERTE(filename != 0); // don't forget the assertions

        try {

            // do something with the filename

            ...

        }

        catch(CFileException) {

            // handle expected error-related exception somehow

            // use a Trace statement to make the error visible during

            // debugging

            _RPTF1(_CRT_WARN, "Couldn't do something with file %s\n", filename);

        }

    }

    在这个函数中可预料的与错误相关的异常得到了处理,但是其他异常必须在更高级的函数中进行处理。

    现在考虑一个关键的低级函数。

    class CCriticalException : public CProgramException {

        public:

        CCriticalException(const _exString& _what_arg) :

            CProgramException(_what_arg){}

    };

 

    void CriticalDocumentFunction() {

        try {

            // modify the document

        }

        catch(expectedException& e) {

            // handle somehow

        }

        catch(...) {

            // any unexpected exceptions here can't be good

            _RPTF1(_CRT_WARN,

                "Unexpected exception occurred while modifying %s\n",

                m_DocumentName);

            throw CCriticalException(

            "An unrecoverable exception may have corrupted the document");

        }

    }

    对这个关键的函数来说,任何出现在修改文挡的过程中的未预料的异常都应该得到处理。这个函数抛出了一个CCCriticalException来向调用者发出信号说明这个文裆有可能遭到破坏。

    创建一个特殊的类清楚地确定任何关鍵的不可恢复的异常

    下面是一个异常在命令级别(commndtevel)如何得到处理的例子:

    int NotifyUser(LPCTSTR problen = 0, UINT code = 0) {

        LPCTSTR format = _T("Can't perform the cast command.\r\

    %s, but the program should still run.\n\

    However, to be safe you might want to save your documents to \n\

    temporary files and restart the program.\n\n\

    Click Abort to quit the program now or Ignore to continue.");

        LPCTSTR codeProblem = _T("A potentially recoverable exception\

    0x%X was found");

        LPCTSTR genericProblem = _T("An unexpected problem was found");

        TCHAR message[LARGE_SIZE], problemBuf[LARGE_SIZE];

        if(problem == 0 || *problem == _T('\0')) {

            if(code != 0) {

                _stprintf(problemBuf, codeProblem, code);

                problem = problemBuf;

            }

            else

                problem = genericProblem;

        }

        _stprintf(memmsage, format, problem);

        return MessageBox(0, message, AppName, MB_ABORTRETRYIGNORE|MB_ICONSTOP);

    }

 

    void CMyDocument::OnSomeCommand() {

        try {

            DoSomeCommand();

        }

        catch(CCriticalException& e) {

            HandleCorruptDocument(e);

        }

        catch(exception& e) {

            if(!IsValid() || NodifyUser(e.what()) == IDABORT)

                terminate();

        }

        catch(CRecoverableSEHException& e) {

            if(!IsValid() || NodifyUser(0, e.m_exceptionCode) == IDABORT)

                terminate();

        }

    }

    如果在命令级别一个函数失败了,这段代码将假设如果没有拋出CCriticalException(它说明这个命令有可能破坏了文档)或没有抛出CUnrecoverableSEhException,那么当前的命令不能够得到执行。然而,程序有可能由于某个原因而崩溃,因此如果这种假设被证明是错误的(例如如果栈在某种程度上遭到破坏,而导致一个访问非法异常),那么下一个高级函数调用会导致一个不能处理的异常。通过在命令级别或事务级别采用防御性异常处理的方法,不可恢复的故障仍然会导致进程的终止。

    第一个异常处理器通过调用HandleCorruptDocument捕获任何出现在DoSomeCommand函数中的关键的异常,而handleCorruptDocument很可能会关闭当前的进程。第二个异常处理器使用CProgramException类来捕获任何程序代码抛出的异常,也捕获由C++本身抛出的异常。第三个处理器仅捕获转化的结构异常,这些结构异常是由程序中的逻辑错误造成的。第二个和第三个处理器验证文挡是有效的,并通知用户出现的问题(如果消息框更灵活的话,就使用可以很大改善性能的消息框),并允许用户决定进程是否继续执行。如果文档无效或用户不想继续执行,进程就终止了。

    为了让所有的命令可以使用这种异常处理的方法,你可以使用宏将代码简化:

    #ifndef _DEBUG

    #define CATCH_DOCUMENT_BUG() \

            catch(CCriticalException& e) {\

                HandleCorruptDocument(e);\

            }\

            catch(exception& e) {\

                if(!IsValid() || NodifyUser(e.what()) == IDABORT)\

                    terminate();\

            }\

            catch(CRecoverableSEHException& e) {\

                if(!IsValid() || NodifyUser(0, e.m_exceptionCode) == IDABORT)\

                    terminate();\

            }\

    #elif

    #define CATCH_DOCUMENT_BUG() catch(...){throw;} // don't catch

    #endif

 

    void CMyDocument::OnSomeCommand() {

        try {

            DoSomeCommand();

        }

        CATCH_DOCUMENT_BUG()

    }

    使用宏也可以使仅在发行版本中执行与错误相关的异常捕获更简单一些,因此也使得更容易在调试版本中找到未处理的异常。当然,在不同的版本中使用不同的代码需要更多的发行版本测试。

    最后,要确定文档没有被命令破坏,就执行一个IsValid函数来使文档有效。这样一个IsValid函数和在MFC断言(参见第3章“使用断言”)中使用的AssertValid类似,除了它返回的是一个布尔值,它在所有的版本中都要被编译,而且不会抛出异常。下面是这样的一个可能的函数形式:

    BOOL CMyDocument::IsValid() {

        try{

            //check the immediate base class first

            if(!CMyDocumentBaseClass::IsValid())

                return FALSE;

 

            //check the data members not in the base class

            if(!m_pObject1->IsValid())

                return FALSE;

            if(!m_pObject2->IsValid())

                return FALSE;

            // now check the class invariants not checked by the base

            // class be sure to doucument the invariants

            if(m_Value == illegalValue)

                return FALSE;

            if(!m_pObject1.GetSize() != m_Size)

                return FALSE;

            ...

            return TRUE:

        }

        catch(...) {

            return FALSE;

        }

    }

    如果你的程序没有错误,用户将不会看到这段代码的任何执行动作。如果你的程序有一个不可恢复的错误,进程将会因为一个未处理的异常崩溃,以防止更多的破坏。如果有一个可恢复的与错误相关的异常,程序将继续起作用(虽然可能由于任何一个有错的命令不能运行而使得性能降低)并避免丢失用户的工作。丢失一些不必丢失的工作是这里最坏错误类型中的一种。

5.11调试异常

    在使用Visual C++调试器时,你可以调试任何first-chance的异常或last-chance的异常。输出窗口中的Debug标签将通知你first-chance的异常。如果程序从Visual C++中运行的话,将出现一个未处理异常消息框来通知你last-chance异常,如图5.1所示。如果程序作为独立个体在操作系统中运行的话,你将接收到一个异常消息框,如图5.2所示。让一个进程因为一个未处理的Last-chance而崩溃在调试时很有用,因为它可以帮助你找到错误。

    对初学者来说,一个first-chance的异常并不是一个错误,或者根本不是一个必须的错误符号。它并不表示从调试器中运行你的程序会出错。而且,一个first-chance异常仅简单地表示调试器检测到抛出了一个异常。只要这个异常最终被程序处理,这就不是一个错误。如果没有被处理,调试器将接收到一个last-chance的异常通知,你的进程会因为这个未处理的异常而崩溃,这很明显是一个错误。通过让调试器给出first-chance异常跟踪消息,Visual C++使得异常处理更加清楚。例如,设想你的程序抛出了数以千计的异常。既然异常对于意外的情况是可预期的,那么这样的行为很可能是不正确的,即使所有的异常都被程序处理了,可是这样的异常处理的代价非常高,处理数以千计的异常将不可避免地影响性能。

5.1Visual C++中运行的程序的未处理的异常消息框

5.2Windows98中运行的程序的未处理的异常消息框

    一个first-chance的异常并不是一个错误,或者根本不是一个必须的错误符号。而且,一个first-chance异常仅简单地表示调试器检测到抛出了一个异常。

    你可以使用下面的过程跟踪异常从何处抛出。首先,注意由输出窗口给出的异常序号。例如,下面的first-chance异常:

    First-chance exception in WorldsBugglestProgram.exe:0xC0000005:Access Violation

    有一个异常号0xC0000005。现在从Debug菜单中选择Exceptions选项,并在异常对话框中找到这个异常号,如图5.3所示。选择Stop always动作来打断first-chance异常或Stop if not handled来打断未处理的last-chance的异常。现在为了要跟踪一个first-chance的异常,运行程序,并等待接收first-chance异常消息框,如图5.4所示。

5.3Visual C++的异常对话框

5.4 Visual C++first-chance异常消息框

    单击OK并检査当前的代码位置和调用栈窗口来确定何时抛出异常和如何抛出异常。注意C++异常并不直接从你的程序代码中抛出而是从C++运行库中抛出,因此你需要使用调用栈窗口来返回你的代码。

    要确定异常在何处被捕获(如果被捕获的话)需要使用(Step out)命令。跳过异常消息框如图5.5所示。单击Yes会跳出异常回到程序(仅在调试器处理问题时才单击No),然后继续使用step out命令直到你在异常处理器中。如果你在first-chance异常消息框中被堵住,并且一次又一次地接收到相同的first-chance异常,你可以通过在异常对话框中将Stop always转为Stop if not handled,修改断点的设置来继续执行。

5.5 Visual C++的跳过异常消息框

5.12各种技巧

    下面介绍的是一些各种各样的技巧来帮助你更好地处理异常。

正确地使用异常处理

    我已经讨论过了何时使用/GX/Eha编译器选项,但是还有另一个情况需要注意。和异步异常(/EHa)相对的是同步异常(/EHs),而不是/GX/GX编译器选项实际上是是/EHsc的简化形式,其中“c”表示编译器应该假设externC”的函数不会抛出C++异常,因此这些函数在异常优化时是不被考虑的。这种假设是合理的,因为extemC”的函数一般上都是由C语言编写成的,因此它们不会抛出C++异常。然而,这种假设也有可能是错误的,如下面的例子所示:

    extern "C" int CompareInt(const void *arg1, const void *arg2) {

        if(arg1 == 0 || arg2 == 0)

            throw CProgramException("Bad pointers passed to comparison function");

        int int1 = *(int*)arg1, int2 = *(int*)arg2;

        if(arg1 < arg2)

            return -1;

        else if(arg1 > arg2)

            return 1;

        else

            return 0;

    }

    在这个例子中,这个函数没有使用/GX编译器选项,而必须使用/EHs

    幸运的是,如果你的程序使用了异常的话,你不需要担心忘记使用一个异常处理编译器选项。如果你真的忘记了,你会接收到一个编译器警告信息:warning C4530:C++ exception handler usedbut unwind semantics are not enabled. Specify-GX(警告C4530:使用了C++异常处理器,但是释放语义无效。详细说明见-GX)

记录异常

    异常有两个可能的使用者:用户和调用环境。通常,异常对象类型用于向调用环境通知出现的问题,而问题的描述字符串用于向用户通知。会出现错误消息的异常应该在异常对象中包括问题的描述信息。例如,从C++ exception类派生来的异常对象可以通过将描述字符串传送给构造函数来描述问题,然后处理器就可以使用what成员函数访问这个字符串。相反地,CSEHException类没有描述字符串,但是它包括足够的信息可以在需要的时候创建一个描述符(description)

    为用户和调用环境记录异常,通常,异常对象类型用于向调用环境通知出现的问题,而问题的描述字符串用于向用户通知。

    描述字符串应该被格式化,这样才可以直接用在错误消息文本中。例如,在本章中,我把字符串格式化为以一个大写字符开头,结尾没有标点。为了方便本地化,你应该使用资源ID号,而不要使用文本字符串。不幸的是,Exception类的设计使得使用资源ID号很困难。因为你不能在对象创建之后再设置描述符(description),而且你也不能在构造函数的成员初始化链中分配一个局部变量。这个问题有一个解决方法,就是在数据成员中加载资源字符串,如下所示:

    extern HINSTANCE hInst;

 

    LPCTSTR LoadResString(int rersID, LPSTR buffer, int bufferLength) {

        if(LoadString(hInst, resID, buffer, bufferLength) == 0)

            *buffer = '\0';

        return buffer;

    }

 

    class CProgramException : public exception {

    public:

        CProgramException(const _exString& _what_arg) :

            exception(_what_arg){}

        CProgramException(int resID) :

            exception(LoadResString(resID, m_Description, LARGE_SIZE)){}

    protected:

        TCHAR m_Description[LARGE_SIZE];

    };

    如果你抛出没有描述符的异常,至少应该在throw语句中利用注释记录异常。

newmalloc抛出异常

    Visual C++的默认情况下,newmalloc对于错误不会抛出异常。关于这种默认动作,唯一一个合理的解释是:对于已存在的代码,并不希望这些函数会抛出异常。新的代码应该让这些函数抛出异常,因为内存分配错误是一种程序不可忽视的错误。你可以通过使用_set_new_handler安装一个处理器,让new针对错误抛出异常。你也可以让malloc通过调用_set_new_mode使用同一个处理器。下面的代码显示了它是如何完成的:

    #include <new.h>

 

    class bad_alloc : public exception {

    public:

        bad_alloc(const _exString& _what_arg) : exception(_what_arg){}

    };

 

    int NewHandler(size_t size) {

        throw bad_alloc("...");

        return 0;

    }

    int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,

                       LPTSTR lpCmdLine, int nCmdShow) {

        _set_new_handler(NewHandler);

        _set_new_mode(1); // use NewHandler for malloc as well

        ...

    }

    你可以让new抛出你想要的任何类型的异常,但是这段代码使用的是标准C++bad_alloc异常。有趣的是,Visual C++并没有定义bad_alloc异常(但是它定义了标准的bad_castbad_typeid异常)。因此,我不得不自已定义这个类。new处理器可以试图找到更多可用的内存并返回一个非零的值表示分配可以再次进行。一般情况下,在Windows中没有一个简单的分配更多的内存空间的方法;因此,NewHandler抛出一个bad_alloc异常并返回零值表示分配失败(尽管return语句永远不会执行)。最后,_set_new_handler安装处理器并调用参数值为1_set_new_mode,以表示malloc应该使用new处理器。

    如果已存在的代码中没有设定new返回空值,那么你应该始终让new出错时抛出一个异。

让浮点数错误抛出异常

    浮点数和整数不一样,在默认情况下它被零除不会出现异常,但是会出现一个非常奇怪的值“1.#INFO(它表示这个值并不是一个数字)。要让检测浮点数问题更简单一些,你应该用如下的代码让浮点数错误抛出异常。

    #include <float.h>

    int cw = controlfp(0, 0);

    cw &= ~(EM_OVERFLOW | EM_UNDERFLOW | EM_INEXACT | EM_ZERODIVIDE |

            EM_DENORMAL | EM_INVALID);

    _controltp(cw, MCW_EM);

    浮点数异常处理器必须调用_clearfp作为它的第一条指令来清空浮点数异常。

5.13推荐阅读

    ......

    Pietrek,Matt. a Crash Course on the Depths of Win32 Structured Exception Handling.Microsoft Systems Journal, January 1997

    提供了系统级别Windows结构异常处理机制的精彩描述。

    ......

    Richter, Jeffrey. Programming Applications for Microsoft Windows: Master the Critical Building Blocks of 32-Bit and 64-Bit Windows_based Applications. Redmond, WA: Microsoft Press, 1999

    Part V, Structured Exception Handling,其中有三草关于Windows结构异常处理介绍得很好。

    Schmidt, Robert. "Handling Exceptions." Microsoft Developer Network.

    有几个关于很多异常处理细节方面的系列,包括很多很有趣又很有用的信息。如果你想知道Visual C++中异常处理是如何进行的细节信息(也包括应该如何执行的信息),这是首选的一本书。这本书的评价很高。但是最好把整个系列读完,因为在前面的部分出现的某些错误在后面的部分被修正了。

    Stroustrup, Bjame. C++ Programming Language, 3rd ed. Reading, MAAddison-Wesley, 1997

    14章“Exception Handling”提供了在C++中异常处理的全面又具有权威的总结。本书提供了关于开发异常策略的很有用的技巧。

    ......

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值