必备 .NET - C# 异常处理

欢迎查看首个“必备.NET”专栏。您可以在其中了解 Microsoft .NET Framework 领域的所有最新动态,无论是 C# vNext 的最新进展(当前是 C# 7.0)、改进的 .NET 内部结构,还是 Roslyn 和 .NET 核心前端的最新动态(如转为开放源代码的 MSBuild)。

自 .NET 于 2000 年发布预览版以来,我一直在撰写和开发与 .NET 有关的内容。我撰写的大部分内容不仅限于新生事物,而是关于如何利用相应技术,并着眼于最佳做法。

我住在美国华盛顿州斯波坎市,我是 IntelliTect 高端咨询公司 (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 Systemusing 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)。

有关损坏状态异常需要注意的一个技术问题是,只有当异常是由运行时引发时,才会跳过 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。在未来的专栏中,我打算更加关注异常引发指南。一言以蔽之,引发异常的主题就是: 异常的预期接收方是程序员,而不是程序的最终用户。

请注意,本文的大部分内容摘取自我的下一版书籍“必备 C# 6.0(第 5 版)”(Addison-Wesley,2015 年)。有关此书的内容,请访问 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、博客 IntelliTect.com/Mark、Twitter @markmichaelis 或电子邮件 mark@IntelliTect.com 与他取得联系。

衷心感谢以下技术专家对本文的审阅: Kevin Bost、Jason Peterson 和 Mads Torgerson

原文发布于微信公众号 - 我为Net狂(dotNetCrazy)

原文发表时间:2015-12-10

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Java后端技术栈

关于Java代码优化的N条建议!

本文是作者:五月的仓颉 结合自己的工作和平时学习的体验重新谈一下为什么要进行代码优化。在修改之前,作者的说法是这样的:

1222
来自专栏Java学习网

Java面试题系列之基础部分(六)——每天学5个问题

Java基础部分学习的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语法,集合的语法,io的语法,虚拟机方面的语法,这些都是最基...

2625
来自专栏Crossin的编程教室

【编程课堂】有序字典 OrderedDict

编程课堂将和每周一坑一样,成为本教室公众号的一个长期固定栏目。每期讲解一个编程知识点,包括但不限于 Python 语法、模块介绍、编程小技巧等。用简短的篇幅,让...

3688
来自专栏gaoqin31

设计模式之 工厂模式

简单工厂模式 : 简单工厂模式是属于创建型的设计模式,又叫做静态工厂方法模式,但不属于23种GOF设计模式,简单工厂模式是由一个工厂决定创建哪一类产品的实例,简...

1555
来自专栏为数不多的Android技巧

ART深度探索开篇:从Method Hook谈起

Android上的热修复框架 AndFix 想必已经是耳熟能详,它的原理实际上很简单:方法替换——Java层的每一个方法在虚拟机实现里面都对应着一个ArtMet...

1981
来自专栏微信公众号:Java团长

10个最受欢迎的Java类

每一个Java程序员都有一份属于自己的Java类排名表。这个排名表没有严格的规定,也没有可遵循的规则,它完全取决于你参与的Java项目的工作。下面这些类,不用我...

1202
来自专栏向治洪

python 日期与时间

###python 日期与时间 (time,datetime包) [toc] #####概述 在应用程序的开发过程中,难免要跟日期、时间处理打交道。如:记录一个...

37210
来自专栏java一日一条

在Java中如何避免“!=null”式的判空语句?

我整天都是在跟Java打交道。我在Java开发中最常用的一段代码就是用object != null在使用对象之前判断是否为空。这么做是为了避免NullPoint...

921
来自专栏诸葛青云的专栏

学了指针没学动态内存一切都白搭!C语言基础教程之内存管理

本文将讲解 C 中的动态内存管理。C 语言为内存的分配和管理提供了几个函数。这些函数可以在<stdlib.h>头文件中找到。

1090
来自专栏Petrichor的专栏

tensorflow编程: Building Graphs

每次都必须要指定一个graph作为as_default,并只能在该graph中进行一切操作。

1453

扫码关注云+社区

领取腾讯云代金券