第六节:指导原则和最佳实践
理解异常机制固然重要,但同等重要的是理解如何正确使用异常。我经常发现类库开发人员捕捉所有类型的异常,造成应用程序开发人员对问题不知情。本章就异常的使用提供一些指导原则。
重要提示 如果你是类库开发人员,要设计供其他开发人员使用的类型,那么一定要严谨按照这些指导原则行事。你的责任非常重大,要精心设计类库中的类型,使之适用于各种各样的应用程序。记住,你无法做到对自己要调用的代码了如指掌,也不知道哪些代码会调用你的代码。由于无法预知使用类型的每一种情形,所以不要做出任何策略抉择,换言之,你的代码不要想当然的决定一些错误情形;应该让调用者自己决定。
除此之外,要非常严谨的监视状态,尽量不要破换它,使用代码契约验证传给方法的实参,尝试完全不去修改状态。如果不得不修改状态,就好做好出错的准备,并在出错后尝试恢复状态。
如果你是应用程序开发人员,可以定义自己认为合适的任何策略。遵照本章的知道原则行事,有助于在应用程序发布之前发现并修复代码中的问题,使应用程序更健壮。但是,经深思熟虑之后,也完全可以不按这些知道原则行事。你要设置自己的策略。例如,应用程序代码在捕捉异常方面可以比类库代码更激进一些。
- 善用finally块
我认为finally块非常厉害!不管线程抛出什么样的异常,finally块中的代码都保证会执行。应该先用finally块清理那些已成功启动的操作,然后再返回调用者或执行finally块之后的代码。Finally块还经常用于显示释放对象以避免资源泄露。下面将所有资源清理代码放在finally代码块中:
private void SomeMethod()
{
FileStream fs = new FileStream(@"c:\Data.bin", FileMode.Open);
try
{
Console.WriteLine(100 / fs.ReadByte());
}
finally
{
//将资源清理代码放在finally代码块中,确保无论异常是否会发生,文件都将关闭
fs.Close();
}
}
确保清理代码的执行时如此重要,以至于许多编程语言都提供了一些构造来简化清理代码的编写。例如,只要使用lock,using和foreach语句,C#编译器会自动生成try/finally块,使用这些构造时,编译器将你写的代码放在try块内部,并自动将清理代码放在finally块中。
1、 使用lock语句时,锁会在finally块中释放。
2、 使用using语句时,会在finally块中调用对象的Dispose方法。
3、 使用foreach语句时,会在finally块中调用IEnumerator对象的Dispose方法。
4、 定义析构器方法时,会在finally块中调用基类的Finalize方法。
例如 ,以下C#代码利用using语句。这段代码比上一例子短,但两者编译后的结果是一样的。
private void SomeMethod()
{
using (FileStream fs = new FileStream(@"c:\Data.bin", FileMode.Open))
{
Console.WriteLine(100 / fs.ReadByte());
}
}
- 不要什么都捕捉
使用异常时,新手一个常犯错误时过于频发或者不恰当使用catch块,捕捉一个异常表明你遇见了该异常,理解它为什么发生,并指导如何处理它。换句话说,实在为应用程序定义一个策略。
例如 向下面这样的代码
try
{
//尝试执行可能失败的代码
}
catch(Exception)
{
}
这段代码指出它遇见了所有的异常类型,并知道如何从所有异常中恢复。这不是在吹牛么?如果一个类型是类库的一部分,那么在任何情况下,它都决不能捕捉被”吃掉”所有异常(装作异常没有发生),因为它不可能准确预知应用程序具体如何响应一个异常。除此之外,通过委托、虚方法和接口方法,类型会经常调用应用程序代码。如果应用程序代码抛出一个异常,应用程序的另一部分则可能预期要捕捉这个异常,所以,你决不能写一个”大小通吃”的类型,悄悄的吃掉这个异常,而是允许该异常在调用栈上上移动,让应用程序代码针对性的处理这个异常。
顺便说一句,在一个catch块中,确实可以捕捉System.Exception并执行一些代码,只要在这个catch块的末尾重新抛出异常。千万不要捕捉System.Exception异常悄悄的吃掉它,否则应用程序不知道已经出错,还会继续运行,造成不可预测的结果和潜在的安全隐患。Vs的代码分析工具(FxCopCmd.exe)会标记包含一个catch块的所有代码,除非代码中含有一个throw语句,
最后,可以在一个线程中捕捉异常,在另一个线程中重新抛出。为此提供支持的是异步编程模型。例如,假定一个线程池线程执行的代码抛出了异常,CLR捕获并吃掉这个异常,并允许线程返回线程池。稍后,会有某个线程调用EndXXX方法来判断异步操作的结果。EndXXX方法将抛出与负责实际工作的那个线程池线程抛出的一样的异常。所以,异常虽然被第一个方法吃掉,但又被EndXXX线程重新抛出。这样一来,该异常在应用程序面前就不是隐藏的了。
- 得体的从异常中恢复
有的时候,在调用一个方法时,已经预测到它可能抛出异常。由于能预测到这些异常,所以可以写一些代码,允许应用程序从异常中得体的恢复并运行。下面是一个伪代码的例子:
public string CalculateSpreadsheetCell(Int32 row,Int32 column)
{
String result;
try
{
result =//计算电子单元格中的值。
}
catch(DivideByZeroException)//捕捉被零除错误
{
result="Can't show value value:Too big";
}
return
}
上面伪代码计算电子表格单元格中的内容,将代表值的一个字符串返回给调用者,使调用者能在应用程序的窗口中显示字符串。但是,单元格的内容可能是另外两个单元格相除的结果。如果作为分母的单元格包含了0,CLR将抛出上面的异常。在上面的例子中,方法能捕捉到这个异常,返回并向用户显示一个特殊的字符串。类似的,单元格的内容可能是另两个单元格相乘的结果。如果结果超出该单元格的取值范围,CLR将抛出一个OverflowException异常。同样的,会返回并向用户显示一个字符串。
捕捉具体的异常时,应充分掌握在什么情况下会抛出异常,并指导从捕捉的异常类型派生了哪些类型。除非在catch块的末尾重新抛出捕捉到的异常。否则,不要捕捉并处理Exception,因为不肯能搞清楚在try块中全部可能抛出的异常。
- 发生不可恢复的异常时回滚部分完成的操作----维持状态
通常,方法要调用其它几个方法来执行一个抽象操作,这些方法中有些可能会成功,有些可能会失败,例如,假如要将一组对象序列化一个磁盘文件。序列化好第10个对象之后,抛出一个异常(可能因为磁盘已满,或者或者要序列化下一个对象没有应用Serializable这个定制attribute)。在这种情况下,应该将这个异常漏给调用者处理,但磁盘文件的状态怎么办呢?文件包含一部分序列化的对象图,所以它已经损坏。理想情况下,应用程序应该回滚部分完成的操作,将文件恢复为任何对象序列化之前的状态,一下代码演示了正确的实现方法:
public void SerializeObjectGraph(FileStream fs, IFormatter formatter, Object rootObj)
{
//保存文件的当前位置
Int64 beforeSerizlization = fs.Position;
try
{
//尝试将对象图序列化到文件中
formatter.Serialize(fs, rootObj);
}
catch
{
//任何事情出错,就将文件恢复到一个有效状态
fs.Position = beforeSerizlization;
//截断文件
fs.SetLength(fs.Length);
throw;
}
}
为了正确回滚部分完成的操作,代码应捕捉所有异常。但是,这里要捕捉所有异常,因为你不关心发生了什么错误,只关心如何将数据结构恢复为一致状态。捕捉并处理好异常后,不要把它吃掉。相反,要让调用者知道发生了什么异常。为此,只需要重新抛出相同的异常。实际上,C#和其他语言简化了这项任务,只需像以上代码那样单独使用C#的throw关键字,不再throw后面指定任何东西。
注意,以上代码中的catch块没有指定任何异常类型,因为要捕捉所有异常类型。除此之外,catch块中的代码也不需要准确的知道抛出什么样的异常,只需要知道错误发生就可以了,throw语句可以重新抛出捕捉到的任何对象。
- 隐藏实现细节来维系契约
有时候需要捕捉一个异常并重新抛出一个不同的异常。这样做唯一的原因是维系方法“契约”。另外,抛出的新异常类型地应该是一个具体的异常(不能是其他异常类型的基类)。假如在一个PhoneBook类型中定义了一个方法,它负责根据姓名来查找电话号码,如下伪代码所示:
sealed class PhoneBook
{
private string m_pathname;
public String GetPhoneNumber(String name)
{
String phone;
FileStream ds = null;
try
{
ds=new FileStream(m_pathname,FileMode.Open);
phone=/*以找到的电话号码*/
}
catch(FileNotFoundException e)
{
//抛出一个不同的异常,将Name包含到其中,并将原来的异常设为内部异常
throw new NameNotFoundException(name,e);
}
catch(IOException e)
{
throw new NameNotFoundException(name,e);
}
finally
{
if(fs!=null) ds.Close();
}
return phone;
}
}
地址薄的数据是从一个文件(而非网络连接或数据库)获得的。但是,PhoneBook类型的用户并不知道这一点,因为这是未来可能改变的一个实现细节。所以,文件由于任何原因为找到或不能读取,调用者将看到一个FileNotFoundException和IOException异常,但这两个异常都不是预期的,因为文件存在与否以及能否读取不是方法隐士契约的一部分,调用者根本无法预测。所以,该方法或捕捉这两种异常,并抛出一个新的NameNotFoundException异常。
使用这个技术时,只有从分掌握了造成抛出异常的原因,才能捕捉这些具体的异常。另外,还应知道哪些异常类型时从你捕捉的这个异常类型派生的。
由于最终还是要抛出一个异常,所以调用者仍然顺利的知道了该方法不能完成任务,而NameNotFoundException类型为调用者提供了理解其中原因的一个抽象视图。将内部异常设为FileNotFoundException或IOException是非常重要的一步,这样才能保证不丢失造成异常的真正原因。除此之外,知道造成异常的原因,不仅对PhoneBook类的开发人员有用,对使用PhoneBoook类型的开发人员也有用。
重要提示: 使用这个技术时,实际是在两个方面欺骗了调用者。首先,在实际发生的错误上欺骗了调用者。在本例中,是文件未找到,而报告的是没有找到指定的姓名。其次,在错误发生的位置上欺骗了调用者。如果允许FileNotFoundException异常在调用栈中向上传递,它的StackTrace属性显示错误实在FileStream的构造器中发生的。但是由于现在是“吞掉”该异常。并重新抛出一个NameNotFoundException异常。所以堆栈跟踪会显示错误时在catch块中发生的,离异常实际发生的位置有好几行远,这会使调式变的非常困难,所以,这个技术务必谨慎。
现在,假设PhoneBook类型的实现和前面稍有不同。假设该类型提供了一个公共属性PhoneBookPathname,用户可通过它设置或获取存储了电话号码的那个文件的路径名。由于用户现在知道电话号码是从一个文件中获取,所以你应该修改GetPhoneNumber方法。使它不捕捉任何异常,相反,应该让抛出的所有异常都沿着方法的调用栈向上传递。注意,我改变的不是GetPhoneNumber方法的任何参数,而是PhoneBook类型之用户的抽象。用户期待文件路径是PhoneBook的契约的一部分。
有的时候,开发人员之所以捕捉一个异常并抛出一个新异常。目的是在异常中添加额外的数据或上下文。然而,如果这是你唯一的目的,那么只需要捕捉希望的异常类型,在异常对象的Data中添加数据,然后重新抛出相同的异常对象:
private static void SomeMethod(String filename)
{
try
{
}
catch (IOException e)
{
e.Data.Add("FileName",filename);
throw;//重新抛出一个异常对象,只是它现在包含额外的数据
}
}
下面是这个技术的很好的应用:如果一个类型构造抛出一个异常,而且该异常未在类型构造器方法中捕捉,CLR就会在内部捕捉到该异常,并改为抛出一个新的异常。并改为抛出一个新的TypeInitializationException。这样做之所以有用,是因为CLR会在你的方法中生成代码,隐士地调用类型构造器。如果类型构造器抛出一个DivideByZeroException,你的代码可能尝试捕捉它并从中恢复。但是,你甚至不知道自己正在调用类型构造器。所以CLR将DivideByZeroException转化成一个TypeInitializationException,使你清楚的知道异常时因为类型构造器失败而发生的;问题不出在你的代码。
相反,下面是这个技术的一个不好的应用:通过反射调用方法时,CLR内部捕捉方法抛出的任何异常,并把它转化成一个TargetInvocationException
。这是让人十分讨厌的一个设计,因为现在必须捕捉TargetInvocationException
对象,通过查看他的InnerException属性来辨别失败的真正原因。实际上,在使用反射时,经常会看到如下所示的代码:
private static void Reflection(Object o)
{
try
{
var mi=o.GetType().GetMethod("DoSomething");
mi.Invoke(o,null);
}
catch(TargetInvocationException E)
{
throw E.InnerException;
}
}
}
但是,我有一个好消息告诉大家:如果使用C#的dynamic基元类型来调用一个成员。编译器生成的代码就不会捕捉任何异常,而且会抛出一个TargetInvocationException对象,最初抛出的异常对象会正常滴在调用栈中向上传递。对大多数开发人员,这是使用C#基元来兴来替代反射的一个很好的理由。