《CLR via C#》 异常处理
定义异常
异常是指成员没有完成它的名称所宣称的行动(方法执行中出错中断)。
异常处理机制
C#支持用try
块、catch
块和finally
块来处理异常的各个部分。
- 负责会抛出异常代码的监听放到一个
try
块。 - 负责异常恢复的代码应放到一个或多个
catch
块。 - 负责清理的代码应放到一个
try
块。
try块
一个try
块至少要有一个关联的catch
块或finally
块。
提示
如果一个try
块中执行多个可能抛出同一个异常类型的操作,但不同的操作有不同的异常恢复措施,就应该将每个操作都放到它自己的try
块中,才能正确的恢复状态。
catch块
一个try
块可以关联0个或多个catch
块。catch
关键字后的圆括号中的表达式称为捕捉类型
。C#要求捕捉类型必须是System.Exception
或者它的派生类。没有圆括号则默认捕捉所有异常即System.Exception
。
CLR自上而下搜索匹配的catch
块,所以应该将较具体的异常放在顶部。也就是说,派生程度最大的异常放在最上边,接着是它的基类型。最后是System.Exception
。
一旦CLR找到匹配的catch
块,就会执行内层所有finally
块中的代码。所谓“内层finally
块”是指从抛出异常的try
块开始,到匹配异常的catch
块之间的所有finally
块。(不是finally
块在try
块和catch
块之间,而是调用函数栈中的finally
块)。
1static void Func()
2{
3 try {
4 throw new FieldAccessException();
5 } finally {
6 Console.WriteLine("Func finally");
7 }
8}
9
10// 执行Main函数时,调用Func函数,Func函数中的try块会触发异常而
11// 捕捉的catch块在Main函数中,所以Func函数中的finally块被执行。
12// 所有内层`finally`块执行完毕后,匹配异常的`catch`块才开始执行。
13// 输出结果为:
14// Func finally
15// catch FieldAccessException
16// Main finally
17static void Main(string[] args)
18{
19 try {
20 Func();
21 } catch(FieldAccessException e) {
22 Console.WriteLine("catch FieldAccessException");
23 } finally {
24 Console.WriteLine("Main finally");
25 }
26}
在catch
块的末尾,有以下三种决策方式:
- 重新抛出相同的异常,向调用栈高一层的代码通知该异常的发生。
- 抛出一个不同的异常,向调用栈高一层的代码提供更丰富的异常信息。
- 让线程从
catch
块底部退出(不是终止线程,而是“贯穿”catch
块的底部,让其接着执行finally
块)。
catch
块的代码可通过异常变量对象来访问异常的具体信息(如堆栈信息)。
finally块
一般在finally
块中执行try
块的行动所要求的资源清理操作。
例如,打开文件后,就应该把关闭文件的代码放到finally
块中保证其执行。避免打开过程中遇到异常从而造成文件保持打开状态(直到下一次垃圾回收)。
catch
和finally
块中的代码应该非常短,而且要有非常高的成功率,避免自己又抛出异常。
如果catch
或finally
块内部抛出了异常,CLR仍会正常运转,好像异常是在finally
块之后抛出的一样。但是,出异常的所有信息都会丢失。这个新异常可能不会由你的代码处理,最终变成一个未处理的异常。在这种情况下,CLR会终止进程。
System.Exception
类
属性名称 | 访问 | 类型 | 说明 |
---|---|---|---|
Message | 只读 | String | 辅助性文字说明,指出抛出异常的原因。 |
Data | 只读 | IDictionary | 引用一个“键/值对”集合。抛出异常前在该集合中添加数据,对其异常恢复过程中利用 |
Source | 读/写 | String | 包含生成异常的程序集名称 |
StackTrace | 只读 | String | 包含抛出异常前调用过的所有方法的名称和签名 |
TargetSite | 只读 | MethodBase | 包含抛出异常的方法 |
HelpLink | 只读 | String | 帮助文档URL |
InnerException | 只读 | Exception | 当前异常时处理一个异常时抛出的,该属性指向上一个异常 |
HResult | 读/写 | Int32 | 跨越托管和本机代码边界时使用的一个32位值。 |
StackTrace
一个异常抛出时,CLR内部记录throw
指令的位置(抛出位置)。一个catch
块捕捉到异常时,CLR记录捕捉位置。在catch
块内访问被抛出的异常对象的StackTrace
属性,负责实现该属性的代码会调用CLR内部的代码,后者创建一个字符串来指出从异常抛出位置到异常捕捉位置所有的方法。
提示
抛出异常时,CLR会重制异常起点;CLR只记录最新的异常对象的抛出位置。但如果调用throw
关键字本身来重新抛出异常,CLR就不会重制堆栈起点。
1try { ... }
2catch (Exception e) {
3 // ...
4 throw e; // CLR认为是异常起点,会重制重新记录异常。
5}
6
7try { ... }
8catch (Exception e) {
9 // ...
10 throw; // 不影响CLR对异常起点的认知。
11}
要获得从线程起始出到异常catch
块之间的完整堆栈跟踪,需要使用System.Diagnostics.StackTrace
类型。
获得堆栈跟踪后,可能发现调用栈中的一些方法没有出现在堆栈跟踪字符中。可能有两方面原因:
- 调用栈记录的是线程的返回位置(而非来源位置)。
- JIT编译器可能做了优化,将一些方法内联(inline)。
提示
编译器开启/debug开关会在生成的程序集中嵌入System.Diagnostics.Debuggabletrribute
定制特性,如果该特性指定了DisableOptimizations
标志,JIT编译器就不会对程序集的方法进行内联。
设计规范和最佳实践
建议定义浅而宽的异常类型层次结构(继承深度少,一个父类可有很多子类),以创建尽量少的基类。原因是基类的主要作用就是将大量错误当作一个错误,而这是危险的。基于同样的思考,永远不要抛出一个System.Exception
对象。
自己设计异常不仅繁琐,还容易出错。主要原因是从Exception
派生的所有类型都应该是可序列化的(serializable),使它们能穿越AppDomain边界或者写入日志/数据库。
善用finally块
应该先用finally
块清理那些已成功启动的操作,再返回至调用者或者执行finally
块之后的代码。另外,还经常利用finally
块显式释放对象以避免资源泄露。
编辑器在使用一些关键字的时候会自动加入try/finally块。
- 使用
lock
语句时,锁在finally
块中释放。 - 使用
using
语句时,在finally
块中调用对象的Dispose
方法。 - 使用
foreach
语句时,在finally
块中调用IEnumerator
对象的Dispose
方法。 - 定义析构器方法时,在
finally
块中调用基类的Finalize
方法。
不要什么都捕捉
捕捉异常表明你预见到该异常,理级它为什么发生,并知道如何处理它。换句话说,就是为应用程序定义一个策略。任何情况下都不允许捕捉并隐藏所有异常,因为不可能准确预知所有异常。
发生不可恢复的异常时回滚部分完成的操作————维持状态
为了正确回滚已部分完成的操作,代码应捕捉所有异常。因为你不关心发生了什么错误,只关心如何将数据恢复为一致状态。捕捉并处理好异常后,不要把它隐藏。而要让调用者知道发生了异常。
隐藏实现细节来维系协定
有时需要捕捉一个异常并重新抛出不同的异常。这样做的唯一原因时维系方法的“协定”。另外,抛出的新异常类型应该是一个具体异常。有时,开发人员之所以捕捉一个异常并抛出一个新异常,目的是在异常中添加额外的数据或上下文。
提示
使用这个技术时,实际是在两个方面欺骗了调用者。首先,在实际发生的错误上欺骗了调用者。其次在错误发生的位置上欺骗了调用者。
未处理异常
当异常抛出时,没有找到任何catch
块匹配异常,就会发生一个未处理异常。CLR检测到进程中的任何线程有未处理的异常,都会终止进行。未处理异常编码应用程序遇到了未预料到的情况,并认为是应用程序真正的bug。
代码协定
代码协定(code contract)提供了直接在代码中声明代码设计决策的一种方式。这些协定采取以下形式。
- 前条件:一般用于对实参进行验证。
- 后条件:方法因为一次普通的返回或者抛出异常而终止时,对状态进行验证。
- 对象不变性(Object Invariant):在对象的整个声明周期内,确保对象的字段的良好状态。
1// 前条件方法:[Conditional("CONTRACTS_FULL")]
2public static void Requires(Boolean condition);
3public static void EndContractBlock();
4
5// 前条件:Always
6public static void Requires<TException>(Boolean condition) where TException : Exception;
7
8// 后条件方法:[Conditional("CONTRACTS_FULL")]
9public static void Ensures(Boolean condition);
10public static void EnsuresOnThrow<TException>(Boolean condition) where TException : Exception;
11
12// 特殊后条件方法:Always
13public static T Result<T>();
14public static T OldValue<T>(T value);
15public static T ValueAtReturn<T>(out T value);
16
17// 对象不变性方法:[Conditional("CONTRACTS_FULL")]
18public static void Invariant(Boolean condition);
19
20// 限定符(Quantifier)方法:Always
21public static Boolean Exists<T>(IEnumerable<T> collection, Predicate<T> predicate);
22public static Boolean Exists(Int32 fromInclusive, Int32 toExclusive, Predicate<Int32> predicate);
23
24// 辅助(Helper)方法:[Conditional("CONTRACTS_FULL")]或[Conditional("DEBUG")]
25public static void Assert(Boolean condition);
26public static void Assume(Boolean condition);
许多方法都应用了[Condition("CONTRACTS_FULL")]或[Condition("DEBUG")]
特性,意味着除非定义了恰当的符号,否则编译器会忽略调用这些方法的任何代码。标记"Always"的任何方法意味着编译器总是生成调用方法的代码。
运行时违反协定会引发Contract
的ContractFailed
事件。如果向这个事件登记了方法,会收到ContractFailedEventArgs
对象。
注意
前条件、后条件或不变性测试中引用的任何成员都一定不能有副作用(改变对象的状态)。这是必须的,因为测试条件不应改变对象本身的状态。