Menu

C# 抛出异常:卫语句、throw 表达式与自定义异常

在 C# 中如何以及何时抛出异常。学习 throw 语句、各种内置异常类型分别对应哪类错误、配合 ?? 和 ?: 使用的 throw 表达式、ThrowIfNull 辅助方法,以及如何编写带有自定义数据和内部异常的自定义异常类。

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

捕获异常只是错误处理的一半,另一半是抛出正确的异常。选得好的异常能准确告诉调用方哪里出了错,以及这是调用方的失误还是程序状态的问题。本页讲的是抛出这一侧;处理异常请看 try catch。

throw 语句与卫语句

throw 接收一个异常对象。方法会在那里停止执行,异常则沿着调用栈向上寻找处理程序。最常见的用法是卫语句:在方法开头做检查,在开始任何工作之前就拒绝无效的输入。

输出:

ArgumentOutOfRangeException for parameter 'amount'
InvalidOperationException: The account is frozen.
Balance: 100

nameof(amount) 会生成字符串 "amount",即使参数被重命名也依然正确。参数类异常会把它保存在 ParamName 中,工具和日志会用它指出是哪个参数有问题。

卫语句让方法的其余部分保持简单:通过检查之后,代码就可以假定输入有效。它们还会在出错的地方立即失败,而不是让错误的值继续传下去,在三个方法之后引发一个令人摸不着头脑的 NullReferenceException。

该抛出哪种异常类型

如果内置类型能描述当前情况,就复用它;调用方已经知道如何处理它。

情况抛出
必需的参数是 nullArgumentNullException
参数超出允许的范围(数量为负数、索引越过末尾)ArgumentOutOfRangeException
参数在其他方面无效(名称为空、ID 格式错误)ArgumentException
在对象当前的状态下,这次调用无效InvalidOperationException
这个类型从不支持该操作(只读集合的 Add)NotSupportedException
方法还没有写好NotImplementedException
对象在 Dispose 之后还被使用ObjectDisposedException
有时限的操作超时了TimeoutException

前三行与 InvalidOperationException 之间的分界,在于需要改变的是谁。参数异常说的是“换一种方式调用”。InvalidOperationException 说的是“调用本身没问题,只是现在不行”。

不要直接抛出 Exception、SystemException 或 ApplicationException:调用方没法只捕获它们而不连带捕获其他所有异常。也不要自己抛出 NullReferenceException、IndexOutOfRangeException 或 StackOverflowException;运行时把它们留给真正的 bug。

throw 表达式

在 C# 7 之前,throw 只能是语句。从 C# 7 开始,它还可以在三个地方作为表达式出现,常见的检查因此可以写成一行:

输出:

Ana <ana@example.com>
Null: name
ArgumentException: email

注意,ArgumentNullException 派生自 ArgumentException,所以如果把 catch (ArgumentException) 放在前面,它也会捕获 null 的情况。同时处理两者时,catch 子句的顺序很重要,原因也在于此。

ThrowIfNull 及类似方法(.NET 6 及更高版本)

现代 .NET 增加了静态辅助方法,替你写好检查和抛出,还会自动获取参数名:

public void Ship(Order order, int quantity, string address)
{
    ArgumentNullException.ThrowIfNull(order);                   // .NET 6
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); // .NET 8
    ArgumentException.ThrowIfNullOrWhiteSpace(address);          // .NET 8
    ObjectDisposedException.ThrowIf(disposed, this);             // .NET 7

    // ...
}

它们的行为和手写的 if 加 throw 相同,并让每个卫语句只占一行。在较旧的目标框架上,请使用前面展示的 if 写法。

编写自定义异常类

当调用方需要单独捕获这种特定的失败,或者处理程序需要的数据很难用一条消息字符串表达时,就创建你自己的异常类型。

输出:

Cannot withdraw 25 from a balance of 15.
Short by 10

惯例如下:

  • 类名以 Exception 结尾。
  • 它派生自 Exception(如果它是某个更具体的内置类型的特例,比如 InvalidOperationException,就派生自那个类型)。
  • 它有三个标准构造函数:无参数、接收消息、接收消息加内部异常。你自己的构造函数在此基础上再添加。
  • 额外的数据放在只读属性中,在构造函数里设置。这样处理程序就可以根据 e.Requested 采取行动,而不必去解析消息。

