多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
# 必备 .NET - C# 异常处理 作者 [Mark Michaelis](https://msdn.microsoft.com/zh-cn/magazine/mt149362?author=Mark+Michaelis) | 2015 年 11 月 ![](https://box.kancloud.cn/2016-01-08_568f2a846e245.png) 欢迎查看首个“必备.NET”专栏。您可以在其中了解 Microsoft .NET Framework 领域的所有最新动态,无论是 C# vNext 的最新进展(当前是 C# 7.0)、改进的 .NET 内部结构,还是 Roslyn 和 .NET 核心前端的最新动态(如转为开放源代码的 MSBuild)。 自 .NET 于 2000 年发布预览版以来,我一直在撰写和开发与 .NET 有关的内容。我撰写的大部分内容不仅限于新生事物,而是关于如何利用相应技术,并着眼于最佳做法。 我住在美国华盛顿州斯波坎市,我是 IntelliTect 高端咨询公司 ([IntelliTect.com](http://intellitect.com/)) 的“首席电脑痴”。IntelliTect 专门从事开发“难度大的产品”,做得很出色。20 年来,我一直是 Microsoft MVP(目前领域是 C#),并且在其中的 8 年里,我还是一名 Microsoft 区域总监。今天,本专栏将启动探讨更新后的异常处理指南。 C# 6.0 新增了两种异常处理功能。首先,它支持异常条件,即能提供表达式通过在堆栈展开之前进入 catch 块,筛选出异常。其次,它在 catch 块内添加了异步支持。在将异步添加到 C# 5.0 语言时,这是无法实现的。此外,之前五版 C# 和相应的 .NET Framework 中也有其他许多变更,在某些情况下这些变更非常重要,需要对 C# 编码指南进行编辑。在本期内容中,我将回顾许多变更,并提供更新后的编码指南,因为这些指南与异常处理(即捕获异常)相关。 ## 捕获异常: 回顾 很好理解的是,引发特定的异常类型可以让捕获程序使用异常类型本身来确定问题。换言之,其实没有必要捕获异常,也没有必要通过对异常消息使用 switch 语句来确定采取什么措施处理异常。相反,C# 支持多个 catch 块,每个 catch 块都能定位特定的异常类型,如图 1 所示。 图 1:捕获不同的异常类型 ~~~ using System; public sealed class Program {   public static void Main(string[] args)     try     {        // ...       throw new InvalidOperationException(          "Arbitrary exception");        // ...    }    catch(System.Web.HttpException exception)      when(exception.GetHttpCode() == 400)    {      // Handle System.Web.HttpException where      // exception.GetHttpCode() is 400.    }    catch (InvalidOperationException exception)    {      bool exceptionHandled=false;      // Handle InvalidOperationException      // ...      if(!exceptionHandled)        // In C# 6.0, replace this with an exception condition      {         throw;      }     }      finally    {      // Handle any cleanup code here as it runs      // regardless of whether there is an exception    }  } } ~~~ 当异常发生时,执行会跳至可以处理此异常的第一个 catch 块。如果有多个 catch 块与 try 相关联,则匹配接近程度依继承链而定(假设不含 C# 6.0 异常条件),且首个匹配项将处理异常。例如,即使引发的异常具有类型 System.Exception,这也是“一种”继承关系,因为 System.Invalid­OperationException 最终源自 System.Exception。由于 InvalidOperationException 最接近匹配引发的异常,因此是 catch(InvalidOperationException...) 会捕获异常,而不是 catch(Exception...) 块(如果有的话)。 catch 块必须按从最具体到最笼统的顺序显示(同样假设不含 C# 6.0 异常条件),以免出现编译时错误。例如,将 catch(Exception...) 块添加到其他所有异常之前会导致编译错误,因为之前的所有异常都源自继承链上某处的 System.Exception。另请注意,catch 块不要求使用命名参数。实际上,最终捕获即使没有参数类型也是允许的,不过这只限常规 catch 块。 有时,在捕获异常后,您可能会发现实际上无法充分处理异常。在这种情况下,您主要有两种选择。第一种选择是重新引发其他异常。在以下三种常见方案中,您可以这样做: 方案 1:捕获的异常无法充分确定异常触发问题。例如,当使用有效 URL 调用 System.Net.WebClient.DownloadString 时,运行时可能会在没有网络连接的情况下引发 System.Net.WebException,不存在的 URL 也会引发同种异常。 方案 2:捕获的异常包含不得在调用链前端公开的专用数据。例如,很早以前的 CLR v1 版本(甚至是初期测试版)有诸如“安全异常: 您无权确定 c:\temp\foo.txt 的路径”之类的异常。 方案 3:异常类型过于具体,以至于调用方无法处理。例如,当调用 Web 服务查找邮政编码时,服务器发生 System.IO 异常(如 Unauthorized­AccessException、IOException、FileNotFoundException、DirectoryNotFoundException、PathTooLongException、NotSupportedException、SecurityException 或 ArgumentException)。 重新引发其他异常时,请注意,您可能会丢失原始异常(可能就会发生方案 2 中的情况)。为了避免这种情况,请使用已捕获的异常设置包装异常的 InnerException 属性,通常可以通过构造函数进行分配,除非这样做会公开不得在调用链前端公开的专用数据。这样一来,原始堆栈跟踪仍可用。 如果您不设置内部异常,但仍在 throw 语句(引发异常)后面指定异常实例,则异常实例上会设置位置堆栈跟踪。即使您重新引发之前捕获的异常(已设置堆栈跟踪),系统也会进行重置。 第二种选择是在捕获异常时,确定您实际上是否无法适当处理异常。在这种情况下,您需要重新引发完全相同的异常,并将它发送给调用链前端的下一个处理程序。图 1 的 InvalidOperationException catch 块展示的就是这种情况。throw 语句没有确定要引发的异常(完全依靠自身引发),即使异常实例(异常)出现在可以重新引发的 catch 块范围内,也是如此。引发特定的异常会将所有堆栈信息更新为匹配新的引发位置。结果就是,所有指明调用站点(即异常的最初发生位置)的堆栈信息都会丢失,这会导致问题更加难以诊断。在确定 catch 块无法充分处理异常后,应使用空的 throw 语句重新引发异常。 无论您是要重新引发相同的异常,还是要包装异常,常规指南是避免在调用堆栈的下端报告或记录异常。换言之,不要每次捕获和重新引发异常都进行记录。这样做会在日志文件中造成不必要的混乱,并且也不会增加价值,因为每次记录的内容都相同。此外,异常还包含引发异常时的堆栈跟踪数据,所以无需每次都进行记录。请务必记录处理的异常,或者在不处理的情况下,在关闭进程之前,对异常进行记录。 ## 在不替换堆栈信息的情况下引发现有异常 C# 5.0 中新增了一种机制,可以在不丢失原始异常中的堆栈跟踪信息的情况下,引发之前已引发的异常。这样,您便可以重新引发异常(例如,从 catch 块外部引发),因此无需使用空的 throw。尽管需要这样做的情况很少,但有时在程序执行移至 catch 块外部之前,异常可能已包装或保存。例如,多线程代码可能使用 AggregateException 包装异常。.NET Framework 4.5 提供了专门用于处理这种情况的 System.Runtime.ExceptionServices.ExceptionDispatchInfo 类,它是通过使用静态 Capture 和实例 Throw 方法。图 2 展示了如何在不重置堆栈跟踪信息或不使用空的 throw 语句的情况下,重新引发异常。 图 2:使用 ExceptionDispatchInfo 重新引发异常 ~~~ using System using System.Runtime.ExceptionServices; using System.Threading.Tasks; Task task = WriteWebRequestSizeAsync(url); try {   while (!task.Wait(100)) {     Console.Write(".");   } } catch(AggregateException exception) {   exception = exception.Flatten();   ExceptionDispatchInfo.Capture(     exception.InnerException).Throw(); } ~~~ 借助 ExeptionDispatchInfo.Throw 方法,编译器不会将它看作 return 语句,就像是对正常的 throw 语句一样。例如,如果方法签名返回了值,但使用 ExceptionDispatchInfo.Throw 没有从代码路径返回任何值,则编译器会发出错误来指明没有值返回。有时,开发者可能不得不遵循含 return 语句的 ExceptionDispatchInfo.Throw,即使在运行时此类语句从不执行,而是会引发异常,也是如此。 ## 在 C# 6.0 中捕获异常 常规的异常处理指南是避免捕获您无法完全处理的异常。然而,由于 C# 6.0 之前的捕获表达式只能按异常类型进行筛选,因此在检查异常之前,catch 块必须是异常的处理程序,才能够在堆栈展开之前,在 catch 块处检查异常数据和上下文。可惜的是,在决定不处理异常后,编写代码以便相同上下文内的不同 catch 块能够处理异常是一项很繁琐的做法。此外,重新引发相同的异常会导致不得不再次调用双步异常进程。此进程涉及的第一步是在调用链前端提供异常,直至发现可处理异常的对象;涉及的第二步是为在异常和 catch 位置之间的每个框架展开调用堆栈。 引发异常后,与其因为进一步检查异常后发现无法充分处理异常,而在 catch 块处展开调用堆栈,只是为了重新引发异常,不要一开始就捕获异常明显是更可取的做法。对于 C# 6.0 及更高版本,catch 块可以使用额外的条件表达式。C# 6.0 支持条件子句,不再限制 catch 块是否只能根据异常类型进行匹配。借助 when 子句,您可以提供布尔表达式进一步筛选 catch 块,仅在条件为 true 时处理异常。图 1 中的 System.Web.HttpException 块通过相等比较运算符展示了这一功能。 使用异常条件的有趣结果是,当有异常条件时,编译器不会强制 catch 块按继承链中的顺序显示。例如,附带异常条件的 System.ArgumentException 类型 catch 现在可以显示在更具体的 System.ArgumentNullException 类型之前,即使后者源自前者,也是如此。这一点非常重要,因为这样您便可以编写与常规异常类型(后面是更具体的异常类型,带有或不带异常条件)配对的具体异常条件。运行时行为仍然与早期版本的 C# 保持一致;异常由首个匹配的 catch 块捕获。增加的复杂性仅仅是,catch 块是否匹配由类型和异常条件的组合决定,并且编译器只会强制实施与不带异常条件的 catch 块相关的顺序。例如,带有异常条件的 catch(System.Exception) 可以显示在带有或不带异常条件的 catch(System.Argument­Exception) 之前。然而,在不带异常条件的异常类型的 catch 显示后,不可能再出现更具体的异常 catch 块(如 catch(System.ArgumentNullException)),无论其是否带有异常条件。这样一来,程序员可以“灵活地”对可能乱序的异常条件进行编码,早期的异常条件可以捕获为后面的异常条件而设的异常,甚至可以呈现无意中无法访问的后期异常。最终,catch 块的顺序与 if-else 语句的顺序相似。在条件符合后,系统会忽略其他所有 catch 块。然而,与 if-else 语句中的条件不同的是,所有的 catch 块都必须包含异常类型检查。 ## 更新后的异常处理指南 虽然图 1 中的比较运算符示例非常容易,但异常条件并不只是简单而已。例如,您可以进行方法调用来验证条件。唯一的要求是表达式必须是谓词,可以返回布尔值。换言之,您基本上可以在 catch 异常调用链内部执行所需的任何代码。这样一来,您就有机会再也不捕获和重新引发相同的异常;从根本上讲,您可以在捕获异常前,充分地缩小上下文的范围,这样就可以仅在这样做有效时才捕获异常。因此,避免捕获您无法完全处理的异常这一指南就可以真正落实。实际上,任何有关空的 throw 语句的条件检查都可以用代码进行标记,并且是可以避免的。请考虑添加异常条件,支持使用空的 throw 语句,在进程终止前保持可变的状态除外。 也就是说,开发者应该将条件子句限制为只检查上下文。这一点非常重要,因为如果条件表达式本身引发异常,则新的异常会遭到忽略,并且条件会被视为 false。因此,您应该避免在异常条件表达式中引发异常。 ## 常规 catch 块 C# 要求代码引发的所有对象都必须源自 System.Exception。然而,此要求并不通用于所有语言。例如,C/C++ 允许引发任何对象类型,包括不是源自 System.Exception 的托管异常或基元类型(如整数或字符串)。对于 C# 2.0 及更高版本,所有异常都会作为源自 System.Exception 的异常传播到 C# 程序集中,无论异常是否源自 System.Exception。结果就是,System.Exception catch 块会捕获所有未被之前的 catch 块捕获的“合理处理”异常。然而,在 C# 1.0 之前,如果通过方法调用(驻留在程序集中,而不是在 C# 中编写)引发非源自 System.Exception 的异常,则 catch(System.Exception) 块不会捕获异常。因此,C# 也支持行为现在与 catch(System.Exception exception) 块完全相同的常规 catch 块 (catch{ }),除非没有类型或变量名称。此类块的缺点就是,没有可访问的异常实例,因此没有办法了解相应的行动措施。甚至无法记录异常或确定并不多见的情形(即此类异常无关紧要)。 在实践中,catch(System.Exception) 块和常规 catch 块(本文通常称为 catch System.Exception 块)都是可以避免的,只需在关闭进程前记录异常即可,“处理”异常的幌子除外。遵循只捕获您可以处理的异常这一基本原则,而编写程序员声明的代码似乎很冒失(此 catch 可以处理所有可能引发的异常)。首先,登记所有异常(特别是在 Main 主体中,其中执行代码的量是最多的,而且上下文的量似乎是最少的)的工作量似乎非常巨大,最简单的程序除外。其次,有许多可能意外引发的异常。 在 C# 4.0 之前,程序通常无法恢复第三组的损坏状态异常。然而,对于 C# 4.0 及更高版本,这个组就不太受到关注,因为 catch System.Exception 块(或常规 catch 块)实际上不会捕获此类异常(就技术而言,您可以使用 System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions 修饰方法,这样即使这些异常被捕获,您可以充分解决此类异常的可能性也极低。有关详细信息,请访问[bit.ly/1FgeCU6](http://bit.ly/1FgeCU6))。 有关损坏状态异常需要注意的一个技术问题是,只有当异常是由运行时引发时,才会跳过 catch System.Exception 块。实际上,显式引发的损坏状态异常(如 System.StackOverflowException 或其他 System.SystemException)会被捕获。不过,引发此类异常极具误导性,获得支持的原因仅限向后兼容性。如今,指南是不引发任何损坏状态异常(包括 System.StackOverflowException、System.SystemException、System.OutOfMemoryException、System.Runtime.Interop­Services.COMException、System.Runtime.InteropServices.SEH­Exception 和 System.ExecutionEngineException)。 总之,请避免使用 catch System.Exception 块,除非是要使用一些清理代码处理异常,并在重新引发或顺畅地关闭应用程序之前,对异常进行记录。例如,如果 catch 块可以在关闭应用程序或重新引发异常之前,成功保存任意可变数据(不一定能被假设,因为内容很可能已损坏)。当遇到因为继续执行不安全而应终止应用程序的情况时,代码应调用 System.Environment.FailFast 方法。请避免使用 System.Exception 和常规 catch 块,除非在关闭应用程序前,顺畅地记录异常。 ## 总结 在本文中,我介绍了更新后的异常处理指南(与捕获异常有关),主要是由于过去几个版本中的 C# 和 .NET Framework 改进才需要更新的。尽管有一些新的指南,但许多指南仍像以前一样明确可靠。下面介绍了异常捕获指南的摘要: * 避免捕获无法完全处理的异常。 * 避免隐藏(放弃)未完全处理的异常。 * 务必使用 throw 重新引发异常;而不是在 catch 块内引发 。 * 务必使用已捕获的异常设置包装异常的 InnerException 属性,除非这样做会公开专用数据。 * 考虑使用异常条件,支持在捕获无法处理的异常后,重新引发异常。 * 避免通过异常条件表达式引发异常。 * 谨慎重新引发其他异常。 * 尽量少使用 System.Exception 和常规 catch 块,除非在关闭应用程序前,对异常进行记录。 * 避免在调用堆栈的下端报告或记录异常。 若要回顾这些指南的详细信息,请转至 [itl.tc/ExceptionGuidelinesForCSharp](http://itl.tc/ExceptionGuidelinesForCSharp)。在未来的专栏中,我打算更加关注异常引发指南。一言以蔽之,引发异常的主题就是: 异常的预期接收方是程序员,而不是程序的最终用户。 请注意,本文的大部分内容摘取自我的下一版书籍“必备 C# 6.0(第 5 版)”(Addison-Wesley,2015 年)。有关此书的内容,请访问 [itl.tc/EssentialCSharp](http://itl.tc/EssentialCSharp)。 * * * Mark Michaelis *是 IntelliTect 的创始人,担任首席技术架构师和培训师。在近二十年的时间里,他一直是 Microsoft MVP,并且自 2007 年以来一直担任 Microsoft 区域总监。Michaelis 还是多个 Microsoft 软件设计评审团队(包括 C#、Microsoft Azure、SharePoint 和 Visual Studio ALM)的成员。他在开发者会议上发表了演讲,并撰写了大量书籍,包括最新的“必备 C# 6.0(第 5 版)”。 可通过他的 Facebook [facebook.com/Mark.Michaelis](http://facebook.com/Mark.Michaelis)、博客 [IntelliTect.com/Mark](http://intellitect.com/Mark)、Twitter [@markmichaelis](https://twitter.com/@markmichaelis) 或电子邮件 [mark@IntelliTect.com](mailto:mark@IntelliTect.com) 与他取得联系。*