一文看懂 .NET 的异常处理机制、原则以及最佳实践

什么时候该抛出异常,抛出什么异常?什么时候该捕获异常,捕获之后怎么处理异常?你可能已经使用异常一段时间了,但对 .NET/C# 的异常机制依然有一些疑惑。那么,可以阅读本文。

本文适用于已经入门 .NET/C# 开发,已经开始在实践中抛出和捕获异常,但是对 .NET 异常机制的用法以及原则比较模糊的小伙伴。通过阅读本文,小伙伴们可以迅速在项目中使用比较推荐的异常处理原则来处理异常。


快速了解 .NET 的异常机制

Exception 类

我们大多数小伙伴可能更多的使用 Exception 的类型、Message 属性、StackTrace 以及内部异常来定位问题,但其实 Exception 类型还有更多的信息可以用于辅助定位问题。

如果你自己写一个自定义异常类,那么你可以在自定义的异常类中记录更多的信息。然而大多数情况下我们都考虑使用 .NET 中自带的异常类,因此可以充分利用 Exception 类中的已有属性在特殊情况下报告更详细的利于调试的异常信息。

捕捉异常

捕捉异常的基本语法是:

try
{
    // 可能引发异常的代码。
}
catch (FileNotFoundException ex)
{
    // 处理一种类型的异常。
}
catch (IOException ex)
{
    // 处理另一种类的异常。
}

除此之外,还有 when 关键字用于筛选异常:

try
{
    // 可能引发异常的代码。
}
catch (FileNotFoundException ex) when (Path.GetExtension(ex.FileName) is ".png")
{
    // 处理一种类型的异常,并且此文件扩展名为 .png。
}
catch (FileNotFoundException ex)
{
    // 处理一种类型的异常。
}

无论是否有带 when 关键字,都是前面的 catch 块匹配的时候执行匹配的 catch 块而无视后面可能也匹配的 catch 块。

如果 when 块中抛出异常,那么此异常将被忽略,when 中的表达式值视为 false。有个但是,请看:.NET Framework 的 bug?try-catch-when 中如果 when 语句抛出异常,程序将彻底崩溃 - walterlv

引发异常

引发异常使用 throw 关键字。只是注意如果要重新抛出异常,请使用 throw; 语句或者将原有异常作为内部异常。

创建自定义异常

如果你只是随便在业务上创建一个异常,那么写一个类继承自 Exception 即可:

public class MyCustomException : Exception
{
    public string MyCustomProperty { get; }

    public MyCustomException(string customProperty) => MyCustomProperty = customProperty;
}

不过,如果你需要写一些比较通用抽象的异常(用于被继承),或者在底层组件代码中写自定义异常,那么就建议考虑写全异常的所有构造函数,并且加上可序列化:

[Serializable]
public class InvalidDepartmentException : Exception
{
    public InvalidDepartmentException() : base() { }
    public InvalidDepartmentException(string message) : base(message) { }
    public InvalidDepartmentException(string message, Exception innerException) : base(message, innerException) { }

    // 如果异常需要跨应用程序域、跨进程或者跨计算机抛出,就需要能被序列化。
    protected InvalidDepartmentException(SerializationInfo info, StreamingContext context) : base(info, context) { }
}

在创建自定义异常的时候,建议:

finally

异常堆栈跟踪

堆栈跟踪从引发异常的语句开始,到捕获异常的 catch 语句结束。

利用这一点,你可以迅速找到引发异常的那个方法,也能找到是哪个方法中的 catch 捕捉到的这个异常。

异常处理原则

try-catch-finally

我们第一个要了解的异常处理原则是——明确 try catch finally 的用途!

try 块中,编写可能会发生异常的代码。

最好的情况是,你只将可能会发生异常的代码放到 try 块中,当然实际应用的时候可能会需要额外放入一些相关代码。但是如果你将多个可能发生异常的代码放到一个 try 块中,那么将来定位问题的时候你就会很抓狂(尤其是多个异常还是一个类别的时候)。

catch 块的作用是用来 “恢复错误” 的,是用来 “恢复错误” 的,是用来 “恢复错误” 的。

如果你在 try 块中先更改了类的状态,随后出了异常,那么最好能将状态改回来——这可以避免这个类型或者应用程序的其他状态出现不一致——这很容易造成应用程序“雪崩”。举一个例子:我们写一个程序有简洁模式和专业模式,在从简洁模式切换到专业模式的时候,我们设置 IsProfessionalModetrue,但随后出现了异常导致没有成功切换为专业模式;然而接下来所有的代码在执行时都判断 IsProfessionalModetrue 状态不正确,于是执行了一些非预期的操作,甚至可能用到了很多专业模式中才会初始化的类型实例(然而没有完成初始化),产生大量的额外异常;我们说程序雪崩了,多数功能再也无法正常使用了。

当然如果任务已全部完成,仅仅在对外通知的时候出现了异常,那么这个时候不需要恢复状态,因为实际上已经完成了任务。

你可能会有些担心如果我没有任何手段可以恢复错误怎么办?那这个时候就不要处理异常!——如果不知道如何恢复错误,请不要处理异常!让异常交给更上一层的模块处理,或者交给整个应用程序全局异常处理模块进行统一处理(这个后面会讲到)。

