异常
本节将介绍以下内容:
— .NET异常机制
— .NET常见的异常类型
— 自定义异常
8.6.1 引言
内存耗尽、索引越界、访问已关闭资源、堆栈溢出、除零运算等一个个摆在你面前的时候,你想到的是什么呢?当然是,异常。
在系统容错和程序规范方面,异常机制是不可或缺的重要因素和手段。当挑战来临的时候,良好的系统设计必定有良好的异常处理机制来保证程序的健壮性和容错机制。然而对异常的理解往往存在或多或少的误解,例如:
l 异常就是程序错误,以错误代码返回错误信息就足够了。
l 在系统中异常越多越能保证容错性,尽可能多的使用try/catch块来处理程序执行。
l 使用.NET自定义Exception就能捕获所有的异常信息,不需要特定异常的处理块。
l 将异常类作为方法参数或者返回值。
l 在自定义异常中通过覆写ToString方法报告异常信息,对这种操作不能掉以轻心,因为某些安全敏感信息有泄漏的可能。
希望读者在从本节的脉络上了解异常的基本情况和通用规则,将更多的探索留于实践中的体察和品味。
8.6.2 为何而抛?
关于异常,最常见的误解可能莫过于对其可用性的理解。对于异常的处理,基本有两种方式来完成:一种是异常形式,一种是返回值形式。然而,不管是传统Win32 API下习惯的32位错误代码,还是COM编程中的HRESULT返回值,异常机制所具有的优势都不可替代,主要表现为:
l 很多时候,返回值方式具有固有的局限性,例如在构造函数中就无法有效的应用返回值来返回错误信息,只有异常才能提供全面的解决方案来应对。
l 提供更丰富的异常信息,便于交互和调试,而传统的错误代码不能有效提供更多的异常信息和调试指示,在程序理解和维护方面异常机制更具优势。
l 有效实现异常回滚,并且可以根据不同的异常,回滚不同的操作,有效实现了对系统稳定性与可靠性的控制。例如,下例实现了一个典型的事务回滚操作:
public void ExcuteSql(string conString, string cmdString)
{
SqlConnection con = new SqlConnection(conString);
try
{
con.Open();
SqlTransaction tran = con.BeginTransaction();
SqlCommand cmd = new SqlCommand(cmdString, con);
try
{
cmd.ExecuteNonQuery();
tran.Commit();
}
catch (SqlException ex)
{
Console.WriteLine(ex.Message);
//实现事务回滚
tran.Rollback();
throw new Exception("SQL Error!", ex);
}
}
catch(Exception e)
{
throw (e);
}
finally
{
con.Close();
}
}
l 很好地与面向对象语言集成,在.NET中异常机制已经很好地与高级语言集成在一起,以异常System.Exception类建立起的体系结构已经能够轻松应付各种异常信息,并且可以通过面向对象机制定义自己的特定异常处理类,实现更加特性化的异常信息。
l 错误处理更加局部化,错误代码更集中地放在一起,增强了代码的理解和维护,例如资源清理的工作完全交由finally子句来执行,不必花费过多的精力去留意其维护。
l 错误代码返回的信息内容有限而难于理解,一连串数字显然不及丰富的文字信息说明问题,同时也不利于快速地定位和修改需要调试的代码。
l 异常机制能有效应对未处理的异常信息,我们不可能轻易地忽略任何异常;而返回值方式不可能深入到异常可能发生的各个角落,不经意的遗漏就会造成系统的不稳定,况且这种维护方式显然会让系统开发人员精疲力竭。
l 异常机制提供了实现自定义异常的可能,有利于实现异常的扩展和特色定制。
综上所述,异常机制是处理系统异常信息的最好机制与选择,Jeffrey Richter在《Microsoft .NET框架程序设计》一书中给出了异常本质的最好定义,那就是:
异常是对程序接口隐含假设的一种违反。
然而关于异常的焦虑常常突出在其性能对系统造成的压力上,因为返回值方式的性能毋庸置疑更具“先天”的优势。那么异常的性能问题,我们又该如何理解呢?
本质上,CLR会为每个可执行文件创建一个异常信息表,在该表中每个方法都有一个关联的异常处理信息数组,数组的每一项描述一个受保护的代码块、相关联的异常筛选器(后文介绍)和异常处理程序等。在没有异常发生时,异常信息表在处理时间和内存上的损失几乎可以忽略,只有异常发生时这种损失才值得考虑。例如:
class TestException
{
//测试异常处理的性能
public int TestWithException(int a, int b)
{
try
{
return a / b;
}
catch
{
return -1;
}
}
//测试非异常处理的性能
public int TestNoExceptioin(int a, int b)
{
return a / b;
}
}
上述代码对应的IL更能说明其性能差别,首先是有异常处理的方法:
.method public hidebysig instance int32 TestWithException(int32 a,
int32 b) cil managed
{
// 代码大小 17 (0x11)
.maxstack 2
.locals init ([0] int32 CS$1$0000)
IL_0000: nop
.try
{
IL_0001: nop
IL_0002: ldarg.1
IL_0003: ldarg.2
IL_0004: div
IL_0005: stloc.0
IL_0006: leave.s IL_000e
} // end .try
catch [mscorlib]System.Object
{
IL_0008: pop
IL_0009: nop
IL_000a: ldc.i4.m1
IL_000b: stloc.0
IL_000c: leave.s IL_000e
} // end handler
IL_000e: nop
IL_000f: ldloc.0
IL_0010: ret
} // end of method TestException::TestWithException
代码大小为17个字节,在不发生异常的情况下,数据在IL_0006出栈后以leave.s指令退出try受保护区域,并继续执行IL_000e后面的操作:压栈并返回。
然后是不使用异常的情形:
.method public hidebysig instance int32 TestNoExceptioin(int32 a,
int32 b) cil managed
{
// 代码大小 9 (0x9)
.maxstack 2
.locals init ([0] int32 CS$1$0000)
IL_0000: nop
IL_0001: ldarg.1
IL_0002: ldarg.2
IL_0003: div
IL_0004: stloc.0
IL_0005: br.s IL_0007
IL_0007: ldloc.0
IL_0008: ret
} // end of method TestException::TestNoExceptioin
代码大小为9字节,没有特别处理跳出受保护区域的操作。
由此可见,两种方式在内存的消化上差别很小,只有8个字节。而实际运行的时间差别也微不足道,所以没有异常引发的情况下,异常处理的性能损失是很小的;然而,有异常发生的情况下,必须承认异常处理将占用大量的系统资源和执行时间,因此建议尽可能的以处理流程来规避异常处理。
8.6.3 从try/catch/finally说起:解析异常机制
理解.NET的异常处理机制,以try/catch/finally块的应用为起点,是最好的切入口,例如:
class BasicException
{
public static void Main()
{
int a = 1;
int b = b;
GetResultToText(a, 0);
}
public static void GetResultToText(int a, int b)
{
StreamWriter sw = null;
try
{
sw = File.AppendText(@"E:"temp.txt");
int c = a / b;
//将运算结果输出到文本
sw.WriteLine(c.ToString());
Console.WriteLine(c.ToString());
}
catch (DivideByZeroException)
{
//实现从DivideByZeroException恢复的代码
//并重新给出异常提示信息
throw new DivideByZeroException ("除数不能为零!");
}
catch (FileNotFoundException ex)
{
//实现从IOException恢复的代码
//并再次引发异常信息
throw(ex);
}
catch (Exception ex)
{
//实现从任何与CLS兼容的异常恢复的代码
//并重新抛出
throw;
}
catch
{
//实现任何异常恢复的代码,无论是否与CLS兼容
//并重新抛出
throw;
}
finally
{
sw.Flush();
sw.Close();
}
//未有异常抛出,或者catch捕获而未抛出异常,
//或catch块重新抛出别的异常,此处才被执行
Console.WriteLine("执行结束。");
}
}
1.try分析
try子句中通常包含可能导致异常的执行代码,而try块通常执行到引发异常或成功执行完成为止。它不能单独存在,否则将导致编译错误,必须和零到多个catch子句或者finally子句配合使用。其中,catch子句包含各种异常的响应代码,而finally子句则包含资源清理代码。
2.catch分析
catch子句包含了异常出现时的响应代码,其执行规则是:一个try子句可以关联零个或多个catch子句,CLR按照自上而下的顺序搜索catch块。catch子句包含的表达式,该表达式称为异常筛选器,用于识别try块引发的异常。如果筛选器识别该异常,则会执行该catch子句内的响应代码;如果筛选器不接受该异常,则CLR将沿着调用堆栈向更高一层搜索,直到找到识别的筛选器为止,如果找不到则将导致一个未处理异常。不管是否执行catch子句,CLR最终都会执行finally子句的资源清理代码。因此编译器要求将特定程度较高的异常放在前面(如DivideByZeroException类),而将特定程度不高的异常放在后面(如示例中最下面的catch子句可以响应任何异常),依此类推,其他catch子句按照System.Exception的继承层次依次由底层向高层罗列,否则将导致编译错误。
catch子句的执行代码通常会执行从异常恢复的代码,在执行末尾可以通过throw关键字再次引发由catch捕获的异常,并添加相应的信息通知调用端更多的信息内容;或者程序实现为线程从捕获异常的catch子句退出,然后执行finally子句和finally子句后的代码,当然前提是二者存在的情况下。
关于:异常筛选器
异常筛选器,用于表示用户可预料、可恢复的异常类,所有的异常类必须是System.Exception类型或其派生类,System.Excetpion类型是一切异常类型的基类,其他异常类例如DivideByZeroException、FileNotFoundException是派生类,从而形成一个有继承层次的异常类体系,越具体的异常类越位于层次的底层。
如果try子句未抛出异常,则CLR将不会执行任何catch子句的响应代码,而直接转向finally子句执行直到结束。
值得注意的是,finally块之后的代码段不总是被执行,因为在引发异常并且没有被捕获的情况下,将不会执行该代码。因此,对于必须执行的处理环节,必须放在finally子句中。
3.finally分析
异常发生时,程序将转交给异常处理程序,意味着那些总是希望被执行的代码可能不被执行,例如文件关闭、数据库连接关闭等资源清理工作,例如本例的StreamWriter对象。异常机制提供了finally子句来解决这一问题:无论异常是否发生,finally子句总是执行。因此,finally子句不总是存在,只有需要进行资源清理操作时,才有必要提供finally子句来保证清理操作总是被执行,否则没有必要提供“多余”的finally子句。
finally在CLR按照调用堆栈执行完catch子句的所有代码时执行。一个try块只能对应一个finally块,并且如果存在catch块,则finally块必须放在所有的catch块之后。如果存在finally子句,则finally子句执行结束后,CLR会继续执行finally子句之后的代码。
根据示例我们对try、catch和finally子句分别做了分析,然后对其应用规则做以小结,主要包括:
l catch子句可以带异常筛选器,也可以不带任何参数。如果不存在任何表达式,则表明该catch子句可以捕获任何异常类型,包括兼容CLS的异常或者不兼容的异常。
l catch子句按照筛选器的继承层次进行顺序罗列,如果将具体的异常类放在执行顺序的末尾将导致编译器异常。而对于继承层次同级的异常类,则可以随意安排catch子句的先后顺序,例如DivideByZeroException类和FileNotFoundException类处于System.Exception继承层次的同一层次,因此其对应的catch子句之间可以随意安排先后顺序。
l 异常筛选器,可以指定一个异常变量,该变量将指向抛出的异常类对象,该对象记录了相关的异常信息,可以在catch子句内获取该信息。
l finally子句内,也可以抛出异常,但是应该尽量避免这种操作。
l CLR如果没有搜索到合适的异常筛选器,则说明程序发生了未预期的异常,CLR将抛出一个未处理异常,应用程序应该提供对未处理异常的应对策略,例如:在发行版本中将异常信息写入日志,而在开发版本中启用调试器定位。
l try块内定义的变量对try块外是不可见的,因此对于try块内进行初始化的变量,应该定义在try块之前,否则try块外的调用将导致编译错误。例如示例中的StreamWriter的对象定义,一定要放在try块之外,否则无法在finally子句内完成资源清理操作。
8.6.4 .NET系统异常类
1.异常体系
.NET框架提供了不同层次的异常类来应对不同种类的异常,并且形成一定的继承体系,所有的异常类型都继承自System.Exception类。例如,图8-4是异常继承层次的一个片段,继承自上而下由通用化向特定化延伸。
FCL定义了一个庞大的异常体系,熟悉和了解这些异常类型是有效应用异常和理解异常体系的有效手段,但是显然这一工作只能交给搜索MSDN来完成了。然而,我们还是应该对一些重要的.NET系统异常有一定的了解,主要包括:
l OverflowException,算术运算、类型转换时的溢出。

图8-4 异常类的部分继承体系
l StackOverflowException,密封类,不可继承,表示堆栈溢出,在应用程序中抛出该异常是不适当的做法,因为一般只有CLR本身会抛出堆栈溢出的异常。
l OutOfMemoryException,内存不足引发的异常。
l NullReferenceException,引用空引用对象时引发。
l InvalidCastException,无效类型转换引发。
l IndexOutOfRangeException,试图访问越界的索引而引发的异常。
l ArgumentException,无效参数异常。
l ArgumentNullException,给方法传递一个不可接受的空参数的空引用。
l DivideByZeroException,被零除引发。
l ArithmeticException,算术运行、类型转换等引发的异常。
l FileNotFoundException,试图访问不存在的文件时引发。
注意,这里罗列的并非全部的常见异常,更非FCL定义的所有系统异常类型。对于异常类而言,更多的精力应该放在关注异常基类System.Exception的理解上,以期提纲挈领。
2.System.Exception类解析
关于System.Exception类型,它是一切异常类的最终基类,而它本身又继承自System.Object类型,用于捕获任何与CLS兼容的异常。Exception类提供了所有异常类型的基本属性与规则,例如:
l Message属性,用于描述异常抛出原因的文本信息。
l InnerException属性,用于获取导致当前异常的异常集。
l StackTrack属性,提供了一个调用栈,其中记录了异常最初被抛出的位置,因此在程序调试时非常有用,例如:
public static void Main()
{
try
{
TestException();
}
catch (Exception ex)
{
//输出当前调用堆栈上的异常的抛出位置
Console.WriteLine(ex.StackTrace);
}
}
private static void TestException()
{
//直接抛出异常
throw new FileNotFoundException("Error.");
}
l HResult受保护属性,可读写HRESULT值,分配特定异常的编码数值,主要应用于托管代码与非托管代码的交互操作。
还有其他的方法,例如HelpLink用于获取帮助文件的链接,TargetSite方法用于获取引发异常的方法。
还有很多公有方法辅助完成异常信息的获取、异常类序列化等操作。其中,实现ISerializable接口方法GetObjectData值得关注,异常类新增字段必须通过该方法填充SerializationInfo,异常类进行序列化和反序列化必须实现该方法,其定义可表示为:
[ComVisible(true)]
public interface ISerializable
{
[SecurityPermission(SecurityAction.LinkDemand, Flags = SecurityPermission Flag.SerializationFormatter)]
void GetObjectData(SerializationInfo info, StreamingContext context);
}
参数info表示要填充的SerializationInfo对象,而context则表示要序列化的目标流。我们在下文的自定义异常中将会有所了解。
.NET还提供了两个直接继承于Exception的重要子类:ApplicationException和SystemException类。其中,ApplicationException类型为FCL为应用程序预留的基类型,所以自定义异常可以选择ApplicationException或者直接从Exception继承;SystemException为系统异常基类,CLR自身抛出的异常继承自SystemException类型。
8.6.5 定义自己的异常类
FCL定义的系统异常,不能解决所有的问题。异常机制与面向对象有效的集成,意味着我们可以很容易的通过继承System.Exception及其派生类,来实现自定义的错误处理,扩展异常处理机制。
上文中,我们简单学习了System.Exception类的实现属性和方法,应该说研究Exception类型对于实现自定义异常类具有很好的参考价值,微软工程师已经实现了最好的实现体验。我们以实际的示例出发,来说明自定义异常类的实现,总结其实现与应用规则,首先是自定义异常类的实现:
//Serializable指定了自定义异常可以被序列化
[Serializable]
public class MyException : Exception, ISerializable
{
//自定义本地文本信息
private string myMsg;
public string MyMsg
{
get { return myMsg; }
}
//重写只读本地文本信息属性
public override string Message
{
get
{
string msgBase = base.Message;
return myMsg == null ? msgBase : msgBase + myMsg;
}
}
//实现基类的各公有构造函数
public MyException()
: base(){ }
public MyException(string message)
: base(message) { }
public MyException(string message, Exception innerException)
: base(message, innerException) { }
//为新增字段实现构造函数
public MyException(string message, string myMsg)
: this(message)
{
this.myMsg = myMsg;
}
public MyException(string message, string myMsg, Exception innerException)
: this(message, innerException)
{
this.myMsg = myMsg;
}
//用于序列化的构造函数,以支持跨应用程序域或远程边界的封送处理
protected MyException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
myMsg = info.GetString("MyMsg");
}
//重写基类GetObjectData方法,实现向SerializationInfo中添加自定义字段信息
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("MyMsg", myMsg);
base.GetObjectData(info, context);
}
}
然后,我们实现一个自定义异常测试类,来进一步了解.NET异常机制的执行过程:
class Test_CustomException
{
public static void Main()
{
try
{
try
{
string str = null;
Console.WriteLine(str.ToString());
}
catch (NullReferenceException ex)
{
//向高层调用方抛出自定义异常
throw new MyException("这是系统异常信息。", ""n这是自定义异常信息。", ex);
}
}
catch (MyException ex)
{
Console.WriteLine(ex.Message);
}
}
}
结合示例的实践,总结自定义异常类的规则与规范,主要包括:
l 首先,选择合适的基类继承,一般情况下我们都会选择Exception类或其派生类作为自定义异常类的基类。但是异常的继承深度不宜过多,一般在2~3层是可接受的维护范围。
l System.Exception类型提供了三个公有构造函数,在自定义类型中也应该实现三个构造函数,并且最好调用基类中相应的构造函数;如果自定义类型中有新的字段要处理,则应该为新的字段实现新的构造函数来实现。
l 所有的异常类型都是可序列化的,因此必须为自定义异常类添加SerializableAttribute特性,并实现ISerializable接口。
l 以Exception作为异常类名的后缀,是良好的编程习惯。
l 在自定义异常包括本地化描述信息,也就是实现异常类的Message属性,而不是从基类继承,这显然违反了Message本身的语义。
l 虽然异常机制提高了自定义特定异常的方法,但是大部分时候我们应该优先考虑.NET的系统异常,而不是实现自定义异常。
l 要想使自定义异常能应用于跨应用程序域,应该使异常可序列化,给异常类实现ISerializable接口是个好的选择。
l 如果自定义异常没有必要实现子类层次结构,那么异常类应该定义为密封类(sealed),以保证其安全性。
8.6.6 异常法则
异常法则是使用异常的最佳体验规则与设计规范要求,在实际的应用中有指导作用,主要包含以下几个方面:
l 尽可能以逻辑流程控制来代替异常,例如非空字段的处理不要延迟到业务处理阶段,而应在代码校验时完成。对于文件操作的处理,应该首先进行路径是否存在的校验,而不是将责任一股脑推给FileNotFoundException异常来处理。
l 将异常理解为程序的错误,显然曲解了对异常本质的认识。正如前文所言,异常是对程序接口隐含假设的一种违反,而这种假设常常和错误没有关系,反倒更多的是规则与约定。例如客户端“无理”的用Word来打开媒体文件,对程序开发者来说,这种“错误”是不可见的,这种问题只是违反了媒体文件只能用相关播放器打开的假设,而并非程序开发者的错误。
l 对异常形成文档,详细描述关于异常的原因和相关信息,是减少引发异常的有效措施。
l .NET 2.0提供了很多新特性来简化异常的处理,同时从性能的角度考虑也是很好的选择,例如:
public static void Main()
{
DateTime now;
if(DateTime.TryParse("2007/11/7 23:31:00", out now))
{
Console.WriteLine("Now it's {0}", now);
}
}
上例中实际实现了一个Try-Parse模式,以最大限度地减少异常造成的性能损失。对于很多常用的基础类型成员来说,实现Try-Parse模式是避免处理异常性能的一种不错的选择,.NET类库的很多基础类型都实现了这一模式,例如Int32、Char、Byte、DateTime等等。
还有一种Tester-Doer模式,同样是用来减少异常的性能问题,在此就不做深入的研究。
l 对于多个catch块的情况,应该始终保证由最特定异常到最不特定异常的顺序来排列,以保证特定异常总是首先被执行。
l 异常提示应该准确而有效,提供丰富的信息给异常查看者来进行正确的判断和定位。
l 异常必须有针对性,盲目地抛出System.Exception意味着对于异常的原因是盲目的,而且容易造成异常被吞现象的发生。何时抛出异常,抛出什么异常,建立在对上下文环境的理解基础上。
l 尽量避免在Finally子句抛出异常。
l 应该避免在循环中抛出异常。
l 可以选择以using语句代替try/finally块来完成资源清理,详见6.3节“using的多重身份”。
另外,微软还提供了Enterprise Library异常处理应用程序块(简称EHAB)来实现更灵活、可扩展、可定制的异常处理框架,力图体现对异常处理的最新实践方式。
8.6.7 结论
本节旨在提纲挈领的对异常机制及其应用实践做以铺垫,关于异常的性能、未见异常处理及堆栈跟踪等问题只能浅尝于此。在今后的实践中,还应注意应用异常机制处理,要关注上下文的环境做出适当选择。

浙公网安备 33010602011771号