代码改变世界

理解.NET中的异常(一)

2007-03-14 10:26 Anders Cui 阅读(...) 评论(...) 编辑 收藏


或许从第一次使用异常开始,我们就要经常考虑诸如何时捕获异常,何时抛出异常,异常的性能如何之类的问题,有时还想了解究竟什么是异常,它的机制又是什么。本文试着对这些问题进行讨论。


主要内容包括:

为什么使用异常    主要讨论异常与错误码之间的选择

异常的本质        异常的概念的理解   

异常的机制        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三种语句块。

 

try
{
    
// 在此处编写那些需要恢复或清理操作的代码
}
catch (NullReferenceException)
{
    
// 在此处编写能够从NullReferenceException(或其派生类型异常)中恢复的代码
}
catch (Exception)
{
    
// 我们在这个块中编写能够从任何与CLS兼容的异常中恢复的代码

    
// 另外,此时通常应将其重新抛出
    throw;
}
catch
{
    
// 我们在这个块中编写能够从任何与CLS兼容或者不兼容的异常中恢复的代码

    
// 此时通常应将其重新抛出
    throw;
}
finally
{
    
// 在finally块中我们放入那些对try块中启动的操作进行清理的代码。
    
// 不管是否有异常抛出,此处代码总是执行。
}

 

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块的末尾(比如进行恢复操作后),我们有三种选择:

  • 重新抛出所捕获的异常,向更高一层的调用堆栈中的代码通知该异常的发生。

  • 抛出一个不同的异常,向更高一层的调用堆栈中的代码提供更多信息。

  • 让线程从catch块的底部退出。

如果我们选择前两种方法,将抛出一个异常,CLR的行为将和处理前面的异常时一样,遍历调用堆栈搜索合适的筛选器。如果选用第三种方法,会将异常吞掉,更高层次的调用堆栈将不会知晓异常的发生,在执行完catch块中的代码后,立即执行与之关联的finally块(如果存在的话),然后执行当前try/catch/finally语句块之后的代码。

关于这三种方法的选择,将在后面讨论。

3.3 finally

finally块中包含的是确保要执行的代码。一般地,finally块中的代码执行的是一些资源清理操作,这些清理操作通常是try块中的行为所需要的。例如,我们在try块中打开一个文件,那么我们就应该将关闭文件的代码放在与其对应的finally块中。

FileStream fs = null;
try
{
    fs 
= new FileStream(fileName, FileMode.Open);
}
catch (OverflowException)
{
    
// 恢复操作的代码
}
finally
{
    
if (fs != null)
    {
        fs.Close();
    }
}

清理资源的代码不能放在finally块之后,如果出现了异常,但catch块未能捕获,那么这些代码就不会执行了。

try块也并非总需要finally块,有时候try块的操作并不需要任何清理工作。



参考:
 《.NET 框架程序设计》-Jeffery Richter
 《.NET 设计规范》-Krzysztof Cwalina, Brad Abrams