另外,异常不能用于在正常执行过程中更改程序的流程。异常只能用于报告和处理错误条件。

finally 块的作用是清理资源。

虽然 .NET 的垃圾回收机制可以在回收类型实例的时候帮助我们回收托管资源(例如 FileStream 类打开的文件),但那个时机不可控。因此我们需要在 finally 块中确保资源可被回收,这样当重新使用这个文件的时候能够立刻使用而不会被占用。

一段异常处理代码中可能没有 catch 块而有 finally 块,这个时候的重点是清理资源,通常也不知道如何正确处理这个错误。

一段异常处理代码中也可能 try 块留空,而只在 finally 里面写代码,这是为了“线程终止”安全考虑。在 .NET Core 中由于不支持线程终止因此可以不用这么写。详情可以参考:.NET/C# 异常处理:写一个空的 try 块代码,而把重要代码写到 finally 中(Constrained Execution Regions) - walterlv

该不该引发异常?

什么情况下该引发异常?答案是——这真的是一个异常情况!

于是,我们可能需要知道什么是“异常情况”。

一个可以参考的判断方法是——判断这件事发生的频率:

例如这些情况都应该认为是异常:

而下面这些情况则不应该认为是异常:

对于这些不应该认为是异常的情况,编写的代码就应该尽可能避免异常。

有两种方法来避免异常:

  1. 先判断再使用。
    • 例如读取文件之前,先判断文件是否存在;例如读取文件流时先判断是否已到达文件末尾。
    • 如果提前判断的成本过高,可采用 TryDo 模式来完成,例如字符串转数字中的 TryParse 方法,字典中的 TryGetValue 方法。
  2. 对极为常见的错误案例返回 null(或默认值),而不是引发异常。极其常见的错误案例可被视为常规控制流。通过在这些情况下返回 NULL(或默认值),可最大程度地减小对应用的性能产生的影响。(后面会专门说 null)

而当存在下列一种或多种情况时,应引发异常:

  1. 方法无法完成其定义的功能。
  2. 根据对象的状态,对某个对象进行不适当的调用。

请勿有意从自己的源代码中引发 System.ExceptionSystem.SystemExceptionSystem.NullReferenceExceptionSystem.IndexOutOfRangeException

该不该捕获异常?

在前面 try-catch-finally 小节中,我们提到了 catch 块中应该写哪些代码,那里其实已经说明了哪些情况下应该处理异常,哪些情况下不应该处理异常。一句总结性的话是——如果知道如何从错误中恢复,那么就捕获并处理异常,否则交给更上层的业务去捕获异常;如果所有层都不知道如何处理异常,就交给全局异常处理模块进行处理。

应用程序全局处理异常

对于 .NET 程序,无论是 .NET Framework 还是 .NET Core,都有下面这三个可以全局处理的异常。这三个都是事件,可以自行监听。

对于 GUI 应用程序,还可以监听 UI 线程上专属的全局异常:

关于这些全局异常的处理方式和示例代码,可以参阅博客:

抛出哪些异常?

任何情况下都不应该抛出这些异常:

首先是你自己不应该抛出这样的异常。其次,你如果在运行中捕获到了上面这些异常,那么代码一定是写得有问题。

如果是捕获到了上面 CLR 的异常,那么有两种可能:

  1. 你的代码编写错误(例如本该判空的代码没有判空,又如索引数组超出界限)
  2. 你使用到的别人写的代码编写错误(那你就需要找到它改正,或者如果开源就去开源社区中修复吧)

而一旦捕获到了上面其他种类的异常,那就找到抛这个异常的人,然后对它一帧狂扁即可。

其他的异常则是可以抛出的,只要你可以准确地表明错误原因。

另外,尽量不要考虑抛出聚合异常 AggregateException,而是优先使用 ExceptionDispatchInfo 抛出其内部异常。详见:使用 ExceptionDispatchInfo 捕捉并重新抛出异常 - walterlv

异常的分类

该不该引发异常 小节中我们说到一个异常会被引发,是因为某个方法声称的任务没有成功完成(失败),而失败的原因有四种:

  1. 方法的使用者用错了(没有按照方法的契约使用)
  2. 方法的执行代码写错了
  3. 方法执行时所在的环境不符合预期

简单说来,就是:使用错误,实现错误、环境错误。

使用错误:

实现错误:

前面由 CLR 抛出的异常代码主要都是实现错误

环境错误:

另外,还剩下一些不应该抛出的异常,例如过于抽象的异常和已经过时的异常,这在前面一小结中有说明。

其他

一些常见异常的原因和解决方法

在平时的开发当中,你可能会遇到这样一些异常,它不像是自己代码中抛出的那些常见的异常,但也不包含我们自己的异常堆栈。

这里介绍一些常见这些异常的原因和解决办法。

AccessViolationException

当出现此异常时,说明非托管内存中发生了错误。如果要解决问题,需要从非托管代码中着手调查。

这个异常是访问了不允许的内存时引发的。在原因上会类似于托管中的 NullReferenceException

FileNotFoundException

捕捉非 CLS 异常


参考资料

本文会经常更新,请阅读原文: https://blog.walterlv.com/post/dotnet-exception.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,或者前往 CSDN 关注我的主页

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 (walter.lv@qq.com)