【51CTO.com原创稿件】在 .NET 中异常处理是一个庞大的模块,专门用来处理程序中的已知可捕获异常,这篇文章我将详细讲解异常处理的细节性的东西,其中包含了异常处理类型、自定义异常处理、多 catch 的异常处理以及异常处理的依赖。
一、异常处理类型
C# 允许我们编写的代码抛出从 System.Exception 派生的任何异常类型(这其中包括了间接派生和直接派生)。例如下面的代码段:
- public class Demo
- {
- public int StringToNumber(string para)
- {
- string[] numberArray={"零","一","二","三"};
- int number = Array.IndexOf(numberArray,(para??throw new ArgumentNullException(nameof(para))));
- if (number <0)
- {
- throw new ArgumentException("参数值无法转换为数字",nameof(para));
- }
- return number;
- }
- }
上述代码使用了 throw 关键字抛出了异常,并且使用了特定的异常类型说明了发生异常的背景。在代码中我们只用到了 C# 7.0 的新特性 throw 表达式 ,在 para 为 null 时会抛出 ArgumentNullException 异常,当 number 的值小于 0 的时候我们并没有抛出 Exception 类型的异常,而是抛出了更能明确告知异常原因的 ArgumentException 类型的异常。我们从代码中可以看到,当 para 参数为 null 时抛出的是 ArgumentNullException 类型的异常而不是 NullReferenceException 类型的异常。对于这两个类型的异常好多开发人员其实并不清楚它俩的区别。其实它俩的区别还是很简单的, ArgumentNullException 是在错误的传递了空值时抛出的,如果传递的是 非空的无效参数 则必须使用 ArgumentException 或者 ArgumentOutOfRangeException 。如果是底层运行时发现对象的值为空的时候才会抛出 NullReferenceException 类型的异常,这个异常一般来说开发人员不能随意抛出,我们应该先判断参数是否为空之后再使用参数,如果为空就抛出 ArgumentNullException 异常。
除了 NullReferenceException 异常外,还有五种派生自 System.SystemException 的异常不能自己抛出,只能有运行时抛出,它们分别是 System.StackOverflowException 、 System.OutOfMemoryException 、System.Runtime.InteropServices.COMException 、System.ExecutionEngineException 和 System.Runtime.InteropServices.SEHException 。同样,开发人员尽量不在程序代码中抛出 Exception 和 ApplicationException 异常,因为它们所反映出来的异常过于笼统,没法为异常提供明确的信息。
在实际项目开发中有可能会遇到代码执行到一定程度就会出现不安全或者无法恢复的状态,这时代码大多数情况下不会出现异常,因此我们在这种情况下就必须调用 System.Environemnt.FailFast 方法终止程序,这个方法会向实践日志写入一条消息之后马上终止程序进程。 前面的代码中我们还使用了 nameof 操作符,使用这个操作符首先是因为我们可以利用重构工具方便的自动更改标识符,另外如果参数名发生了变化我们能及时收到编译错误。
针对这一节的内容我来做一个简单的总结:
-
成员接收到错误的参数时应当抛出 ArgumentException 异常或者它的子类型异常;
-
在抛出 ArgumentException 异常或者子类型异常时必须设置 ParamName 属性,也就是 nameof;
-
抛出的异常必须能明确表示异常的问题;
-
避免在意外获得空值时抛出 NullReferenceException 异常;
-
不要抛出 System.SystemException 及其派生的异常;
-
不要抛出 Exception 和 ApplicationException 异常;
-
如果程序出现不安全因素时必须调用 System.Environemnt.FailFast 方法来终止程序的运行;
-
要向传给参数异常类型的 ParamName 使用 nameof 操作符
Tip:参数异常类型包括 ArgumentNullException 、ArgumentNullException 、ArgumentOutOfRangeException
二、捕获异常处理
捕获异常处理这一节比较简单,主要需要了解并掌握的是多 catch 块和异常类型的顺序问题以及 when 子句。
-
多 catch 块 多个 catch 块在 C# 中是比较常见的,我们前面一节说过抛出的异常必须能明确表示异常的问题,因此我们可以利用多 catch 块解决一个代码段中有可能出现的多种异常的情况,每个 catch 块针对一种异常情况进行处理。我们来看一个简单的代码段:
- void OpenFile(string filePath)
- {
- try
- {
- //more code
- }
- catch(ArgumentNullException ex)
- {
- //more code
- }
- catch(DirectoryNotFoundException ex)
- {
- //more code
- }
- catch(FileNotFoundException ex)
- {
- //more code
- }
- catch(IOException ex)
- {
- //more code
- }
- catch(Exception ex)
- {
- //more code
- }
-
- }
上述代码中我们一共定义了 5 个 catch 块,当发生异常时会被对应的 catch 块拦截并处理。这一小节就这么简单,主要是多 catch 块的使用,下一小节我将讲解 catch 块最重要的内容。
-
异常类型的顺序 异常类型的顺序是很多初学者甚至是部分多年的老程序员会犯的问题,我们从前面的代码中也可以看到 Exception 异常位于最后的位置, IOException 位于倒数第二的位置,这是因为 Exception 异常是所有异常的父类,所有的异常都是直接或间接派生自它,而 IOException 又是 DirectoryNotFoundException 和 FileNotFoundException 的父类。根据异常匹配的顺序,C# 会始终匹配第一个符合要求的异常,如果将父类异常放在子类异常的前面,那么再代码出现异常的时候回直接匹配父类异常的 catch ,不再去匹配后面的子类异常 catch 。
Tip:不管在什么情况下都必须把 Exception 异常作为最后的 catch ,当程序中出现的异常没有匹配任何 catch 块时可以被 Exception catch 块拦截并处理
-
when 子句 从 C# 6.0 开始, catch 块支持条件表达式,这样我们可以不根据异常类型来匹配程序中出现的异常。When 子句返回的时一个布尔值,当返回 true 时 catch 块才会执行。我们来看一个使用 when 子句的例子:
- try
- {
- //more code
- }
- catch(Win32Exception ex) when (ex.NativeErrorCode==42)
- {
- //more code
- }
不过我们也可以在 catch 块中使用 if 语句执行上面的条件检查,但是这样做的话整个 catch 块的逻辑就变为先成为异常处理程序,再进行条件判断,进而造成了在不满足条件的情况下无法去执行别的符合要求的 catch 块。如果使用了 when 子句程序就可以先检查条件,在决定是否执行 catch 块。但是 when 自己也有需要注意的地方,如果 when 子句中抛出了异常,那么这新的异常就会被忽略并且整个 when 子句返回值将变为 false 。
-
重新抛出异常 这里在简单说一下异常的重新抛出,有些开发人员喜欢在 catch 块中写这段语句
throw ex
。这段语句存在一个致命的问题,在 catch 块中这么写将会抛出一个新的异常,那么将会造成所有的栈信息被更新进而丢失最初的栈信息造成难以定位问题。因此 C# 开发团队设计出了可以不指定具体异常的方法,就是在 catch 块中直接使用 throw 语句。这样我们就可以判断当前 catch 块是否可以处理这个异常,如果不能就讲原始栈信息抛出去。
三、常规 catch
C# 要求代码抛出的任何对象都必须从 Exception 派生,从 C#2.0 开始,不管是不是从 Exception 派生的所有异常在进入程序集之后,都会被打包成从 Exception 派生的。结果是捕捉 Exception 的 catch 块现在可捕捉前面的块不能捕捉的所有异常。
-
简述 C# 还支持常规 catch 块,即 catch{} ,它的行为和 catch(Exception ex) 块的行为一样,唯一不同的是它不具备类型名和变量名。同样它也必须位于所有 catch 块的末尾。在代码中如果同时存在常规 catch 块和 catch(Exception ex) 块编译器就会显示警告,因为程序会永远匹配 catch(Exception ex) 块而不去匹配常规 catch 块。之所以 C# 中出现常规 catch 块的原因是因为如果程序中存在调用的别的语言开发的程序集,并且该程序集在使用过程中抛出了异常,那么这个异常是不会被 catch(Exception ex) 块所拦截,而是进入到未处理状态,为了避免这个问题 c# 就推出了常规 catch 块。
Tip:虽然常规 catch 块具有强大的功能,但是它依然存在一个问题。它不具备一个可供访问的异常实例,所以无法确定异常是无害的还是有害于程序的。
-
原理 常规 catch 所生成的 CIL 代码是 catch(object),这就说明不管抛出什么类型它都可以捕获得到。虽然生成的 CIL 代码是 catch(object),但是我们不能在代码中直接这么写。常规 catch 块无法捕获不是派生自 Exception 的异常,因此 C# 在设计的时候将所有来自其他语言的异常都统一设置为 System.Runtime.InteropServices.SEHException 异常,因此常规 catch 块既能捕获继承自 Exception 的异常,又能捕获非托管代码的异常。
四、规范
异常处理规范不是由微软所规定的,而是开发人员在千千万万的项目中总结出来的,下面我们来看一下。
-
只捕获可以处理的异常 通常我们只处理当前代码可以处理的异常,而不能处理的异常将会抛出去,让栈中层级高的调用者去处理。
-
不隐藏无法处理的异常 这个问题会发生在刚刚从事开发的人员身上,他们会捕获所有异常即不处理也不抛出。这种情况下如果系统出现问题那么将逃过检测。
-
少用 Exception 和常规 catch 块 所有的异常都是继承自 Exception ,因此使用 Exception 来处理异常并不是一个最优方法,而且某些异常需要马上关闭程序进程。
-
避免在调用栈较低的位置报告或记录异常 大部分调用栈较低的位置无法完整处理异常,所以只能抛出异常,并且如果在这些位置记录异常并且再抛出异常会造成异常的重复记录。
-
无法处理异常时,因使用 throw 而不是 throw ex 抛出一个新的异常会造成栈追踪重置为重新抛出的位置,而不是重用原始抛出位置。因此如果不需要重新抛出不同的异常类型或者不是想故意隐藏原始调用栈,就应使用 throw ,允许相同的异常在调用栈中向上传播。
-
避免在 catch 块中重新抛出异常 如果在开发中发现捕获的异常不能完整或恰当的处理,并且需要抛出异常那么我们就需要重新优化捕获异常的条件。
-
避免在 when 子句中抛出异常 when 子句抛出异常会造成表达式的结果变为 false,进而不能运行 catch 块。
-
避免以后 when 子句条件改变 这种情况常见于异常会因本地化而改变,那么这是我们将不得不改变 when 子句的条件。
五、自定义异常处理
一般来说抛出异常时我们应该使用 c# 为我们提供的异常类型。但是某些情况下我们还需自定义异常,例如我们编写的 API 是由其他语言开发人员调用的,这时我们就不能抛出自己所使用的语言的异常,应该自定义异常让调用者清晰明了的知道发什么么错误。
自定义异常一般都是从 Exception 或者其他异常类派生出来,这是唯一的要求。自定义异常还必须遵循如下三点要求:
-
异常名称以 Exception 结尾;
-
必须包含无参构造函数、包含唯一一个参数类型为 string 的构造函数和同时获取一个字符串以及一个内部异常作为参数的构造函数;
-
集成层次不能大于 5 层。
部分程序要求异常可以序列化,这时我们可以使用可序列化异常。我们只需要在自定义异常类型上加上 System.SerializableAttribute特性 或 实现ISerializable ,然后添加一个构造函数来获取 SerializationInfo 和 StreamingContext 。这里需要注意的是如果你使用的是 .NET Core 2.0 以下版本那么将无法使用可序列化异常。
六、总结
作者简介
朱钢,笔名喵叔,国内某技术博客认证专家,.NET高级开发工程师,7年一线开发经验,参与过电子政务系统和AI客服系统的开发,以及互联网招聘网站的架构设计,目前就职于一家初创公司,从事企业级安全监控系统的开发。
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】