Menu

C# try catch:用 catch、when 和 finally 处理异常

try、catch 和 finally 是 C# 处理异常的方式。学习异常如何沿调用栈向上传递、如何按正确的顺序捕获特定的异常类型、用 when 过滤、在 finally 中清理、用 throw; 重新抛出而不丢失堆栈跟踪,以及哪些异常不该捕获。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

当运行时出了问题(文件不存在、文本不是数字、字典里没有某个键),.NET 会抛出一个异常:一个描述错误的对象。异常会沿着调用栈向上传递,直到某个 catch 块处理它。如果没有任何地方处理,程序就会停止并打印错误。

基本的 try catch

把可能失败的代码包在 try 中,在 catch 中处理失败:

输出:

42 doubled is 84
'forty-two' is not a number
7 doubled is 14
Still running

当 int.Parse("forty-two") 抛出异常时,try 块中它后面的 Console.WriteLine 会被跳过,catch (FormatException) 块运行,然后循环继续。如果不需要异常对象,可以省略变量(catch (FormatException))。

这个特定的场景有更好的工具:int.TryParse(input, out int n) 会返回 false 而不是抛出异常。异常是为代码预料不到的情况准备的;经常无效的输入是意料之中的,所以应该检查它,而不是去捕获异常。

异常如何传递

在调用链深处抛出的异常,会逐层展开每个方法,直到找到匹配的处理程序。这些方法中位于抛出点之后的代码都不会运行。

输出:

OrderTotal finished
5.00
Caught KeyNotFoundException in Main

PriceOf 和 OrderTotal 都没有 catch,所以异常直接穿过它们到达 Main。把处理程序放在知道如何应对这个失败的那一层,而那往往不是出错的地方。

按正确的顺序捕获特定异常

一个 catch 子句会处理它声明的类型,以及从该类型派生的所有类型。有多个子句时,第一个匹配的子句胜出,所以要按从最具体到最笼统的顺序排列:

输出:

10 / 2 = 5
Cannot divide by zero
Both values must be whole numbers
Unexpected: OverflowException

最后一次调用抛出了 OverflowException,前面两个具体的子句都不处理它,于是由笼统的 catch (Exception e) 处理。如果把 catch (Exception) 放在最前面,后面的子句就永远执行不到,编译器会报错 CS0160 并拒绝编译。

用 when 的异常过滤器

C# 6 新增了 when:一个决定 catch 子句是否适用的条件。如果条件为 false,运行时会继续寻找其他处理程序,就像这个子句不存在一样。

输出:

Not found: show an empty page
Server error 503: retry later
Unhandled by Call: HTTP 401

过滤器让你可以根据异常内部的数据来分支,而不必先捕获再重新抛出。它们也是用同样方式处理两个不相关类型的简洁写法,第三个子句就是这样做的。过滤器在调用栈展开之前运行,所以当没有过滤器匹配时,调试器或崩溃转储看到的仍然是原始状态。

finally:总会运行的代码

当控制流离开 try 时,无论它是正常结束、提前返回还是抛出了异常,finally 块都会运行。清理代码就放在这里。(有一个例外:如果任何地方都没有捕获这个异常,进程可能会在它运行之前就结束。)

输出:

Open connection
Close connection
finished
Open connection
Close connection
returned early
Open connection
Close connection
handled error

在每种情况下,“Close connection”都会在方法的结果到达 Main 之前打印出来:finally 在 return 的值计算出来之后、方法真正返回之前运行。try 可以只带 finally 而完全没有 catch,这样既能清理资源,又能让异常继续传给调用方。

对于实现了 IDisposable 的对象(文件、流、连接),using 语句会替你写好这个 try/finally。

重新抛出:throw; 与 throw e;

有时 catch 块会记录日志或保存一些信息,然后让异常继续传递。你重新抛出的方式决定了堆栈跟踪能否保留下来:

输出:

throw;   trace mentions LoadConfig: True
throw e; trace mentions LoadConfig: False

throw e; 会把这个异常当作是从 catch 块新抛出的,所以它下面的调用帧,包括真正出错的那个方法,都会从跟踪信息中消失。重新抛出时一定要用单独的 throw;。(这里加上 NoInlining 特性,只是因为 JIT 可能会把这么小的方法合并进调用方,那样它在两种跟踪信息中都看不到。)

如果想补充上下文,就把异常包装进一个新异常,并把原始异常作为内部异常传进去:throw new ConfigException("Could not start the app", e);。内部异常会保留它自己的堆栈跟踪,日志工具会把整条链打印出来。编写自己的异常类型请参阅 抛出异常。

Exception 对象

每个异常都派生自 System.Exception。最常用的成员:

成员包含的内容
Message一段人类可读的描述
GetType().Name异常类型,比如 FormatException
StackTrace抛出点处的方法调用链
InnerException引发这个异常的异常,或者 null
ToString()类型、消息、内部异常和堆栈跟踪全部合在一起

