异常处理
C#使用throw关键字和try...catch...finally...语句对异常进行抛出和捕获处理,良好的异常处理机制能极大提升我们代码的健壮性。这篇笔记我们学习如何捕获和抛出异常,以及在C#中处理异常的一些常用做法和最佳实践。
捕获异常
下面例子我们捕获一个除以零异常。
int i = 1;
int j = 0;
try
{
int k = i / j;
}
catch (DivideByZeroException ex)
{
Console.WriteLine("\nMessage ---\n{0}", ex.Message);
Console.WriteLine("\nHelpLink ---\n{0}", ex.HelpLink);
Console.WriteLine("\nSource ---\n{0}", ex.Source);
Console.WriteLine("\nStackTrace ---\n{0}", ex.StackTrace);
Console.WriteLine("\nTargetSite ---\n{0}", ex.TargetSite);
}
finally
{
Console.WriteLine("finally执行");
}
注意异常对象ex的用法,ex中有一些定义好的属性,用来存储异常的各种信息,包括异常消息、错误栈等,我们可以按需取用。
try...catch...finally
完整的异常捕获结构由try、catch和finally三部分组成:
try块:包含可能抛出异常的代码。catch块:捕获并处理指定类型的异常,可以有0个或多个。finally块:无论是否发生异常都会执行,通常用于释放资源等收尾工作。
finally块的一个关键特性是即使try或catch中执行了return,finally也依然会被执行,因此它非常适合放置必须执行的清理逻辑。不过对于实现了IDisposable的对象,更推荐使用using语句(详见前置章节),它本质上是try...finally的语法糖。
捕获多个异常
try后面可以跟多个catch块,分别处理不同类型的异常。此时要特别注意顺序,写法上我们必须从具体异常到通用异常排列,否则编译器会报错“后面的catch永远无法到达”。
try
{
// 可能抛出多种异常的代码
}
catch (DivideByZeroException ex)
{
// 处理具体的除零异常
}
catch (FormatException ex)
{
// 处理具体的格式异常
}
catch (Exception ex)
{
// 通用异常兜底
}
如果只关心捕获而不需要使用异常对象,catch后面也可以不写变量。
catch
{
Console.WriteLine("发生了一个未知异常");
}
抛出异常
使用throw关键字可以主动抛出一个异常对象。
throw new BusinessException("业务逻辑异常");
throw和throw ex的区别
实际开发时我们经常遇到需要在catch块中重新抛出异常的情况,C#语言中,推荐使用throw;来实现这一需求。语法上,throw ex;虽然也能编译通过,但throw;和throw ex;有一个非常重要且容易忽略的区别,throw ex;会重置堆栈跟踪,丢失异常最初抛出的位置。
try
{
// ...
}
catch (Exception ex)
{
// 错误写法:会重置堆栈跟踪,丢失异常最初抛出的位置
throw ex;
}
try
{
// ...
}
catch (Exception ex)
{
// 正确写法:保留原始堆栈跟踪信息
throw;
}
throw ex;会把堆栈跟踪的起点重置到当前这一行,导致排查问题时看不到异常真正的源头,而单独使用throw;则保留原始堆栈跟踪,因此,如果只是想把异常继续向上抛出,一定要使用throw;。
如果需要在抛出时补充额外的上下文信息,则应该把原异常作为内部异常(inner exception)传给新异常,而不是丢弃它。
try
{
// 读取配置文件
}
catch (IOException ex)
{
// 包装为业务异常,同时保留原始异常
throw new BusinessException("读取配置文件失败", ex);
}
throw表达式(C# 7.0)
C# 7.0起,throw可以作为表达式使用,从而方便地写出“参数为空就抛异常”这类代码。
public void SetName(string name)
{
_name = name ?? throw new ArgumentNullException(nameof(name));
}
异常筛选器
C# 6.0引入了异常筛选器语法,可以在catch后面使用when关键字附加一个条件,只有条件成立时才进入该catch块。
try
{
// 发起HTTP请求
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// 只处理404的情况
}
使用when的好处是条件不满足时异常会继续向上传播,且筛选器是在异常堆栈展开之前执行的,比“先捕获再判断、不匹配再抛出”的写法更高效,也不会破坏原始堆栈跟踪。
使用自定义异常
C#中,所有异常派生自Exception类,其下有两个子类SystemException和ApplicationException,SystemException仅供CLR和.NET内部使用,我们不要自行继承它;ApplicationException是.NET早期设计时,原本打算用它作为所有应用程序级异常基类的,但后来在实践中发现这么做毫无区分价值,因此已废弃。我们自定义异常的标准实现模式是直接继承Exception。
下面例子代码中,我们自定义了一个异常类,并提供了3个标准的构造函数。
class BusinessException : Exception
{
public BusinessException()
: base() { }
public BusinessException(string message)
: base(message) { }
public BusinessException(string message, Exception innerException)
: base(message, innerException) { }
}
下面代码我们抛出这个自定义异常,并使用try...catch...捕获。
try
{
throw new BusinessException("业务逻辑异常");
}
catch (BusinessException ex)
{
Console.WriteLine("\nMessage ---\n{0}", ex.Message);
Console.WriteLine("\nHelpLink ---\n{0}", ex.HelpLink);
Console.WriteLine("\nSource ---\n{0}", ex.Source);
Console.WriteLine("\nStackTrace ---\n{0}", ex.StackTrace);
Console.WriteLine("\nTargetSite ---\n{0}", ex.TargetSite);
}
自定义异常通常以Exception作为类名后缀,如BusinessException,并且应当提供上述三个标准构造函数,尤其是接收message和innerException的构造函数,以便在包装底层异常时保留原始信息。
常见的内置异常
.NET定义了大量内置异常类型,下面列出了其中比较常见的一些异常类,了解它们有助于我们选择正确的异常类型抛出或捕获。
| 异常类型 | 说明 |
|---|---|
ArgumentNullException |
参数为null |
ArgumentException |
参数无效 |
ArgumentOutOfRangeException |
参数超出有效范围 |
InvalidOperationException |
当前对象状态不允许该操作 |
NullReferenceException |
访问了null对象的成员 |
IndexOutOfRangeException |
数组索引越界 |
KeyNotFoundException |
字典中不存在指定的键 |
FormatException |
数据格式错误(如Parse转换失败) |
DivideByZeroException |
除以零 |
OverflowException |
数值运算溢出 |
NotImplementedException |
功能尚未实现 |
ObjectDisposedException |
访问了已释放的对象 |
其中ArgumentNullException、ArgumentException这类用于参数校验的异常。实际开发中,Fail Fast是一种符合直觉的良好编程实践,在方法入口处主动抛出异常可以让问题尽早暴露,也便于调用方定位错误。此外,在.NET 6还提供了静态方法简化这类校验。
public void SetName(string name)
{
ArgumentNullException.ThrowIfNull(name);
// ...
}
异常处理的最佳实践
- 不要用异常控制正常的程序流程:异常在抛出和捕获时开销较大,可预期的分支应当使用
if等普通判断。 - 捕获具体的异常类型:避免一律使用
catch (Exception),以免掩盖掉本不应处理的问题。 - 不要吞异常:
catch块中什么都不做(或只Console.WriteLine)会让问题难以排查,至少应记录日志。 - 不要
throw ex;:需要重新抛出时使用throw;,需要包装时使用内部异常。 - 尽早抛出、尽早失败:参数不合法的异常应在方法入口处抛出。
- 保证资源释放:优先使用
using,必要时使用finally。 - 抛出合适的异常类型:不要所有错误都抛
Exception。
C#异常机制和Java的对比
和Java相比,C#没有受检查异常(checked exception),因此也没有throws关键字,个人认为这是个糟糕的设计,因为编译器不能帮助我们检查某些必要的异常处理是否编写好了。而Java中,一些老旧的库(如JDBC)过度使用了受检查异常,这是库编写的问题,而不是受检查异常机制设计的不好。当然,个人观点仅供参考。