年前最后一篇技术博客了,由于近期的上线,自学进度严重滞后,年后还是得拾起来啊,闲言少叙,书归正传,经过艰苦的学习奋斗,终于来到了中级部分知识的最后一篇内容《异常处理》,其实之前学习Java的时候就了解过,在本质论系列的第五章也了解过,但是始终没有明确它的定位,它是干嘛的,什么时候用。综合日常的实战,我可以这么定义异常处理:异常处理通常和日志紧密配合,在可能出现问题的地方捕获系统抛出的异常然后打出对应的日志,方便系统稳定运行和程序员排查问题,搞明白了基础定位和作用之后,再来详细看看异常体系怎么运作。
多异常类型
程序不是抛出System.Exception,而是抛出更合适的ArgumentException。
抛出具体的异常类型
具体的异常类型本身指出什么地方出错(参数异常),并包含了特殊的参数来指出具体是哪一个参数出错:
public sealed class TextNumberParser { public static int Parse(string textDigit) { string[] digitTexts = { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" }; #if !PRECSHARP7 int result = Array.IndexOf( digitTexts, // Leveraging C# 2.0’s null coelesce operator (textDigit ?? // Leveraging C# 7.0’s throw expression throw new ArgumentNullException(nameof(textDigit)) ).ToLower()); #else if(textDigit == null) throw new ArgumentNullException(nameof(textDigit)) int result = Array.IndexOf( digitTexts, textDigit?.ToLower()); #endif if (result < 0) { #if !PRECSHARP6 throw new ArgumentException( "The argument did not represent a digit", nameof(textDigit)); #else throw new ArgumentException( "The argument did not represent a digit", "textDigit"); #endif } return result; } }
以上部分的代码就是抛出了一个参数为null的处理方案,更具体的方式有:
- ArgumentNullException和NullReferenceException这两个异常很相似。前者应在错误传递了空值时抛出。空值是无效参数的特例。非空的无效参数则应抛出ArgumentException或ArgumentOutOfRangeException。一般只有在底层“运行时”解引用null值(想调用对象的成员,但发现对象的值为空)时才抛出NullReferenceException。
- 开发人员不要自己抛出NullReferenceException。相反,应在访问参数前检查它们是否为空,为空就抛出ArgumentNullException,这样能提供更具体的上下文信息,比如参数名等。如果在实参为空时可以无害地继续,请一定使用C#6.0的空条件操作符(?.)来避免“运行时”抛出NullReferenceException。
也就是说,如果预期参数要求必须不为null否则程序无法进行下去了,则通过ArgumentNullException来给使用者抛出异常,如果为null程序也能继续,咱就别抛异常了,通过操作符来控制参数为null时候的代码走向,避免抛出不想要的如NullReferenceException的异常。总之就是看你想要的是什么了。
异常抛出规范
不要抛出System.Exception或System.ApplicationException这种过于宽泛的异常,这种异常没有任何有用信息,同时有几个直接或间接从System.SystemException派生的异常仅供“运行时”抛出,其中包括:
System.StackOverflowException【堆栈溢出】 System.OutOfMemoryException【内存溢出】 System.Runtime.InteropServices.COMException【服务器意外终止】 System.ExecutionEngineException【执行引擎异常】 System.Runtime.InteropServices.SEHException【服务器意外终止】
这些异常时万万不能自己抛出的,所以有些这样的规范:
- 要在向成员传递了错误参数时抛出ArgumentException或者它的某个子类型。抛出尽可能具体的异常(例如ArgumentNullException),并且要在抛出ArgumentException或者它的某个子类时设置ParamName属性,便于定位问题参数
- 要为传给参数异常类型的paramName实参使用nameof操作符。接收这种实参的异常类型包括ArgumentException,ArgumentOutOfRangeException和ArgumentNullException。
- 要抛出最能说明问题的异常(最具体或者派生得最远的异常)。
- 不要抛出NullReferenceException。相反,在值意外为空时抛出ArgumentNullException。
- 不要抛出System.SystemException或者它的派生类型。
- 不要抛出System.Exception或者System.ApplicationException。
- 考虑在程序继续执行会变得不安全时调用System.Environment.FailFast()来终止进程。
简单点总结就是,要抛出具体的能让你定位问题的异常,同时如果捕获了异常让系统再走下去也依然有问题,那么尽早终止就好了。
新特性
以上的代码部分还有两个特性,一个是当参数名发生变更也能获取硬编码的nameof,一个是异常开始支持表达式类型
- C#6.0开始支持使用nameof来获取变量的字面值,通过IDE修改参数,nameof也会打出最新的参数硬编码
- C#7.0开始支持表达式异常,如果textDigit 为null则抛出异常,而在7.0之前必须使用if分支来进行判断:
result = Array.IndexOf( digitTexts, (textDigit ?? throw new ArgumentNullException(nameof(textDigit))));
捕捉异常
异常发生时,会跳转到与异常类型最匹配的catch块执行,匹配度由继承链决定,通常是继承链最远端来处理,例如,抛出的InvalidOperationException
异常就会由 catch(InvalidOperationException exception)
捕获。
public sealed class Program { public static void Main(string[] args) { try { throw new Win32Exception(42); //TextNumberParser.Parse("negative forty-two"); // ... throw new InvalidOperationException( "Arbitrary exception"); // ... } //条件表达式 catch(Win32Exception exception) when(args.Length == exception.NativeErrorCode) { } catch(NullReferenceException exception) { // Handle NullReferenceException } catch(ArgumentException exception) { // Handle ArgumentException } catch(InvalidOperationException exception) { // Handle ApplicationException } catch(Exception exception) { // Handle Exception } finally { // Handle any cleanup code here as it runs // regardless of whether there is an exception } } }
有几点需要注意,也可以说是异常捕获的规范和新特性:
- C#6.0起,catch块支持一个额外的条件表达式。不是只根据异常类型来匹配,现在可以添加when子句来提供一个Boolean表达式。条件为true,catch块才处理异常。当然可以在catch主体中用if语句执行条件检查。但这样做会造成该catch块先成为异常的“处理程序”,再执行条件检查,造成难以在条件不满足时改为使用一个不同的catch块。而使用异常条件表达式,就可先检查程序状态(包括异常),而不需要先捕捉再重新抛出异常。
- 条件子句使用需谨慎: 如条件表达式本身抛出异常,新异常会被忽略,且条件被视为false。所以,要避免异常条件表达式抛出异常。
- catch块必须按从最具体到最常规的顺序排列以避免编译错误。例如,将catch(Exception…)块移到其他任何一种异常之前都会造成编译错误。因为之前的所有异常都直接或间接从System.Exception派生。
- catch块并非一定需要具名参数,例如catch(SystemException){…}。事实上,如下一节所述,最后一个catch甚至连类型参数都可以不要,这样的catch块叫做常规catch块
- 在catch里使用throw来重新抛出异常而不替换堆栈信息,throw后边不能跟任何内容。
还有包装异常这样的高级特性,不是很懂,暂时先不了解了,之后用的时候再学习下。
常规catch块
C#还支持常规catch块,即catch{},其行为和catch(System.Exception exception)块完全一致,只是没有类型名或变量名。此外,常规catch块必须是所有catch块的最后一个。
public static void Main() { try { // ... throw new InvalidOperationException( "Arbitrary exception"); // ... } catch (NullReferenceException ) { // Handle NullReferenceException } catch (ArgumentException ) { // Handle ArgumentException } catch (InvalidOperationException ) { // Handle ApplicationException } catch (Exception ) { // Handle Exception } // Disabled warning as this code is demonstrating the general catch block // following a catch(Exception ) block #pragma warning disable 1058 catch { // Any unhandled exception } finally { // Handle any cleanup code here as it runs // regardless of whether there is an exception } }
感觉这个功能就是兼容C#1.0中不属于System.Exception的,因为从C#2.0开始才支持所有异常包装为派生自System.Exception,所以在1.0时代用常规catch块来覆盖全部异常情况,从2.0时代,常规catch块就和catch(System.Exception exception)的行为一致了。当然其实在原理上,catch块捕获的是object类型,也就是万类始祖,所以当然可以捕获任何异常喽:在CIL层面它的实质就是:catch(object).
异常处理规范
异常处理的一些最佳实践规范,这里的调用栈较高的位置就是上层方法,越高越面向用户,调用栈高的位置捕获调用栈低的位置的异常。:
- 只捕捉能处理的异常。只应捕捉那些已知怎么处理的异常。其他异常类型应留给栈中较高的调用者去处理。
- 不要隐藏(bury)不能完全处理的异常。catch(System.Exception)和常规catch块大多数时候应放在调用栈中较高的位置,除非在块的末尾重新抛出异常。
- 尽量少用System.Exception和常规catch块
- 避免在调用栈较低的位置报告或记录异常。这样记录下来的异常信息不全,而且用户也看不懂,继续向上抛就好了。而且抛的时候使用throw;
- 在catch块中使用throw;而不是throw<异常对象>语句。只要不是重新抛出不同的异常类型,或者不是想故意隐藏原始调用栈,就应使用throw;语句,允许相同的异常在调用栈中向上传播。
- 想好异常条件来避免在catch块中重新抛出异常,如果发现会捕捉到不能恰当处理、所以需要重新抛出的异常,那么最好优化异常条件,从一开始就避免捕捉。
- 避免在异常条件表达式中抛出异常,避免以后可能变化的异常条件表达式
以上就是主要规范,还有一个特殊规范体系是重新抛异常的规范:
重新抛出异常
重新抛出异常的时候有两种情况,一种就是同一种异常类型,要保留原始堆栈信息,咱就一直throw;另一种情况是捕获到栈低位置的异常后不想抛这种,想换一种那么更改异常类型,这个时候又要区分,是否要保留原始堆栈信息,如果要保留,就要使用包装异常,不保留就直接截断。
public static void Main(string[] args) { string url = "http://www.IntelliTect.com"; if (args.Length > 0) { url = args[0]; } Console.Write(url); Task task = WriteWebRequestSizeAsync(url); try { while (!task.Wait(100)) { Console.Write("."); } } catch (AggregateException exception) { exception = exception.Flatten(); ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); } }
这里的ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
就是抛出包装异常。
重新抛出不同异常时要小心:在catch块中重新抛出不同的异常,不仅会重置抛出点,还会隐藏原始异常。要保留原始异常,需设置新异常的InnerException属性(该属性通常可通过构造函数来赋值)。只有以下情况才可重新抛出不同的异常。
- 更改异常类型可更好地澄清问题。例如,在对Logon(User user)的一个调用中,如遇到用户列表文件不能访问的情况,那么重新抛出一个不同的异常类型,要比传播System.IO.IOException更合适。
- 私有数据是原始异常的一部分。在上例中,如文件路径包含在原始的System.IO.IOException中,就会暴露敏感的系统信息,所以应该使用其他异常类型来包装它(当然前提是原始异常没有设置InnerException属性)
- 异常类型过于具体,以至于调用者不能恰当地处理。例如,不要抛出数据库系统的专有异常,而应抛出一个较常规的异常,避免在调用栈较高的位置写数据库的专有代码。
只有以上这三种情况的时候才不用throw;语句而是throw Exception来进行操作
自定义异常设计规范
如果框架的异常类型不能满足自身需求,可以使用自定义异常,其全部继承自System.Exception,设计规范如下:
- 所有异常都应该使用“Exception”后缀,彰显其用途。
- 所有异常通常都应包含以下三个构造函数:无参构造函数、获取一个string参数的构造函数以及同时获取一个字符串和一个内部异常作为参数的构造函数。另外,由于异常通常在抛出它们的那个语句中构造,所以还应允许其他任何异常数据成为构造函数的一部分。
在继承体系上也需要注意,避免使用深的继承层次结构(一般应小于5级)。
本篇博客主要介绍了异常的多种类型,如何抛出,以及如何捕获,特别介绍了常规catch块、nameof、异常条件表达式的新特性,以及异常的一些使用规范,2019封箱之作,以飨诸位!