用内部异常进行包装

当一个底层的失败应该以更高层的失败呈现时,就把它包装起来。原始异常会作为 InnerException 保留下来,不会丢失任何信息:

输出:

Setting 'port' must be a number, got '80a'.
Caused by: FormatException

现在调用方面对的是它能理解的“配置”问题,而记录 e.ToString() 会打印整条异常链,包括 FormatException 及其堆栈跟踪。只有在能增加含义时才去包装;把每个异常都包进一个通用的 MyAppException,只会让处理程序不得不在 InnerException 里翻找。

抛出异常还是返回结果

异常用于调用方在正常运行中预料不到的失败。对于常规的结果,比如经常找不到东西的查找,或者经常无效的用户输入,.NET 的惯例是 Try 模式:返回 bool,并通过 out 参数把值传回去。

public bool TryWithdraw(decimal amount, out string error)
{
    if (amount > Balance) { error = "Insufficient funds."; return false; }
    Balance -= amount;
    error = null;
    return true;
}

许多类型两种方式都提供:int.Parse 会抛出异常,int.TryParse 返回 false;dict[key] 会抛出异常,dict.TryGetValue 返回 false。抛出异常的开销远大于返回一个值,所以它不应该出现在每秒运行数千次的代码路径上。关于 out 参数,请参阅 ref 和 out。

编写好的异常消息

异常消息是给查看日志的开发者读的。让它说明哪里不对,并在安全的前提下附上出错的值:“Quantity must be between 1 and 99, got 0.” 比 “Invalid input.” 好得多。写完整的句子,并且不要把密码、token 之类的机密信息放进消息里,因为它们最终会出现在日志文件中。

常见错误

  • 直接抛出 Exception 本身。 调用方无法有选择地捕获它;请使用具体的类型。
  • 把消息传到了参数名的位置。 new ArgumentNullException("name") 接收的是参数名;消息是第二个参数。
  • 硬编码参数名。 使用 nameof(param),这样重命名之后依然正确。
  • 没有额外含义的自定义异常。 如果有合适的内置类型,就用它。
  • 包装时丢掉了原始错误。 一定要把它作为内部异常传进去。

常见问题

C# 中如何抛出异常?

创建一个异常对象并把它抛出:throw new ArgumentException("Amount must be positive", nameof(amount));。程序会在这一行停止执行,异常沿着调用栈向上传递,直到最近的匹配 catch。请选择最能描述问题的具体内置类型;如果调用方需要单独处理这种情况,就用自定义类型。

C# 中如何创建自定义异常?

从 Exception 派生一个类,类名以 Exception 结尾,并为它提供标准构造函数:一个无参数,一个接收消息,一个接收消息和内部异常,每个都调用对应的 base(...) 构造函数。再为处理程序需要的数据添加只读属性,比如订单 ID 或余额。

什么时候抛出 ArgumentException,什么时候抛出 InvalidOperationException?

当调用方传入了错误的值时,抛出 ArgumentException(或 ArgumentNullException / ArgumentOutOfRangeException):修复方法是换一种方式调用这个方法。当参数没问题、但对象当前的状态不适合这次调用时,抛出 InvalidOperationException,比如从已关闭的连接读取数据,或从已冻结的账户取款。

C# 中的 throw 表达式是什么?

从 C# 7 开始,throw 可以在三个地方作为表达式使用:?? 之后、?: 的任意一个分支,以及表达式主体成员或 lambda 的主体。例如 _name = name ?? throw new ArgumentNullException(nameof(name)); 在一行内完成赋值或抛出异常。

ArgumentNullException.ThrowIfNull 有什么作用?

它是 .NET 6 新增的静态辅助方法:当 customer 为 null 时,ArgumentNullException.ThrowIfNull(customer); 会抛出 ArgumentNullException,并自动填好参数名,否则什么也不做。之后的版本又加入了类似的辅助方法,比如 ArgumentException.ThrowIfNullOrEmpty(.NET 7)和 ArgumentOutOfRangeException.ThrowIfNegative(.NET 8)。

Coddy 编程语言插图

和 Coddy 一起学编程

开始