程序总是会出错的,因为即便开发者做得再仔细,也还是会有预料不到的情况发生。令代码在发生异常时依然能够保持稳定是每一位C#程序员所应掌握的关键技能。
.NET Framework Design Guidelines建议,如果方法不能完成调用者所请求的操作,那就可以考虑抛出异常,此时必须提供各种信息,使得调用者能够据此诊断问题。
此外,还必须保证如果应用程序能够从错误中恢复,那么必须处在某种已知的状态。
考虑在方法约定遭到违背时抛出异常
如果方法不能够履行它与调用者所订立的契约,那就应该让它其抛出异常。这些无法履约的情况都应该通过异常来表示。然而要注意,由于异常并不适合当作控制程序流程的常规手段,因为抛出异常开销很大,而且会导致代码中很多try-catch。因此,还应该同时提供另外一套方法,使得开发者可以在执行操作之前先判断该操作能否顺利执行,以便在无法顺利执行的情况下采取相应的措施,而不是等到抛出了异常之后再去处理。
用类库的的File.Open来举例,它在无法完成操作时会抛出异常;但同时也提供了File.Exists来判断文件是否存在。所以调用者可以在Open前先判断Exists,当然除了文件不存在,文件被占用、没有权限等也会导致Open失败,这是文件操作的细节问题,但这种设计思路是可以借鉴的。
假设提供DoWork方法,按照前面的思路,可以这样实现
public bool TryDoWork()
{
if(!TestConditions())
return false;
DoWork();
return true;
}
public void DoWork(){...}
public bool TestConditions()
{
...
}
专门针对应用程序创建异常
异常是一种用来报告错误的机制,有时需要创建自定义的异常。但首先要明确,并不是所有错误都必须表示成异常,至于哪些错误才需要用异常来表示,并没有固定的规律可循。一般来说,如果某种状况必须立刻得到处理或汇报,否则将长期影响应用程序,那么就应该抛出异常,比如数据库发生了数据完整性问题,就需要立刻抛出异常;但如果只是无法把某个试图的折叠、打开状态记录下来,因为不会造成严重的影响,则可以考虑只返回错误码。
然后,也不需要为所有的throw语句都新建一种异常类,但统统用Exception基类来抛出也不合适。
之所以要创建不同的异常类,主要原因就是为了令调用端能够通过不同的catch子句去捕获那些状况,从而采用不同的处理方式,所以可以基于这一点来判断要新建异常类,还是复用已有的类。
一旦决定自己来创建异常类,就必须遵循相应的原则:
- 继承Exception基类
- 子类应该提供与Exception基类相同的构造函数重载,然后把相应的工作委托基类完成
优先考虑做出强异常保证
某个操作在抛出异常的时候,要负责把自身的状态管理好,这将直接关系到捕获异常的人有没有较大的余地来处理该异常。
针对异常所做的保证分成三种:
- 基本保证(basic guarantee),确保当异常离开了产生该异常的函数后,程序中的资源不会泄漏,而且所有的对象都处在有效状态。这相当于规定了抛出异常的那个方法在运行完其finally子句之后所必须达成的效果。
- 强保证(strong guarantee),强保证是在基本保证的基础上做出的,它要求整个程序的状态不能因为某操作抛出异常而有所变化。
- no-throw保证,执行该操作的那个方法绝对不会抛出异常。
.NET CLR做出了一些基本的保证,例如会在发生异常时把内存管理好。除非你的资源实现了IDisposable接口,否则不太会在这种情况下出现资源泄漏问题。
no-throw保证的例子有finalizer、Dispose方法、catch的when子句,此外编写委托目标方法时也应对遵守no-throw保证,在这些场合,绝对不应该令任何异常脱离其范围。
在这三种态度中,强保证是较为折中的,它既允许程序抛出异常并从中恢复,又使得开发者能够较为简便地处理该异常。
在强异常保证下,如果某操作抛出异常,那么应用程序的状态必须和执行该操作之前相同。这项操作要么完全成功,要么彻底失败。如果失败,那么程序的状态应与执行操作之前一模一样,而不会出现部分成功的情形。
比如在修改集合数据时,为了实现强异常保证,可以考虑先对有待修改的数据做防御式的拷贝(defensive copy),然后在拷贝出来的数据上面执行操作。如果该操作顺利执行而没有抛出异常,那么就用这份数据把原数据替换掉,令程序的状态得以改变;在发生异常时,原数据还是完整的。
从上面的例子也可知,要想做到强异常保证,往往会降低程序的性能,不过很多时候,从错误中恢复的能力,要比性能稍稍得到提升更为重要。
考虑用异常筛选器来改写先捕获异常再重新抛出的逻辑
在catch异常时,有时需要先判断程序状态、对象状态或异常中的属性,然后再加以处理。通常会想到在catch块进行判断,最后再把这个异常重新抛出。
但更推荐异常筛选器来做,因为使用异常筛选器后,编译器所生成的代码会先评判异常筛选器的值,然后再考虑要不要执行栈展开(stackunwinding),因此,发生异常的原始位置能够保留下来,而且调用栈中的所有信息(包括局部变量的值)也可以保持不变。
与之相对的,如果在catch块中使用throw e
重新抛出,那么系统所报告的异常发生地点就是throw语句所在的位置,这会导致丢失异常的堆栈信息,直接throw
虽然可以保留原始堆栈的信息,但这种在catch块中处理的写法,每次都会进入catch块、发生栈展开,这会产生较大的运行开销。
合理利用异常筛选器的副作用
一般来说,异常筛选器中的条件总是应该能在某些情况下得以满足,如果永远都无法满足,那么这个筛选器就失去了意义。然而有的时候,为了能监控程序中所发生的异常,还是可以考虑编写这种永远返回false的筛选器,此时调用栈还没有真正展开,但却可以获取到异常的信息。
比如可以用于异常的记录:
public static void Filter()
{
try
{
// ...
}
catch (Exception e) when (ForWhen(e)) { }
catch (FormatException e)
{
// handle exception
}
}
public static bool ForWhen(Exception e)
{
Console.WriteLine($"captured in when, msg:{e.Message}");
return false;
}
将catch (Exception e) when (ForWhen(e)) { }
放到所有的catch之前,可以将所有的异常记录下来,但这里也需要注意:
- 这行代码catch的应该是Exception基类,除非有特殊目的只catch某些异常
- when条件始终返回false
- 执行when条件判断的代码应做no-throw保证
参考书籍
《Effective C#:改善C#代码的50个有效方法(原书第3版)》 比尔·瓦格纳