思考并回答以下问题:
本章涵盖:
- 多异常类型
- 捕捉异常
- 常规catch块
- 异常处理的规范
- 定义自定义异常
- 封装异常并重新引发
- 小结
第4章讨论了如何使用try/catch/finally块执行标准异常处理。在那一章中,catch块总是捕捉System.Exception类型的异常。本章描述了异常处理的更多细节,具体包括其他异常类型、定义自定义异常类型以及用于处理每种异常类型的多个catch。本章还详细描述了异常对继承的依赖。
多异常类型
代码清单10-1引发System.ArgumentException异常,而不是如第4章所述的System.Exception类型的异常。C#允许代码引发从System.Exception派生(无论直接还是间接)的任何异常类型。
代码要引发任何异常,只需为要引发的异常实例附加关键字throw作为前缀。具体选择的异常类型应该能够最好地说明异常发生的背景。
下面以代码清单10-1的TextNumberParser.Parse()方法为例。
代码清单10-1 引发异常
1 | public sealed class TextNumberParser |
程序不是引发System.Exception,而是引发更合适的ArgumentException,因为类型本身指出什么地方出错(参数异常),并包含了特殊的参数来指出具体是哪一个参数出错。
两个类似的异常是ArgumentNullException和NullReferenceException,前者应该在错误传递了null时引发。null是无效参数的特例。如果不为null,无效参数所引发的异常是ArgumentException或ArgumentOutOfRangeException,NullReferenceException一般只有在底层“运行时”解引用null值(想调用对象的成员,但发现对象的值为null)时引发。开发人员不要自己引发NullReferenceException。相反,应该在访问参数前检查它们是否为null,并在参数为null的前提下引发ArgumentNullException,从而提供更具体的上下文信息,如参数名等。
还有其他几个仅供“运行时”引发的、直接或间接从System.SystemException派生的异常,其中包括System.StackOverflowException,System.OutOfMemoryException,SystemRuntime.InteropServices.COMException,System.ExecutionEngineException和System.Runtime.InteropServices.SEHException,不要自己引发这些异常。类似地,不要引发System.Exception或System.ApplicationException,它们过于泛泛,对指明问题的起因和问题的解决提供不了太多帮助。相反,要引发符合场景的、最具体的派生的异常。虽然开发人员应避免创建可能造成系统错误的API,但假如代码的执行达到一种再执行就会不安全或者不可恢复的状态,就应该果断地调用System.Environemnt.FailFast()。这样做会向Windows Application事件日志写入一条消息,然后立即终止进程。如果用户事先进行了选择,消息还会发送给“Windows错误报告”。
规范
要在向成员传递了错误参数时引发ArgumentException或者它的某个子类型。引发尽可能具体的异常
(如ArgumentNullException)。
要在引发ArgumentException或者它的某个子类时设置ParamName属性。
要引发能说明问题的、最具体的异常(派生得最远的异常)。
不要引发NullReferenceException,相反,在值意外为空时引发ArgumentNullException。
不要引发System.SystemException或者它的派生类型。
不要引发System.Exception或者System.ApplicationException.
考虑在程序继续执行会变得不安全时调用System.Enviromment.FailFast()来终止进程。
捕捉异常
引发特定的异常类型,可以通过异常类型本身来识别(并解决)问题。换言之,不需要捕捉异常并使用一个switch语句,根据异常消息来决定要采取的操作。相反,C#允许使用多个catch块,每个块都面向一个具体的异常类型,如代码清单10-2所示。
代码清单10-2 捕捉不同的异常类型
1 | using System; |
代码清单10-2总共有5个catch块,每个块都处理不同的异常类型。发生异常时,会跳转到与异常类型最匹配的catch块执行。匹配度由继承链决定。例如,即使引发的是system.Exception类型的异常,由于System.InvalidOperationException派生自System.Exception,这个“属于”关系就在继承链中发生了。因此InvalidOperationException与引发的异常匹配度最高。最终,将由catch(InvalidOperationException …)捕捉到异常,而不是由catch(Exception …)块捕捉。
catch块必须按照从最具体到最常规的顺序排列,这样才能避免编译错误。例如,将catch(Exception …)块移到其他任何一种异常的前面,都会造成编译错误。因为之前的所有异常都直接或间接从System.Exception派生。
如catch(SystemException){}这个catch块所示, catch块并非一定需要一个命名的参数。事实上,如下一节所述,最后一个catch甚至连类型参数都可以不要。
注意,在捕捉InvalidOperationException的catch块中,有一个未注明要引发什么异常的throw语句(throw自己为一行语句)——虽然当前在catch块的范围内有一个异常实例(exception)可供重新引发。如果引发一个具体的异常,会更新所有栈信息来匹配新的引发位置。这会造成指示异常最初发生的调用位置的所有栈信息丢失,使问题变得更难诊断。有鉴于此,C#支持不带有显式异常引用的throw语句,前提是只能在catch块中使用。这样,代码可以检查异常,判断是否能完整地处理该异常;如果不能,就重新引发异常(虽然没有显式指定这个动作)。结果是异常似乎从未被捕捉,也没有任何栈信息被替换。
常规catch块
C#要求代码引发的任何对象都必须从System.Exception派生。从C# 2.0开始,所有异常(不管是不是从system.Exception派生)在进入程序集之后,都会被“包装”成从System.Exception派生。结果是捕捉System.Exception的catch块现在可以捕捉之前的块不能捕捉的所有异常。
C#还支持常规catch块,即catch{},它在行为上和catch(System.Exception exception)块完全一致,只是没有类型名或变量名。除此之外,常规catch块必须是所有catch块的最后一个。由于常规catch块在功能上完全等价于catch(System.Exception exception)块,而且必须放在最后,所以在同一个try/catch语句中,假如这两个catch块同时出现,编译器就会显示一条警告消息,因为常规catch块永远都得不到调用。
异常处理的规范
异常处理为它之前的错误处理机制提供了急需的基本结构。然而,若使用不当,它仍有可能造成一些令人不快的后果。以下规范是异常处理的最佳实践。
只捕捉能处理的异常。通常,一些类型的异常可以处理,但另一些不能。例如,试图打开正在使用的文件进行独占式读/写访问,会引发一个System.IO.IOException,因为文件已经在使用中了。通过捕捉这种类型的异常,代码可以向用户报告该文件正在使用,并允许用户选择取消或者重试。只有那些已知操作的异常才应捕捉。其他异常类型应留给栈中较高的调用者去处理。
不要隐藏你不能完全处理的异常。新手程序员常犯的一个错误是捕捉所有异常,然后假装什么都没有发生,而不向用户报告未处理的异常。这有可能导致严重的系统问题逃过检测。除非代码执行显式的操作来处理一个异常,或者显式地确定一个异常无害,否则catch块应当重新引发异常,而不是在捕捉了异常之后在调用者面前隐藏它们。尤其要注意的是, catch(System.Exception)和常规catch块应放在调用栈中较高的位置,除非决定在块中重新引发异常。
尽可能少地使用System.Exception和常规catch块。几乎所有异常都是从System.Exception派生的。然而,处理某些system.Exception的最佳方式是不对它们进行处理,或者尽快以正常方式关闭应用程序。这些异常包括System.OutofMemoryException和System.StackoverflowException等。在CLR4中,这些异常默认为“不可恢复” 。所以,如果捕捉它们但不重新引发它们,会造成CLR重新引发它们。这些异常是运行时异常,开发人员不能写代码从这些异常中恢复。所以,最佳对策就是关闭应用程序—在CLR 4和更高版本中,这是“运行时”会强制采取的操作。CLR4之前的代码在捕捉这种异常后,也只应运行清理或紧急代码(比如保存任何易失的数据),然后马上关闭应用程序,或者使用throw:语句重新引发异常
避免在调用栈较低的位置报告或记录异常。新手程序员倾向于异常一发生就记录它,或者向用户报告它。然而,由于当前正处在调用栈中较低的位置,而这些位置很少能够完整地处理异常,所以只好重新引发异常。像这样的catch块不应记录异常,也不应向用户报告。假如异常被记录,然后又被重新引发(调用栈中较高的调用者可能做同样的事情),就会造成重复出现的异常记录项。更糟的是,取决于应用程序的类型,向用户显示异常可能并不合适。例如,在Windows应用程序中使用System.Console.writeLine (),用户永远看不到显示的内容。类似地,在无人值守的命令行进程中显示对话框,可能根本不会被人看到,而且可能使应用程序冻结在这个位置。日志记录和与异常相关的用户界面应该保留到调用栈中较高的位置。
在catch块中使用throw;而不是throw <异常对象>语句。可以在catch块中重新引发异常。例如,在catch(ArgumentNu1IException exception)的实现中,可以包含对throw exception的调用。然而,像这样重新引发异常,会将栈追踪重置为重新引发的位置,而不是重用原始引发位置。所以,只要不是重新引发不同的异常类型,或者不是要故意隐藏原始调用栈,就应该使用throw;语句,允许相同的异常在调用栈中向上传播重新引发不同异常时要小心。在catch块中重新引发不同的异常,那么不仅会重置引发点,还会隐藏原始异常。为了保留原始异常,需要设置新异常的InnerException属性(该属性通常可以通过构造器来赋值)。只有以下情况才可以重新引发不同的异常。
a)更改异常类型可以更好地澄清问题。例如,在对Logon(User user)的一个调用中,假如遇到用户列表文件不能访问的情况,那么重新引发一个不同的异常类型要比传播5ystem. Io. IOException更合适。
b)私有数据是原始异常的一部分。在上面的例子中,假如文件路径包含在原始的System. Io. IOException中,就会暴露敏感的系统信息,所以应该使用其他异常类型来包装它。当然,前提是原始异常没有设置InnerException属性。有趣的是, CLR v1的一个非常早的版本(比alpha还要早的一个版本)有一个异常会报告这样的消息: “安全异常:没有足够权限确定c:\temp\foo.txt的路径”.
c)异常类型过于具体,以至于调用者不能恰当地处理。例如,不要引发数据库系统的专有异常。相反,可以使用一个较为泛化的异常,避免在调用栈较高的位置写数据库的专有代码。
规范
避免在调用栈较低的位置报告或记录异常。
不要捕捉不应该捕捉的异常。要允许异常在调用栈中向上传播,除非能非常清楚地知道如何通过程序准确地定位栈中较低位置的错误。
如果理解特定异常在给定的上下文中为何引发,并能通过程序响应错误,就考虑捕捉该异常。
壁免捕捉System.Exception或System.SystemException,除非是在顶层异常处理程序中在重新引发异常之前执行最后的清理操作。
要在catch块中使用throw;而不是throw <异常对象>语句。
重新引发不同的异常时要小心。
不要引发NullRefernceException,相反,在值意外为空时引发ArgumentNul1Exception.
定义自定义异常
在必须引发异常时,首选的方案是使用NET Framework的异常,因为它们得到了良好的构建,能够被很好地理解。例如,首选的不是引发自定义的“无效参数”异常,而是引发System.ArgumentException,然而,假如使用特定API的开发人员想要执行特殊操作(例如,处理自定义异常的逻辑有所不同),就可以考虑定义一个自定义异常。例如,假定某个地图API接收到邮政编码无效的一个地址,那么不是引发System. ArgumentException,相反,更好的做法也许是引发自定义的InvalidAddressException。这里的关键在于调用者是否愿意编写专门的InvalidAddressExceptioncatch块来进行特殊处理,而不是用一个常规的System.ArgumentException catch块。
定义一个自定义异常时,从System.Exception或者其他异常类型派生就可以了。代码清单10-5展示了一个例子。
代码清单10-5创建自定义异常
这个自定义异常包装了多个专门的数据库异常。例如,因为Oracle和SQL Server会为类似的错误引发不同的异常,所以可以在应用程序中定义一个自定义异常,将不同数据库特有的异常标准化到一个通用的异常封装器中,使应用程序能采取标准方式处理相似的异常。这样,不管应用程序使用的后端数据库是Oracle还是SQL Server,都可以在调用栈较高的位置用同一个catch块处理相似的错误。
自定义异常唯一的要求是必须从System.Exception或者它的某个子类派生。除此之外,在使用自定义异常的时候,还应遵照以下最佳实践。
所有异常都应该使用”Exception”后缀,这样一来,从名称上就很容易得知它们的用途。通常,所有异常都应包含以下3个构造器:无参构造器、获取一个string参数的构造器以及同时获取一个字符串和一个内部异常作为参数的构造器。除此之外,由于异常通常是在引发它们的那个语句中构造的,所以也应允许其他任何异常数据成为构造器的一部分。(当然,假如特定的数据是必需的,而构造器回避了这个要求,则不能创建该构造器。)
避免使用深的继承层次结构(一般应该小于5级)。
重新引发一个与已捕捉到的异常不同的异常时,内部异常将发挥重要作用。例如,假定一个数据库调用引发了system.Data.SqlClient.SqLException,但这个异常在数据访问层捕捉到,并作为一个DatabaseException重新引发,那么获取sqlException (或者内部异常)的DatabaseException构造器会将原始SqlException保存到InnerException属性中。这样,在请求与原始异常有关的附加细节时,开发者就可以从InnerException属性中获取异常