错误处理要分几个部分。首先,我们要定义什么是错误。然后,我们要讨论如何判断代码正在经历一个错误,以及如何从这个错误中恢复。这个时候,状态就成为一个要考虑的问题,因为错误常常在不恰当的时机发生。代码可能在状态改变的中途发生错误。在这种情况下,就可能需要将一些状态还原成改变之前的状态。当然,我们还要讨论代码如何通知它的调用者检测到了一个错误。
本章要讨论针对未处理的异常、约束执行区域(constraind execution region, CER)、代码契约、运行时包装的异常以及未捕捉的异常。
20.1 定义”异常”
设计类型时,首先要想好类型的各种使用情况。类型名称通常是一个名词,例如FileStream或者StringBuilder。然后,要为类型定义属性、方法、事件等。这些成员(属性的数据类型、方法的参数、返回值等)的定义方式就是类型的编程接口。这些成员代表类本身或者类型实例可以执行的行动。行动成员通常用动词表示,例如Read,Write,Flush,Append,Insert和Remove等。当行动成员不能完成任务时,就应抛出异常。
面向对象的编程大大提高了开发人员的效率,因为我们可以这样写代码:
public bool TestFunc(string input) { return input.Substring(1, 1).ToUpper().EndsWith("E"); }
我们没有做任何的参数检查,而直接调用了一长串的方法。当input参数为null或空时,上面的代码就会抛出异常。即使方法为没有返回值的void型也应该报告错误,.Net Framework提供的这种机制就叫做异常处理(excepton handling)。
20.2异常处理机制
本节将介绍异常处理(exception handling)的机制,以及进行异常处理所需的C#构造(construct)。
下面的C#代码展示了异常处理机制的标准用法,通过它可以对异常处理及用途有一个初步认识,后续将对try,catch和finally块做进一步讲解。
private void SomeMethod() { try { //需要执行的代码放在这里 } catch (InvalidOperationException) { } catch (IOException) { //从IOException恢复的代码放在这里 } catch { //从除上面的异常外的其他异常恢复的代码放在这里 throw; //重新抛出捕捉到的任何东西 } finally { //这里的代码总是执行,对始于try块的任何操作进行清理 } // 如果try块没有异常,或异常被捕获后没有抛出,就执行这里的代码 }
20.2.1 try块
try块包含的代码通常需要执行一些通用的资源清理操作,或者可能抛出异常需要从异常中恢复。清理代码应放在一个finally块中。try块还可包含也许会抛出异常的代码。异常恢复代码应该放在一个或多个catch块中。针对应用程序能从中安全恢复的每一种异常,都应该创建一个catch块。一个try块至少要有一个关联的catch块或finally块。
20.2.2 catch块
catch块包含的是响应一个异常需要执行的代码。如果try块中的代码没有造成异常的抛出,CLR永远不会执行它的任何catch块中的代码。线程将跳过所有catch块,直接执行finally中的代码。finally块中的代码执行完毕后,从finally块后面的代码继续执行。catch关键字后面的圆括号中的表达式称为捕捉类型(catch type)。在C#中必须将捕捉类型指定为System.Exception或者它的一个派生类型。
用VS调试catch块时,可通过在监视窗口中添加特殊的变量名称$exception来查看当前抛出的异常对象。
CLR自上而下搜索一个匹配的catch块,所以应该将较具体的异常放在顶部。也就是说,首先出现的是派生程度最大的异常类型,接着是它们的基类型,最后是System.Exception。
如果在try块中的代码抛出一个异常,CLR将搜索捕捉类型与抛出的异常相同的(或者是它的基类)catch块。没有捕捉类型的catch块将捕捉剩余的所有异常。
catch块中的代码通常执行一些对异常进行处理的操作。C#允许在捕捉异常后指定一个变量。捕捉到一个异常时,该变量将引用抛出的这个System.Exception派生对象。catch块中的代码,可以通过引用该变量来访问异常的具体信息。
20.2.3finally块
finally块包含的代码是保证会执行的代码。通常finally块中的代码执行的是try块中行动所要求的资源清理操作。
private void ReadData(String pathname) { FileStream fs = null; try { fs = new FileStream(pathname, FileMode.Open); //处理文件中的数据... } catch (IOException) { //在此添加从IOException恢复的代码 } finally { //确保文件被关闭 if (fs != null) fs.Close(); } }
上述代码中,将关闭文件的语句放在finally块之后是不正确的,因为假若异物抛出但未被捕捉到,该语句就执行不到,造成文件打开状态,直到下一次垃圾回收。
try块并非一定要关联一个finally块。有时候try中的代码并不需要任何清理工作。但是,如果有finally块,它必须出现在所有catch块之后。记住,finally块中的代码是清理代码,这些代码只需负责对try块中发起的操作进行清理。
20.3 System.Exception类
微软定义了一个System.Exception类型,并规定所有公共语言规范(CLS)相容的编程语言都必须能抛出和捕捉派生自该类型的异常。C#只允许抛出CLS相容的异常。派生自System.Exception的异常类型被认为是CLS相容的。
最常用的Exception的属性是Message,StackTrace和InnerException。分别表示异常的文字消息,异常的方法堆栈信息,以及内部异常。
这里有必要讲一下System.Exception类型提供的只读属性StackTrace。catch块可读取该属性来获取一个堆栈跟踪(stack trace),它描述了异常发生之前调用的所有方法和签名,该属性对于调试非常有用。访问该属性时,实际要调用CLR中的代码,该属性并不是简单地返回一个字符串。构造Exception派生类型的一个新对象时,StackTrace属性被初始化为null。如果此时读取该属性,得到的不是堆栈追踪,而是一个null。
一个异常抛出时,CLR会在内部记录throw指令的位置。一个catch块捕捉到该异常时,CLR又会记录异常的捕捉位置。在catch块内访问被抛出的异常对象的StackTrace属性时,负责实现该属性的代码会调用CLR内部的代码,后者创建一个字符串来指出从异常抛出位置到异常捕捉位置的所有方法。
字符串--at ConsoleApplication2.Program.Main(String[] args) in d:\CLR练习\ConsoleApplication2\ConsoleApplication2\Program.cs:line 20
如果CLR能找到你的程序集的调试符号,那么在System.Exception属性返回的字符串中,将包含源代码文件路径和代码行号。
相反,如果仅仅使用throw关键字本身(删除后面的e)来重新抛出一个异常对象,CLR就不会重置堆栈的起点。
20.4FCL定义的异常类
20.5抛出异常
实现自己的方法时,如果方法无法完成方法名所指明的任务,就应抛出一个异常。
抛出异常时,需要注意两个问题:
- 抛出什么类型的Exception派生类型。应该选择一个有意义的类型,永远不要抛出一个System.Exception基类对象。
- 向异常类型的构造器传递什么字符串消息。抛出异常应包含一条字符串消息,详细说明方法为什么无法完成任务。
20.6定义自己的异常类
设计自己的异常不仅繁琐,还容易出错。主要原因是从Exception派生的所有类型都应该是可序列化的,使它们能穿越AppDomain边界边界或者写入日志/数据库。
下面是创建一个自定义异常类型的几个原则:
1,声明序列化,这样可以跨AppDomain访问。
2,添加默认构造函数。
3,添加只有一个message参数的构造函数。
4,添加包含message,内部异常参数的构造函数。
5,添加序列化信息的构造函数,访问级别设为private或protected。
定义自定义异常类型:
[Serializable] public sealed class DiskFullException : Exception { public DiskFullException() : base() { } public DiskFullException(string message) : base(message) { } public DiskFullException(string message, Exception innerException) : base(message, innerException) { } public DiskFullException(SerializationInfo info, StreamingContext context) : base(info, context) { } }
使用例:
try { throw new DiskFullException("disk is full"); } catch (DiskFullException ex) { Console.WriteLine(ex.Message); }
20.7用可靠性换取开发效率
面向对象编程,编译器功能,CLR功能以及庞大的类库---使.Net Framework成为一个颇具吸引力的开发平台。但所有的这些东西,都会在你的代码中引入你没有什么控制权的“错误点”,如 OutOfMemoryExcepton等。程序开发不可能对这些异常进行一一捕捉,让应用程序变得绝对健壮。意料之外的异常往往造成程序状态的破坏,为 了缓解对状态的破坏,可以做下面几件事:
- 执行catch或finally块中代码时,CLR不允许终止线程,所以可以像下面这样使Transfer方法变得更健壮:
private void Transfer(Account from, Account to, decimal amount)
{
try {/* 这里什么也没做*/ } finally { from.Money -= amount; //现在,这里不可能发生线程终止(由于Thread.Abort/AppDomain.Unload) to.Money += amount; } }
但是,绝不建议将所有代码都放到finally块中!这个技术只适合于修改极其敏感的状态。
- 可以用System.Diagnostics.Contracts.Constract类向方法应用代码契约。
- 可以使用约束执行区域(Constrained Excecution Region,CER),它提供了消除一些CLR不确定性的一种方式。
- 可利用事务(transaction)来确保状态要么修改,要么都不修改。如TransactionScope类。
- 将自己的方法设计的更明确。如下面的Monitor类实现获取/释放线程同步锁:
public static class SomeType
{
private static readonly object s_lockObject = new object(); public static void SomeMethod() { Monitor.Enter(s_lockObject);//如果抛出异常,是否获取了锁? //如果已经获取了锁,它就得不到释放 try { //在这里执行线程安全的操作... } finally { Monitor.Exit(s_lockObject); }
//... } }
由于存在上面展示的问题,这个重载的Monitor的Enter方法已经不再鼓励使用,建议像下面这样写:
public static class SomeType
{
private static readonly object s_lockObject = new object(); public static void SomeMethod() { bool lockTaken = false;//假定没有获取锁 try { Monitor.Enter(s_lockObject,ref lockTaken);//无论是否抛出异常,以下代码都能正常工作 //在这里执行线程安全的操作 } finally { //如果已获取锁,就释放它 if(lockTaken == true) Monitor.Exit(s_lockObject); } } }
虽然以上代码变得更明确,但在线程同步锁的情况下,现在的建议是根本不要随同异常处理使用它们。
- 如果确定状态以损坏到无法修改的程度,就应销毁所有损坏的状态,防止它造成更多的伤害。然后重启应用程序,将应用程序恢复到一个良好的状态。由于托管代码不能泄露到一个AppDomain的外部,你可以调用AppDomain的Unload方法来卸载整个AppDomain。如果觉得状态过于糟糕,以至于需要终止这个进程,你可以调用Environment的FailFast方法。这个方法中可以指定异常消息,调用这个方法时,不会运行任何活动的try/finally块或者Finalize方法。
20.8指导原则和最佳实践
我 们认为finally块非常强悍!不管线程抛出什么样的异常,finally块中的代码都保证会执行。应该用finally块清理那些已成功启动的操作, 然后再返回调用者或执行finally块之后的代码。Finally块还经常用于显式释放对象以避免资源泄漏。如下例:
public static void SomeMethod()
{
FileStream fs = new FileStream(@"c:\test.txt", FileMode.Open); try { //显示用100除以文件第一个字节的结果 Console.WriteLine(100 / fs.ReadByte()); } finally { //清理资源,即使发生异常,文件都能关闭 fs.Close(); } }
确保清理代码的执行是如此重要,以至于许多编程语言都提供了一些构造来简化清理代码的编写。例如:只要使用 了lock,using和foreach语句,C#编译器就会自动生成try/finally块。另外,重写类的析构器(Finalize)时,C#编译 器也会自动生成try/catch块。使用这些构造时,编译器将你写的代码放到try块内,并自动将清理代码放在finally块内,具体如下:
- 使用lock语句,锁会在finally块中释放。
- 使用using语句,会在finally块中调用对象的Dispose方法。
- 使用foreach语句,会在finally块中调用IEnumerator对象的Dispose方法。
- 定义析构方法时,会在finally块调用基类的Finalize方法。
例如,用using语句代替上面的代码,代码量更少,但编译后的结果是一样的。
public static void SomeMethod()
{
using (FileStream fs = new FileStream(@"c:\test.txt", FileMode.Open)) { Console.WriteLine(100 / fs.ReadByte()); } }
20.9未处理的异常
异常抛出时,CLR会在调用栈中向上查找与抛出异常类型匹配的catch块。如果没有找到一个匹配的catch块,就发生一个未处理异常。CLR检测到进程中的任何线程有一个未处理的异常,就会终止进程。Microsoft的每种应用程序都有自己的与未处理异常打交道的方式。
- 对于任何应用程序,查阅System.Domain的UnhandledException事件。
- 对 于WinForm应用程序,查阅System.Windows.Forms.NativeWindow的 OnThreadException虚方法,System.Windows.Forms.Application的OnThreadException虚 方法,System.Windows.Forms.Application的ThreadException事件。
- 对于WPF应用程 序,查阅System.Windows.Application的 DispatcherUnhandledException事件和System.Windows.Threading.Dispatcher的 UnhandledException和UnhandledExceptionFilter事件。
- 对于Silverlight,查阅System.Windows.Forms.Application的ThreadException事件。
- 对 于ASP.NET应用程序,查阅System.Web.UI.TemplateControl的Error事件。TemplateControl类是 System.Web.UI.Page类和System.Web.UI.UserControl类的基类。另外还要查询 System.Web.HttpApplication的Error事件。
20.10对异常进行调试
Visual Studio->Debug->Exceptions->Common Language Runtime Exceptions
对于任何异常类型,如果勾选了“引发”复选框,调试器会在抛出该异常时中断。
20.11 异常处理的性能问题
20.12 约束执行区域 (CER)
约束执行区是必须对错误有适应能力的一个代码块,说白点,就是这个代码块要保证可靠性非常高,尽量不出异常。看看下面这段代码:
public static void Demo1()
{
try { Console.WriteLine("In Try"); } finally {//Type1的静态构造器在这里隐式调用 Type1.M(); } } private sealed class Type1 { static Type1() { //如果这里抛出异常,M就得不到调用 Console.WriteLine("Type1's static ctor called."); } public static void M() { } }
运行上述代码,得到以下的结果:
In Try
Type1's static ctor called.
我们希望的目的是,除非保证finally块中的代码得到执行,否则try块中的代码根本就不要开始执行。为了达到这个目的,可以像下面这样修改代码:
public static void Demo1()
{
//强迫finally的代码块提前准备好 RuntimeHelpers.PrepareConstrainedRegions(); try { Console.WriteLine("In Try"); } finally {//Type1的静态构造器在这里隐式调用 Type1.M(); } } private sealed class Type1 { static Type1() { //如果这里抛出异常,M就得不到调用 Console.WriteLine("Type1's static ctor called."); } [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)] public static void M() { } }
得到的结果如下:
Type1's static ctor called.
In Try
PrepareConstrainedRegions 是个非常特别的方法,JIT编译器遇到这个方法,就会提前编译与try关联的catch和 finally块中的代码。JIT编译器会加载任何程序集,创建任何类型,调用任何静态构造器,并对方法进行JIT编译,如果其中的任何操作发生异常,这 个异常会在线程进入try块之前抛出。
需要JIT提前准备的方法必须要应用ReliabilityContract特性,并且向这个特性 传递的参数必须是 Consistency.WillNotCorruptState或Consistency.MayCorruptInstance。这是由于假如方法会 损坏AppDomain或进程的状态,CLR便无法对状态的一致性做出任何保证PrepareConstrainedRegions调用来保护的一个 catch或finally块中,请确保只调用根据刚才的描述设置了 ReliabilityContractAttribute的方法。向ReliabilityContract传递的另一个参数Cer.Success, 表示保证该方法不会失败,否则用Cer.MayFail。Cer.None这个值表明方法不进行CER保证。换言之,方法没有CER的概念。对于没有应用 ReliabilityContract特性的方法等价于下面这样标记:
[ReliabilityContract(Consistency.MayCorruptProcess, Cer.None)]
迫使JIT编译器预先准备的还有几个静态方法,它们都定义在RuntimeHelper中:
public static void PrepareMethod(RuntimeMethodHandle method);
public static void PrepareMethod(RuntimeMethodHandle method, RuntimeTypeHandle[] instantiation);
public static void PrepareDelegate(Delegate d);
public static void PrepareContractedDelegate(Delegate d);
还应关注下RuntimeHelpers 的ExecuteCodeWithGuaranteedCleanup这个方法,它是在资源保证得到清理的前提下执行代码的另一种方式:
public static void ExecuteCodeWithGuaranteedCleanup(RuntimeHelpers.TryCode code, RuntimeHelpers.CleanupCode backoutCode, object userData);
调用这个方法要将try和finally块的主体作为回调方法传递,他们的原型要分别匹配以下两个委托:
public delegate void TryCode(object userData);
public delegate void CleanupCode(object userData, bool exceptionThrown);
最后,另一种保证代码得以执行的方式是使用CriticalFinalizerObject类。
20.13 代码契约
代码契约(code contract)提供了直接在代码中申明代码设计决策的一种方式。
- 前条件 一般用于对实参数进行验证。
- 后条件 方法因为一次普通的返回或者因为抛出一个异常而终止时,对状态进行验证。
- 对象不变性(object Invariant) 用于对象的整个生命期内,保持对象字段的良好性状态。
代码契约有利于代码的使用、理解、进化、测试、文档和初期错误检查。可将前条件、后条件和对象不变性想象为方法签名的一部分。所以,代码新版本的契约可以变得更宽松,但是,除非破坏向后兼容性,否则代码新版本的契约不能变得更严格。
代码契约的核心是静态类System.Diagnostics.Contracts.Contract。由于该技术较新,实际中运用机会不多,故不再投入大量精力去研究。具体用时可以查阅MSDN相关文档。