有些刚开始做.NET开发的程序员把异常想得很简单,认为异常无非就是try..catch...finally了,将可能发生异常的代码放置在try里面,如果发生异常用catch(Exception ex)可以一网打尽,然后finally可以做些请求的资源的清理工作。事实上,刚做开发某段时间里,我写代码的时候从来没有认真考虑过怎么去处理和利用异常提供的信息,如果在程序运行的时候抛出异常,我也只是简单地处理Exception对象的Message,然后根据Message的信息去判断BUG可能在什么地方,现在想来这无疑很低效,通过这种方式去查找隐藏比较深的BUG是不得法的。这篇文章试图穿越异常try...catch的表面,深入到.NET中异常处理的机理,以帮助您对异常有一个更好的认识,在自己的程序中能够更好地定义异常处理异常利用异常。
什么是异常
通常异常(Exception)是指超出了你设计的程序能够正常处理范围的情况,如你定义方法给他人调用,通常会为方法做一个说明,以告诉别人你的方法能够完成什么功能,但别人调用这个方法却无法完成预定的功能时,这时候我们说方法有异常。
为什么要有异常
我们实现自己的类时,通常会为自己的类提供一些接口(如方法,事件,属性等)以供调用者使用,如果调用者调用接口无法完成接口预定功能时,我们就必须通知调用者接口调用出错,这时候我们通常有两种方式来通知调用者。第一种是返回错误码的方式,如接口预期完成实现返回0,没有预期完成实现返回1;第二种可以使用.NET Framework提供的异常处理机制,在我们的接口没有完成预期功能的时候抛出一个派生自System.Exception类的实例,调用者可以通过捕捉这个实例进行错误处理。
必须了解的System.Exception类
CLS(公共语言规范)硬性规定所有面向CLR的编程语言都必须支持抛出从System.Exception派生的对象,C#编译器只允许代码派出从Exception派生的对象,所以我们用C#编写程序,毫无疑问,我们自定义的异常类必须派生自 System.Exception,否则你无法通过throw将它抛出来。所以我们在使用自定义异常之前,必须对System.Exception类有更多的了解。
System.Exception的公共方法中,除了GetBaseException(用以遍历所以内部异常组成的链表,返回最开始那个异常),其它都没有特别的,我们更多利用的是它提供个属性。
属性 |
访问权限 |
类型 |
描述 |
Message |
只读 |
String |
包含一段辅助性的文本,描述异常发生的原因。在出现未处理异常时,这些信息通常会写入log。这些信息用户通常是看不见的,所以应尽量使用技术性的词汇以帮助其它开发人员修正代码。 |
Data |
只读 |
IDictionary |
一个指向key-value对集合的引用。通常应在抛出异常前,向该集合添加信息,而捕获异常的代码则使用这些信息进行异常恢复操作。 |
Source |
读写 |
String |
产生异常的程序集的名称 |
StackTrace |
只读 |
String |
包含了调用堆栈中抛出异常的方法的名称和签名。该属性对于调试极具价值。 |
TargetSite |
只读 |
MethodBase |
抛出异常的方法。 |
HelpLink |
读写 |
String |
获取或设置异常的关联帮助文件的链接。 |
InnerExceptoin |
只读 |
Exception |
如果当前异常是在处理另一个异常时产生的,那么该属性表示前一个属性。该属性通常为null。Exception类型还提供了一个公有方法GetBaseException,用以遍历所以内部异常组成的链表,返回最开始那个异常。 |
我们主要通过Exception的属性获取代码出错信息的描述和出错的具体位置,以方便我们快速定位BUG和进行修复。所以我平时用到最多有两个属性,一个是Message,另外一个是StackTrace。这两个属性类型都是string,通常我们将Message写入Log日志文件,TargetSite获取的是抛出异常的方法名称,而异常发生时访问StackTrace属性可以获取一个堆栈跟踪,这个堆栈跟踪描述了异常发生前调用的方法,这些信息对于我们检查异常原因和修复代码都是非常有帮助的。
异常的处理机制
下面的代码展示了异常处理机制的标准用法
try {
// 此次编写可能发生异常的代码
}
catch (InvalidOperationException) {
// 从异常InvalidOperationException恢复的代码...
}
catch (IOException) {
// 从IOException恢复的代码...
}
catch (Exception) {
// C# 2.0之前, 这里的catch仅能捕捉到和CLS相容的异常
// 在C# 2.0版本中以及之后, 这里的catch能捕捉到和CLS兼容和非兼容的异常 因为不兼容CLS的异常会被包装成一个RuntimeWrappedException类型的对象抛出
throw; 重新抛出异常
}
catch {
// 所有C#版本中, 这里的catch能捕捉到和CLS兼容和不兼容的异常
throw; // 重新抛出异常
}
finally {
// 对try的任何操作进行清理
}
// 如果try没有抛出异常或者某个catch块捕捉到异常没有抛出或重新抛出,都会执行下finally下面的代码
}
try块包含可能会抛出异常的代码,catch块一般是对异常进行恢复和错误的记录,如对数据库进行批量操作时,如果发生异常,可以在catch块里面执行回滚的代码,finally块是对try的任何操作进行清理,只要异常被catch住了,finally块代码就一定会被执行。一个try块至少有一个关联的catch块或finally块。如果try块中代码没有任何异常,线程会跳过与之关联的所有的catch块,如果有finally就直接执行finally块代码,完毕后从finally后面代码继续执行。前面说过C#里面所有自定制的异常都必须派生自Exception类,所以这里catch的捕捉类型就必须是
System.Exception或者是它的派生类型。因为一个try块可以对应多个catch块,而CLR是自上而下搜索一个匹配的catch块的,所以我们应该将派生程度最大也就是最具体的异常类型放在最前面的catch的捕捉类型里。而CLR一旦找到匹配的catch块,后面的catch块就不会搜索了。如果该try块相关联的catch中没有一个能够接受该异常,CLR将沿着调用堆栈向更高层搜索能够接受该异常的catch块,如果直到堆栈顶部依然没有找到能够处理该异常的catch块,就会发生一个未处理的异常,CLR就会终止进程。
当CLR找到一个具有匹配捕捉异常类型的catch时,它会先执行从抛出异常的try块开始,到匹配异常的catch块为止的范围内所有的finally块,然后才执行这个catch块里面的代码,完后执行者catch对应finally的代码。理解这一句很重要,下面我们通过一段代码来说明这个执行的过程:
{
class Program
{
static void Main(string[] args)
{
try
{
double result = Calculation.DividedBy(0, 0);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
finally
{
Console.WriteLine("外层finally");
}
Console.ReadKey();
}
}
/// <summary>
/// 计算类
/// </summary>
public static class Calculation
{
/// <summary>
/// 除法
/// </summary>
public static double DividedBy(int a, int b)
{
double result = 0.0;
try
{
result = a / b;
}
catch (System.NotFiniteNumberException ex)
{
throw;
}
finally
{
Console.WriteLine("内层finally");
}
return result;
}
}
}
这段代码执行的结果如下:
内层finally
尝试除以零。
外层finally
具体的执行过程是这样,当程序主函数Main执行Calculation.DividedBy(0, 0)方法时,在DividedBy方法内部执行语句result = a / b时会抛出一个 System.DivideByZeroException类型的异常,这个时候在DividedBy方法的没有找到和DivideByZeroException匹配的捕捉类型,CLR会去调用栈的更高一层搜索与异常匹配的捕捉类型,这样CLR就会搜索到Main函数里面的catch(Exception ex),找到这个具有匹配的捕捉类型的catch后,线程就会开始执行DividedBy方法内部的那个finally块,然后执行Main函数那个匹配的catch块内容,再然后执行Main函数里面那个finally块。
在catch的圆括号中我们除了可以指定异常的捕捉类型外,还可以指定一个异常变量,如ex,当该catch块匹配异常时,会把该异常对象的指引地址赋给这个变量。在catch块的末尾,我们有三种选择:
1)重新抛出相同的异常,用throw或者throw ex(ex为catch圆括号指定的变量名);
2)抛出一个不同的异常,这个异常可以是自定义的也可以是FCL中定义的;
3)让线程从catch块底部退出,继续执行后面的代码。
上面谈到throw和throw ex,这两种方式都是重新抛出相同异常,但有什么区别呢?
一个异常抛出的时候,CLR会记录throw指令的位置(抛出位置)。一个catch块捕捉到该异常时,CLR又会记录异常的捕捉位置,在catch块内访问抛出的异常对象的StackTrace属性可以查看异常抛出位置到异常捕捉位置之间的所有方法。它们的区别就是CLR对于异常抛出起始位置的认知,当你用throw ex的时候会让CLR认为这里是异常抛出位置的起点,而throw则不会改变CLR对异常抛出起始点的认知。拿上面DividedBy方法来说,如用throw ex则会让CLR认为这里是异常抛出的起点,如果用throw,CLR会认为result=a/b才是异常抛出的起点。不过比较奇怪的是,不管你是throw还是throw ex ,在调用栈高一层捕捉到这个异常时查看StackTrace的信息都是一样,记录的位置都是thow或throw ex的位置。
定义自己的异常类
很多观点认为在自己的方法抛出异常时,永远都不要抛出一个System.Exception对象!比较好的方式的先从FCL里面找一个合适的异常类,而不是第一时间就想抛出自己定义的异常。FCL定义了大量的异常类,如System.OutOfMemoryException可以让你在发生内存溢出的情况下抛出,System.TimeOutException可以让你面对操作超时的问题时抛出。但FCL定义的异常显然不可能满足你所有的要求,这时候可考虑自定义的异常。自定义的异常必须从System.Exception或者其派生类作为自定义异常类派生,为了实现异常对象在跨越应用程序域时得到封送处理(marshaled),同时还能持久化至日志或数据库,这个异常类必须是可序列化的,所以定义这个类型的时候需要应用特性Serializable(关于特性的使用可阅读《你必须懂的.NET中Attribute》),如新增字段的话还有实现ISerializable接口中的用于序列化的GetObjectData方法。
下面我们来自定义一个异常类,这个异常类新增一个属性IsLog,用以表识是否将异常写入日志,如IsLog设置为true,则写入日志。因为自定义特性比较繁琐,所以VS里面有定义好的代码段可以供我们使用,我们在代码编写区键入ex的时候VS会提示我们exception的代码段可以选择,我们选择exception,然后单击“Tab”键,VS将自动为我们生成我们自定义异常类的部分关键代码。我们定义好这个异常后将之用在前面代码的DividedBy方法中,当代码判断分母为0的时候,就构建这个异常对象抛出,更高一层的代码捕捉到这个异常,判断异常IsLog属性为true时,将异常信息写入日志,代码如下:
{
class Program
{
static void Main(string[] args)
{
try
{
double result = Calculation.DividedBy(0, 0);
Console.WriteLine(result);
}
catch (MyException ex)
{
if (ex.IsLog)
{
Console.WriteLine("将异常信息:{0} 写入日志文件",ex.Message);
}
}
Console.ReadKey();
}
}
/// <summary>
/// 计算类
/// </summary>
public static class Calculation
{
/// <summary>
/// 除法
/// </summary>
public static double DividedBy(int a, int b)
{
double result = 0.0;
if (b == 0)
{
MyException ex = new MyException("除数不能为零",true);
throw ex;
}
result = a / b;
return result;
}
}
[Serializable]
public class MyException : Exception,ISerializable
{
/// <summary>
/// 新增字段,用以判断是否将异常写入日志文件
/// </summary>
private bool _isLog=false ;
public bool IsLog
{
get { return _isLog; }
}
public MyException():base() { }
//为新增字段实现构造函数
public MyException(string message, bool isLog)
: base(message)
{
_isLog = isLog;
}
//为新增字段实现构造函数
public MyException(string message, bool isLog, Exception inner) : base(message, inner) { _isLog = isLog; }
//用于序列化的构造函数,以支持跨应用程序域或远程边界的封送处理
protected MyException(
System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context)
: base(info, context) { }
//重写基类GetObjectData()方法,实现向SerializationInfo中添加自定义字段信息
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
// 序列化自定义数据成员
info.AddValue("IsLog",IsLog);
base.GetObjectData(info,context);
}
}
}
输出结果如下:
将异常信息:除数不能为零 写入日志文件
参考资料
Jeffrey Richter:CLR via C#
金旭亮:深入了解CLR异常处理机制
一个程序员的反省:理解.NET中的异常