或许从第一次使用异常开始,我们就要经常考虑诸如何时捕获异常,何时抛出异常,异常的性能如何之类的问题,有时还想了解究竟什么是异常,它的机制又是什么。本文试着对这些问题进行讨论。
主要内容包括:
为什么使用异常 主要讨论异常与错误码之间的选择
异常的本质 异常的概念的理解
异常的机制 try,catch,finally三种语句块的讨论
System.Exception及其它FCL中的异常类 讨论.NET Framework中预定义的异常类型
自定义异常类 如何建立自定义的异常类型
正确地使用异常 关于异常使用的一些规范和约定
性能问题的考虑 了解异常对性能的影响,并给出一些建议
1、为什么要使用异常
通常在处理程序中的错误时,可以使用异常,也可以使用返回值的方式。与用返回值来报告错误相比,异常处理有诸多优势:
异常已经很好地与面向对象语言集成在一起。
在某些情况下,如构造函数、操作符重载及属性,开发人员对返回值没有什么选择的余地。想在面向对象框架中统一使用返回值来报告错误是不可能的。在上述情况下,唯一的选择是使用返回值之外的方法,如异常。既然如此,最好的办法就是在所有地方都使用异常来报告错误。这是必须用异常来报告错误的最重要原因(Jeffery Richter语)。
异常增强了API的一致性。
异常的唯一目的就是为了报告错误,而返回值则有多种用途,报告错误只是其中之一。因此如果使用异常,那么报告错误的方式是固定的,这保证了API的一致性。
更容易使错误处理的代码局部化,错误处理的代码可以放在一个更集中的位置。
有很多原因可以导致代码失败,如引用null对象、索引越界、访问已关闭的文件、内存耗尽等等。不使用异常处理,要使我们的代码容易地检测这些原因、并从中恢复过来,即使有可能,也是很困难的。同时要进行这种检测的代码需要散布在程序主逻辑中,使得编码过程变得非常困难,代码也难以理解和维护。
如果使用异常处理,我们就无需再自己编写代码来检测这些潜在的故障。我们只管放心地编码,并“自信地”认为代码没有问题,这个过程自然会变得简单,代码也容易理解和维护。最后将异常恢复代码放在一个集中的位置(try代码块的后面,或者调用栈的更高层),当程序出现故障时,才会执行这些恢复代码。
使用异常,我们可以将资源清理代码放在一个固定的位置,并确保得到执行。
将资源清理代码从应用程序的主逻辑移到一个固定的位置后,应用程序将会变得更容易编写、理解和维护。
容易定位和修复代码中的Bug
一旦程序出现了问题,CLR会遍历线程的调用堆栈,以查找能够处理该异常的代码。如果找不到这样的代码,我们将收到一个开发人员很不愿看到的“未处理异常”通知。根据这个通知,可以很容易地定位问题发生地点,判定原因,并修复Bug。
错误码容易被忽略,而且通常会被忽略。而异常则不然。
异常允许用户定义未处理异常的处理程序,那错误码呢?
另外,异常有助于与一些工具的交互。
各种工具(如调试器、性能分析器、性能计数器等)会时刻注意异常的发生。返回错误码的方法就没有这些好处。
2、异常的本质
异常本质上是对程序接口隐含假设的一种违反。
在设计一个类型时,我们首先应设想该类型被应用的各种场景。类型的名称通常是一个名词,如FileStream,StringBuilder等。然后再为类型定义属性、方法及事件等成员。这些成员的定义方式(属性的数据类型,方法的参数、返回值等)就是类型的程序接口。同时,这些成员描述了类型(或其实例)所能执行的操作,它们的名称通常为动词,如Read,Write,Flush,Append,Insert,Remove等。
对于类型的一个成员来说,如果不能完成它的任务,就应当抛出异常。异常意味着类型的成员未能完成其名称所描述的功能。
我们定义的程序接口通常会有一些隐含假设,如System.IO.File.OpenText(string path)方法,它的功能是打开现有的UTF-8编码文本文件以进行读取,要完成该功能,就要求path参数是一个合法的路径、指定的文件存在、有足够的权限等。所有这些要求在MSDN文档中都有所记录,我们在调用该方法时可以详细地查看文档,以最高效的方式来调用它。但我们都了解,这不太现实,我们不太可能去了解所有的这些隐含的假设,从而也就不可避免地违反它们了。
那么,对于OpenText方法的开发人员来说,如何通知调用它的程序,隐含假设被违反了呢?答案是抛出一个异常(你也许会想到返回值,关于返回值和异常的取舍请参看第1节)。
所以在设计一个类型时,我们应该首先假设类型最常见的使用方式,然后设计其接口使之能够很好地处理这种情况。最后再考虑接口带来的隐含假设,并且当任何假设被违反时便抛出异常。
这样理解下来,有些观点正确与否就很清楚了。
我们把注意力放在前面提及的OpenText方法,来看看下面两种关于异常的误解。
异常与事情发生的频率有关
OpenText方法的开发人员决定何时抛出异常,但真正引发异常的却是调用代码,那OpenText方法的开发人员又怎么知道调用代码引发异常的频率?
异常就是错误
错误意味着程序员犯错了,当调用代码在应用程序中错误地调用了OpenText方法时,设计该方法的开发人员何从知道呢? 只有调用程序才能判断调用的结果是否出现了错误。换言之,异常是OpenText方法对调用程序的一种反馈,而调用的结果是否为错误则是由调用程序判断的,是与调用程序的上下文环境相关的。3、异常的机制
下面的C#代码展示了异常处理的标准用法,从总体上描述了异常处理的样子和用途。在代码之后详细讨论了try、catch、finally三种语句块。
3.1 try块
try块中包含的通常是需要进行清理或/和异常恢复的操作。所有的资源清理代码都应该放在一个finally块中(以确保总是得到执行)。try块还可以包含可能抛出异常的代码。进行异常恢复操作的代码则应该放在一个或多个catch块中。一个try块必须有至少一个与之相关联的catch块或finally块,单独一个try块是没有意义的(C#编译器也不允许你这么做)。
3.2 catch块
catch块中包含的是出现异常时需要执行的相应代码。一个try块可以有0个或多个catch块与之相关联。
如果try块中的代码没有抛出异常,CLR就不会执行与该try块关联的catch块的代码。而是会跳过所有的catch块,直接执行finally块(如果存在的话)中的代码,然后再执行finally块之后的语句。
catch关键字后面的表达式称作异常筛选器(exception filter)。它表示开发人员预料到的、并可以从中恢复的一种异常情况。代码执行时是自上而下搜索catch块的,因此要将更具体的(是指该类型在继承体系中的层次)异常放在上面。事实上,C#编译器不允许更具体的catch块出现在离代码底部更近的位置。
如果try块(或者被try块调用的方法)中的代码抛出了一个异常,CLR将搜索那些筛选器中能够识别该异常的catch块。如果如该try块相关联的筛选器中没有一个能够接受该异常,CLR将沿着调用堆栈向更高层搜索能够接受该异常的筛选器,如果直到堆栈顶部依然没有找到能够处理该异常的catch块,就会出现所谓的未处理异常。
一旦CLR找到了一个能够处理所抛出异常的筛选器,它将执行从抛出异常的try块开始,到匹配异常的catch块为止的范围内所有的finally块(注意要在调用堆栈中理解,此处执行的代码不包括匹配异常的catch块相关联的finally块),然后调用匹配catch块中的代码,最后才是匹配catch块相关联的finally块的代码。
在C#中,异常筛选器可以指定一个异常变量。当捕获到一个异常时,该变量指向那个被抛出的、类型继承自System.Exception的对象,此时可以通过该变量获取异常的相关信息(如其堆栈踪迹)。尽管可以改变该对象,但不应这么做,应把它当作只读变量。
catch块中的代码一般执行一些从异常中恢复的操作。在catch块的末尾(比如进行恢复操作后),我们有三种选择:
如果我们选择前两种方法,将抛出一个异常,CLR的行为将和处理前面的异常时一样,遍历调用堆栈搜索合适的筛选器。如果选用第三种方法,会将异常吞掉,更高层次的调用堆栈将不会知晓异常的发生,在执行完catch块中的代码后,立即执行与之关联的finally块(如果存在的话),然后执行当前try/catch/finally语句块之后的代码。
关于这三种方法的选择,将在后面讨论。
3.3 finally块
finally块中包含的是确保要执行的代码。一般地,finally块中的代码执行的是一些资源清理操作,这些清理操作通常是try块中的行为所需要的。例如,我们在try块中打开一个文件,那么我们就应该将关闭文件的代码放在与其对应的finally块中。