如果你想事后诊断失败,请记录 e.ToString() 而不是 e.Message:单凭消息很少能看出问题出在哪里。不要把 e.ToString() 显示给最终用户。

常见的异常类型

异常典型原因
NullReferenceException在 null 引用上调用成员
ArgumentNullException方法需要一个值,却被传入了 null
ArgumentOutOfRangeException参数或列表索引超出允许的范围
IndexOutOfRangeException数组索引超出边界
FormatException对格式错误的文本调用 int.Parse、DateTime.Parse 等方法
InvalidCastException显式转换为对象并不属于的类型
InvalidOperationException对象当前的状态不适合这次调用(空序列、集合已被修改)
KeyNotFoundException用索引器读取字典中不存在的键
DivideByZeroException整数或 decimal 除以零
OverflowException解析出的数字、checked 转换或 checked 算术的结果超出了类型的范围
FileNotFoundException、IOException文件系统问题

NullReferenceException、IndexOutOfRangeException 和 InvalidCastException 几乎总是意味着有 bug。请修复代码,而不是捕获它们。

不要吞掉异常

空的 catch 会隐藏所有错误,包括那些你没有预料到的:

try
{
    SaveOrder(order);
}
catch (Exception)
{
    // nothing: the order silently was not saved
}

程序会继续运行,就好像保存成功了一样,而真正的原因却丢失了。下面这些准则能让异常处理保持诚实:

  • 只捕获你能处理的异常,并且在能处理它的那一层捕获。
  • 如果捕获只是为了记录日志,就用 throw; 重新抛出,除非程序确实能继续运行。
  • 只在最外层捕获 Exception:Main、请求处理程序、工作循环。
  • 对于意料之中的情况,优先使用 TryParse、TryGetValue 和 null 检查,而不是异常。与一次检查相比,抛出异常很慢,而一个什么都没抛出的 try 块几乎没有开销。

常见错误

  • 把 catch (Exception) 放在最前面。 后面的子句会变得无法执行到(CS0160)。
  • 用 throw e; 重新抛出。 它会抹掉原始的堆栈跟踪;请使用 throw;。
  • 空的 catch 块。 错误会凭空消失;至少要记录日志并重新抛出。
  • 用异常来控制流程。 用 TryParse 验证输入,而不是去捕获 FormatException。
  • 把框架异常的 e.Message 显示给用户。 它的措辞在不同 .NET 版本之间并不相同,而且是写给开发者看的。

常见问题

C# 中的 try catch 是如何工作的?

可能失败的代码放在 try 块中。如果其中某条语句抛出异常,块中剩下的部分会被跳过,运行时会寻找类型与该异常匹配的 catch 子句,先在当前方法中找,再到每一层调用方中找。第一个匹配的 catch 会运行,然后程序从整个 try 语句之后继续执行。

C# 中的 finally 一定会执行吗?

几乎总是会:try 块正常结束之后、catch 处理完异常之后、块内执行了 return 或 break 之后,以及异常途经这里、前往调用栈更上层的 catch 时。进程先结束时它就不会运行:进程被强制终止、调用 Environment.FailFast、发生 StackOverflowException,以及在 .NET Core 及更高版本中出现没有任何地方捕获的异常,这时进程会在 finally 块运行之前终止。

C# 中如何捕获多个异常?

写多个 catch 子句,最具体的类型放在最前面:catch (FileNotFoundException) 在 catch (IOException) 之前,后者又在 catch (Exception) 之前。如果某个子句因为前面的子句已经捕获了它的类型而永远不可能执行到,编译器会拒绝它。要用同样的方式处理两个不相关的类型,就用过滤器:catch (Exception e) when (e is FormatException || e is OverflowException)。

C# 中 throw 和 throw ex 有什么区别?

在 catch 中,throw; 会重新抛出当前异常,并保留原始的堆栈跟踪。throw ex; 抛出的是同一个对象,但会把堆栈跟踪重置到当前这一行,于是真正出错的那个方法就从跟踪信息中消失了。请使用 throw;,或者把它包装起来:throw new MyException("context", ex);。

C# 中的异常过滤器是什么?

就是 catch 后面的 when 子句(C# 6 及更高版本):catch (HttpRequestException e) when (e.Message.Contains("404"))。只有条件为 true 时 catch 块才会运行;否则异常会继续寻找其他处理程序,就像这个子句不存在一样,而且它的调用栈不会被展开。

C# 中应该捕获 Exception 吗?

只在程序的边缘捕获:Main 的最外层、请求处理程序或后台循环,这些地方的任务是记录错误,然后继续运行或干净地退出。在代码深处,只捕获你真正能处理的具体类型,其他的都让它继续向上传递。

Coddy 编程语言插图

和 Coddy 一起学编程

开始