捕获异常只是错误处理的一半,另一半是抛出正确的异常。选得好的异常能准确告诉调用方哪里出了错,以及这是调用方的失误还是程序状态的问题。本页讲的是抛出这一侧;处理异常请看 try catch。
throw 语句与卫语句
throw 接收一个异常对象。方法会在那里停止执行,异常则沿着调用栈向上寻找处理程序。最常见的用法是卫语句:在方法开头做检查,在开始任何工作之前就拒绝无效的输入。
输出:
ArgumentOutOfRangeException for parameter 'amount'
InvalidOperationException: The account is frozen.
Balance: 100
nameof(amount) 会生成字符串 "amount",即使参数被重命名也依然正确。参数类异常会把它保存在 ParamName 中,工具和日志会用它指出是哪个参数有问题。
卫语句让方法的其余部分保持简单:通过检查之后,代码就可以假定输入有效。它们还会在出错的地方立即失败,而不是让错误的值继续传下去,在三个方法之后引发一个令人摸不着头脑的 NullReferenceException。
该抛出哪种异常类型
如果内置类型能描述当前情况,就复用它;调用方已经知道如何处理它。
| 情况 | 抛出 |
|---|---|
必需的参数是 null | ArgumentNullException |
| 参数超出允许的范围(数量为负数、索引越过末尾) | 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)。