一:自己的感悟
今天读到《CLR via C#》的异常和状态管理这一章,作者给出了关于异常处理的诸多建议,里面有一些建议自己深有体会,比如说使用可靠性换取开发效率这一节。之前自己对异常怎么处理也有过自己的思考,归纳了一下主要有以下几点:
1.不要什么异常都捕捉,只有在自己不确定这段代码会不会有问题时才去捕捉异常,大部分的异常应该在开发测试阶段就消灭
2.异常在没有发生时异常对程序的效率没什么影响,一旦发生异常程序的效率的就会下降好几倍
以上就是自己在写程序时对异常的处理,虽然比较了解.NET的异常处理机制比较了解,但是如何使用.NET的异常处理机制写出比较健壮的程序却没有太多敬仰。今天看到CLR的这一章,作者的敦敦教导只能理解其中的一小部分,应该还是自己敲的代码不够多,日后多看点优秀的.NET开源项目,模仿项目中异常处理方法。现在对这一章做一个读书笔记,如下。
二:异常处理机制
.Net Framework异常处理机制是用Windows提供的结构化异常处理(Structured Exception Handling,SHE)机制构建的。
下面的C#代码展示了异常处理机制的标准用法,通过它可以对异常处理及用途有一个初步认识,后续将对try,catch和finally块做进一步讲解。
private void SomeMethod() { try { //需要执行的代码放在这里 } catch (InvalidOperationException) { //从InvalidOperationException恢复的代码放在这里 } catch (IOException) { //从IOException恢复的代码放在这里 } catch { //从除上面的异常外的其他异常恢复的代码放在这里 //??? //捕捉到任何异常,通常要重新抛出异常。 throw; } finally { //这里的代码总是执行,对始于try块的任何操作进行清理 } // 如果try块没有异常,或异常被捕获后没有抛出,就执行这里的代码 }
2.1 try块
Try块中包含的代码通常需要执行一些资源清理操作,或可能抛出异常需要从异常中恢复,或者是两者皆有。清理代码应放在finally块中。异常恢复的代码应放在一个或多个catch快中。针对应用程序能从中安全恢复的每一种异常,都应创建一个catch块。一个try块至少要有一个catch块或finally块。单独的try块没有意义。
指导原则:很多程序员不知道在一个try块中的放入多少代码。如果一个try块中执行多个可能抛出同一异常的操作,但这些操作有不同的恢复措施,那么对每一个操作都应放在一个单独的try块中。
2.2 catch块
Catch块是响应一个异常需要处理的代码,catch关键字后的圆括号内的表达式称为捕捉类型(catch type)。在C#中,必须将捕捉类型指定为System.Exception或它的派生类型。没有捕捉类型的catch块将捕捉剩余的所有异常。多个Catch块的顺序应将派生程度最大的类型放在最前面,最后放System.Excepton或没有捕捉类型的catch块。如果顺序错误会导致编译错误。
如果在try块中抛出一个异常,CLR将搜索捕捉类型与异常类型相同的catch块,如果没有找到,CLR会调用栈的更高一层搜索与异常类型匹配的捕捉类型,如果到了栈的顶部还未找到,就会发生一个未处理异常。一旦找到一个匹配的catch块,所有内层的finally块会被执行;如果没有找到,内层的finally块是不会执行的,这一点要特别注意。接着执行匹配catch块内容,最后是该catch块对应的finally块(如果有的话)。
对于匹配的catch块,我们通常的处理有3种:
●重新抛出相同的异常,向上一层栈通知该异常的发生。
●抛出一个不同的异常,向上一层栈提供更丰富的异常信息。
●让线程从catch块的底部退出。(表示已处理这个异常,不会发生新的异常)
可以通过向AppDomain的FirstChanceException事件登记,只要在这个AppDomain内发生的异常,它就会收到通知。
2.3 finally块
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(); } }
当然,catch块中的异常恢复代码和finally块中的清理代码也有可能失败并抛出一个异常。虽然这种可能性不大,但如果发生这样的事情,CLR会把这种异常当成是finally块之后抛出的异常一样。但此时,CLR不会记录try块中抛出的第一个异常(如果有的话),第一个异常的堆栈信息将丢失。而新抛出的异常很有可能成为一个未处理的异常,CLR会终止这个进程。这是一件好事,因为损坏的状态会被销毁。相较于应用程序继续运行,造成不可预知的结果以及不可能的安全漏洞,这样的好处多得多。
2.4 CLS和非CLR异常
所有面向CLR的编程语言都支持从Exception派生的对象。因为公共语言规范(CLS)对此作了硬性的规定。但CLR实际允许抛出任何类型的一个实例,而且有些编程语言允许代码抛出非CLS相容的异常对象。
在CLR2.0版本以前,catch块只能捕捉派生自Exception的异常类型,C#代码不能捕获非CLR相容的异常,从而造成一些安全隐患。在CLR2.0中,Microsoft引入一个新的RuntimeWrappedException类(在System.Runtime.CompilerServices命名空间),该类派生自Exception,所有它是一个CLR相容的类型。RuntimeWrappedException包含了一个object类型的WrappedException属性。当一个非CLR相容的一个异常抛出时,CLR会自动构造RuntimeWrappedException的一个实例。从而在代码中,任何捕捉Exception类型的代码,都能捕捉非CLS相容的异常,消除了安全隐患。
private void SomeMethod() { try { //需要执行的代码 } catch (Exception) { //C#2.0以前,这个块只能捕捉CLS相容的异常 //而现在,这个块能捕捉CLR相容和不相容的异常 throw; //重新抛出捕捉到的任何东西 } catch { //在所有版本的C#中,这个块能捕捉CLS相容和不相容的异常 throw;// 重新抛出捕捉到的任何东西 } }
在CLR2.0以上版本中编译上面的代码,第二个catch块会给出一个警告,告诉你第一个catch块已捕获了所有异常,非CLS相容的异常都包装在了RuntimeWrappedException中。
开发人员有2个办法迁移CLR2.0之前的代码。首先,可以将两个catch块合并到一个catch块中,并删除一个catch块,这是推荐的做法。其次,可以在Assembly层次应用RuntimeCompatibilityAttribute特性。它告诉CLR不要对非CLS异常进行RuntimeWrappedException包装。
using System.Runtime.CompilerServices; [assembly: RuntimeCompatibility(WrapNonExceptionThrows = false)]
三:System.Exception类
CLR允许异常抛出任何类型的实例——包括Int32到String都可以。但是,微软决定不强迫所有编程语言都抛出和捕捉任意类型的异常。因此,他们定义了一个System.Exception类型,并规定所有CLS相容的编程语言都必须能抛出和捕捉派生自该类型的异常。C#只允许抛出CLS相容的异常。当应用程序抛出一个未处理异常时,可以通过Windows的事件查看器查阅。
最常用的Exception的属性是Message,StackTrace和InnerException;分别表示异常的文字消息,异常的方法堆栈信息,以及内部异常。
注意:抛出一个异常时,CLR会重置异常的起点;也就是说CLR只记录最新的异常对象抛出的位置。
下面的代码抛出和它捕捉到的一样的异常对象,导致CLR重置该异常的起点:
try { //抛出一个异常 throw new Exception("abc"); } catch (Exception e) { throw e;//CLR认为这是异常的起点 }
相反,如果仅仅使用throw关键字本身来重新抛出一个异常时,CLR就不会重置堆栈的起点:
try { //抛出一个异常 throw new Exception("abc"); } catch (Exception e) { throw ;//不影响CLR对异常的认知 }
上面这两段代码得到的结果居然是一样的堆栈信息,是不是让人感觉一头雾水?原因是这样的,调用栈是线程返回位置的一个记录,而不是源信息的一个记录。线程进入这个函数后,最后执行的是throw语句,所有栈信息里的位置就是throw语句的位置。可以将throw new Exception("abc")替换成一个函数,在函数的内部抛出异常,你就会发现throw保留了调用函数的堆栈信息。但throw ex却不会保留堆栈的信息。如下面的代码:
static void Main(string[] args) { try { ThrowException1(); // line 19 } catch (Exception x) { Console.WriteLine("Exception 1:"); Console.WriteLine(x.StackTrace); } try { ThrowException2(); // line 25 } catch (Exception x) { Console.WriteLine("Exception 2:"); Console.WriteLine(x.StackTrace); } } private static void ThrowException1() { try { DivByZero(); // line 34 } catch { throw; // line 36 } } private static void ThrowException2() { try { DivByZero(); // line 41 } catch (Exception ex) { throw ex; // line 43 } } private static void DivByZero() { int x = 0; int y = 1 / x; // line 49 }
输出结果:
Exception 1: at UnitTester.Program.DivByZero() in <snip>\Dev\UnitTester\Program.cs:line 49 at UnitTester.Program.ThrowException1() in <snip>\Dev\UnitTester\Program.cs:line 36 at UnitTester.Program.TestExceptions() in <snip>\Dev\UnitTester\Program.cs:line 19 Exception 2: at UnitTester.Program.ThrowException2() in <snip>\Dev\UnitTester\Program.cs:line 43 at UnitTester.Program.TestExceptions() in <snip>\Dev\UnitTester\Program.cs:line 25
使用System.Diagnostics.StackTrace类型,允许开发人员程序化的处理堆栈跟踪以及构建组成堆栈跟踪的栈帧。下面是一个简单示例:
StackTrace st = new StackTrace(true); StackFrame[] frames = st.GetFrames(); foreach (var frame in frames) { Console.WriteLine("Class ={3},Method={0},File={1} Line:{2}", frame.GetMethod().Name, frame.GetFileName(), frame.GetFileLineNumber(), frame.GetMethod().DeclaringType.FullName); }
如果CLR够找到程序集的调试符号(存储在pdb文件中),可以利用StackTrace的ToString方法输出文件名和代码的行号,如果没有pdb文件,是不行的。所以,不要企图应用程序部署后利用这些信息来定位代码的错误,因为部署后的程序集通常不含pdb文件。
获得一个堆栈的跟踪之后,可能发现在实际调用栈中有的一些方法没有出现在堆栈跟踪字符串中。这可能是两方面的原因造成的。首先,调用栈其实是线程返回位置(而非来源位置)的一个记录。其次,JIT编译器可能内联了一些方法,从而避免了调用一个独立的方法并从中返回的开销。许多编译器(包括C#编译器)都提供一个/Debug开关。使用这个开关编译器会在生成的程序集中嵌入信息,告诉JIT编译器不要内联程序集的方法,确保调试人员得到一个完整的,更有意义的堆栈信息
注意:对程序集应用System.Diagnostics.DebuggableAttribute特性,并指定了DisableOptimizations标志,JIT编译器不会对程序集的方法进行内联处理。
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.DisableOptimizations)]
也可对方法应用MethodImplAttribute特性,这将阻止JIT编译器在调试和发布时对该方法进行内联处理。如:
[MethodImpl(MethodImplOptions.NoInlining)] private void SomeMethod() { }
四:抛出异常
实现自己的方法时,如果方法无法完成任务,就应抛出一个异常。抛出异常时,要考虑两个问题。
●第一个问题是抛出什么样的Exception派生类型。应选择有意义的类型。要考虑调用栈中位于高处的代码,要知道那些代码如何判断一个方法失败,以便开始执行一些得体的恢复代码。可以直接利用FCL中定义好的一个类型,但FCL中也许找不到一个和你想表达的意思完全匹配的类型。所以,可以考虑定义自己的类型,只要它从System.Exception派生就行。如果希望定义一个异常类型的层次结构,强烈建议这个层次结构浅而宽,以创建尽量少的基类。原因是基类的主要作用就是将大量错误当作一个错误,而这通常是危险的。基于同样的考虑,永远不要抛出一个System.Exception对象,抛出其他任何基类异常类型是要特别谨慎。
●第二个问题是向异常类型的构造器传递什么字符串消息。抛出异常时,应包含一条字符串信息,详细说明方法为什么不能完成任务。如果异常被捕捉并进行了处理,用户就看不到该信息。但是,如果异常成为一个为处理的异常,消息常会被写入日志。未处理的异常意味着程序有一个真正的bug,开发人员必须修复该bug,因为用户没有源代码或能力去修复bug并重新编译程序。事实上,这个字符串根本不应该向最终用户显示,所以,字符串消息可以包含非常详细的技术细节,以帮助开发人员修正代码。
五:自定义异常类
一般化的指导原则是,创建用户自定义的异常时,应派生自System.ApplicationException;而CLR抛出的异常都派生自System.SystemException。这两个类都派生自System.Exception。但是这种隐性的规则,CLR内部并没有严格遵守,比如TargetInvocationException派生自System.ApplicationException,IsolatedStroageException派生自System.Exception,造成了相当的混乱。就现在的实际使用和MSDN文档都建议直接从System.Exception派生自定义的异常。
下面是创建一个自定义异常类型的几个原则:
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); }
六:用可靠性换取开发效率
面向对象编程,编译器功能,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方法。然后它会将消息发送个Windows Application的日志。
七:指导原则和最佳实践
7.1 善用finally块
我们认为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()); } }
7.2 不要什么都捕捉
使用异常时,新手一个常规的错误是过于频繁或者不恰当的使用catch块。捕捉一个异常表明你预见到该异常,理解它为什么发生,并知道如何处理它。换句话说,是在为应用程序定义一个策略。
但是我们经常会看到如下的代码。
try { //尝试执行程序员知道可能出错的代码 } catch(Exception ex) { … }
这段代码指出它预见了所有的异常类型,并知道如何从所有的状况中恢复。这不是在吹牛吗?如果一个类型是类库的一部分,那么在任何情况下,它都决不能捕捉并“吞噬”所有异常,因为它不可能预知应用程序具体如何响应一个异常。除此之外,通过委托,虚方法和接口方法,类型会经常调用应用程序代码。如果应用程序抛出一个异常,应用程序的另一部分则可能预期要捕捉这个异常。所以,你绝不能写一个“大小通吃”的类型,悄悄的“吞噬”这个异常,而是应该允许异常在调用栈中向上移动,让应用程序代码有针对性的处理这个异常。
如果异常未得到处理,CLR会终止进程。大多数未处理异常都能在代码测试期间发现。为了修正这些未处理的异常,一个办法是修改代码来捕捉一个特定的异常,要么重写代码排除会造成抛出异常的错误条件。在生产环境中运行的最终版本应该极少出现未处理异常,而且应该相当健壮。
顺便说一句,在一个catch快中,确实可以捕捉System.Exception异常并执行一些代码,只要在catch块的末尾重新抛出异常。千万不要捕捉System.Exception异常并悄悄“吞噬”它,否则应用程序不知道已经出错,还是会继续执行,造成不可预测的结果和潜在的风险。
某些时候,异常的发生造成了对象状态的破坏并且无法恢复。在继续执行程序会造成不可预测的结果和潜在的风险,这是应该调用Environment的FailFast方法来终止进程。
7.3 得体的从异常中恢复
有时候,在调用一个方法时,已经预料到他可能抛出某些异常。由于预料到这些异常,所以可以写一些代码,允许应用程序从异常中特体的恢复并继续执行。下面是一个伪代码:
public string CalculateSpreadsheetCell(int row, int column) { string result = ""; try { result =//计算电子表格单元格的值 } catch (DivideByZeroException){//捕捉0除错误 result = "Can‘t show value: Divide by zero"; } catch (OverflowException){//捕捉溢出错误 result = "Can‘t show value: Too Big"; } return result; }
上述的伪代码计算电子表格单元格的内容,将代表值的字符串返回给调用者。本例预测了DivideByZeroException和OverflowException两个异常。除非在catch块末尾重写抛出捕捉到的异常,否则不要捕捉System.Exception异常,因为不可能搞清楚try块中全部抛出的异常。例如:try块还可能抛出OutOfMemoryException和StackOverflowException,而这只是所有可能的异常中最普通的两个。
7.4 从不可恢复的异常中回滚——维持状态
通常,方法要调用其他几个方法来执行一个抽象操作,这些方法可能成功,也可能失败。例如,假定要对一组对象序列化到一个磁盘文件。序列化好第10个对象后,抛出一个异常(可能是磁盘已满,或是序列化的对象没有应用Serializable特性)。在这种情况下,应该将这个异常“漏”给调用者处理,但磁盘文件的状态怎么办呢?文件包含了一个部分序列化的对象图,所以它已经损坏。理想情况下,应用程序应该回滚部分完成的操作,将文件恢复到序列化之前的状态。
public void SerializeObjectGraph(FileStream fs, IFormatter formatter, object rootObj) { //保存文件的当前位置 Int64 beforeSerialization = fs.Position; try {//尝试将对象图序列化到文件 formatter.Serialize(fs, rootObj); } catch {//捕捉所有异常 fs.Position = beforeSerialization; //任何出错,就将文件恢复到一个有效的状态 fs.SetLength(fs.Position); //截断文件 throw; //重写抛出相同的异常,让调用者知道发生了什么 } }
上面的catch块没有指定任何异常类型,因为我们不关心发生了什么错误,只关心如何将数据恢复为一致的状态,在catch块的末尾用throw将同一个异常再次抛出,从而告知调用者。
7.5 隐藏实现细节来维持契约
有时可能需要捕捉一个异常并重新抛出一个不同的异常。这样做的唯一理由就是维持方法的契约(contract)。另外,新抛出的异常应该是一个具体的异常(不能是其他异常类的基类)。下面是一个伪代码类:
public sealed class PhoneBook { private string m_pathname;//地址簿文件的路径 //其他方法… public string GetPhoneName(string name) { string phone = null; FileStream fs = null; try { fs = new FileStream(m_pathname, FileMode.Open); //这里的代码从fs读取数据,直到找到匹配的name phone = //已找到的电话号码 } catch (FileNotFoundException e) { //抛出一个不同的异常,将name包在其中 //将原来的异常作为内部异常 throw new NameNotFoundException(name, e); } catch (IOException e) { //抛出一个不同的异常,将name包在其中 //将原来的异常作为内部异常 throw new NameNotFoundException(name, e); } finally { if (fs != null) fs.Close(); } return phone; } }
地址簿的数据是从一个文件(而不是网络或数据库),但具体的调用者却不知道这一点,因为这是未来可能改变的一个实现细节。所以,文件由于任何原因没找到或者不能读取,会看到一个FileNotFoundException或者IOException异常,但这两个异常都不是(调用者)预期的,因为“文件存在与否”以及“能否读取”不是方法隐式契约的一部分,方法的调用者根本猜不到。所以GetPhoneName捕捉了这两个异常,并抛出了一个新的NameNotFoundException异常。注意,一定要将真实的异常设定为内部异常后传出去,不然调用者无法知道出错的真正原因。
假设PhoneBook类现在增加了一个公共属性PhoneBookPathName,用户通过它设置或读取存储电话号码文件的路径。由于用户知道电话数据来自于一个文件,所以应修改为在GetPhoneName方法中不捕捉任何异常。让抛出的异常沿着方法的调用栈向上传递(而不是“吞噬”后抛出一个新的异常)。
有的时候,开发人员捕捉一个异常后抛出一个新的异常,目的是在异常中添加额外的数据。如果这是你唯一的目的,那么只需要捕捉你希望的异常类型,在类型对象的Data属性(字典类型)中添加数据,然后然后重写抛出相同的异常对象:
public static void SomeMethod(string filePath) { try { //这里随便做点什么 } catch (IOException e) { //将文件名添加到IOException中 e.Data.Add("FilePath", filePath); throw;//重新抛出异常,它包含了新添加的数据 } }
下面是一个很好的应用:如果一个类型的构造器抛出异常,而且这个异常未在类型构造器方法中捕捉,CLR会捕捉这个异常,并改为抛出一个新的TypeInitializationException。比如,在类型构造器中抛出了一个DivideByZeroException异常,你的代码可能尝试捕捉并从中恢复。但是,你甚至不知道在调用类型构造器,所有CLR将DivideByZeroException转换成了TypeInitializationException,使你清楚的知道异常是因为类型构造器失败而发生的,问题并不出在你的代码。
相反,下面是一个不好的应用:通过反射调用一个方法时,CLR内部捕捉所有异常,并把它转化成一个TargetInvocationException。这是一个讨厌的设计,现在必须捕捉TargetInvocationException,并查看它的InnerException才能知道错误的真正原因。事实上,在使用反射时,经常看到这样的代码:
public void Reflection(object o) { try { var mi = o.GetType().GetMethod("DoSomething"); mi.Invoke(o, null);//DoSomething可能抛出异常 } catch(TargetInvocationException e) { //重写抛出最初抛出的异常 throw e.InnerException; } }
但是,一个好消息是,如果使用C#的dynamic基元类型来调用一个成员,编译器就不会捕捉任何异常,最初抛出的异常对象会正常在调用栈中向上传递。对于大多数开发人员,这是使用C# dynamic基元类型来代替反射的一个很好的理由。
八:未处理异常
异常抛出时,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事件。
九:约束执行区(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."); } //应用p了1System.Runtime.ConstrainedExecution命?名?空o间的IReliabilityContract特A性?á [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便无法对状态的一致性做出任何保证。请确保finally块中只有刚刚描述的应用了ReliabilityContract特性的方法。向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类。
十:代码契约
代码契约(code contract)提供了直接在代码中申明代码设计决策的一种方式。
●前条件 一般用于参数的验证。
●后条件 方法因为一次普通的返回或者因为抛出一个异常而终止时,对状态进行验证。
●对象不变性(object Invariant) 用于对象的整个生命期内,保持对象字段的良好性状态。
代码契约有利于代码的使用、理解、进化、测试、文档和初期错误检查。可将前条件、后条件和对象不变性想象为方法签名的一部分。所以,代码新版本的契约可以变得更宽松,但是,除非破坏向后兼容性,否则代码新版本的契约不能变得更严格。
代码契约的核心是静态类System.Diagnostics.Contracts.Contract。由于该技术较新,实际中运用机会不多,故不再投入大量精力去研究。具体用时可以查阅MSDN相关文档。