〇、前言
前文已经列举了全部有哪些常见的异常类型,本文就开始介绍下各个类别的异常特点。
本文主要介绍参数校验类异常(Argument Exception),主要包括四种:ArgumentNullException、ArgumentException、ArgumentOutOfRangeException、DuplicateWaitObjectException。
点击链接可以查看本系列文章:C# Exception 详解系列。
一、参数校验类异常总览
Exception
└─ SystemException
└─ ArgumentException ← 基类,带 ParamName
├─ ArgumentNullException ← 参数为 null
├─ ArgumentOutOfRangeException ← 参数值越界(带 ActualValue)
└─ DuplicateWaitObjectException ← 同步对象数组中有重复项
它们共同的关键特征:都携带 ParamName(由 ArgumentException 提供),这样调用方 catch 后能精确知道是哪个参数出了问题,日志、监控、API 错误响应都能直接用上这个字段。这也是"参数校验异常"和 InvalidOperationException(对象状态不对,不是调用方传错)最本质的区别。
怎么选择?
参数是引用类型且为 null? → ArgumentNullException
参数非 null,但字符串内容不合法? → ArgumentException(或 ThrowIfNullOrEmpty / ThrowIfNullOrWhiteSpace)
参数是数值/枚举/索引/长度越界? → ArgumentOutOfRangeException
参数是同步句柄数组且有重复? → DuplicateWaitObjectException(几乎不用自己抛)
参数本身合法,但当前对象状态不允许? → InvalidOperationException(不属于本类,别用错)
二、各个异常简介与简单示例
2.1 ArgumentException
语义:参数不为 null,但值本身在语义上不成立------格式错、含非法字符、组合矛盾、不在允许的枚举集合内。
关键成员:ParamName 、可自定义 Message、支持 InnerException(适合包装解析失败的原生异常)。
public void SetOutputPath(string path)
{
ArgumentNullException.ThrowIfNull(path);
if (path.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
throw new ArgumentException("路径不能包含通配符或非法字符", nameof(path));
if (Path.IsPathRooted(path) == false)
throw new ArgumentException("必须是绝对路径", nameof(path));
}
// 多个参数之间存在逻辑矛盾:把"哪个参数有问题"说清楚
public void Configure(TimeSpan timeout, int retryCount)
{
if (retryCount > 0 && timeout == TimeSpan.Zero)
throw new ArgumentException("启用重试时超时时间不能为零", nameof(timeout));
}
// 包装下层异常,保留根因
public Guid ParseId(string id)
{
ArgumentException.ThrowIfNullOrEmpty(id);
try { return Guid.Parse(id); }
catch (FormatException ex)
{
throw new ArgumentException($"'{id}' 不是有效的 GUID", nameof(id), ex);
}
}
.NET 7/8+ 简化写法:
| 方法 | 版本 | 触发条件 | 抛出 |
|---|---|---|---|
ArgumentException.ThrowIfNullOrEmpty(s) |
.NET 7+ | null 或 "" |
null→ArgumentNullException,空→ArgumentException |
ArgumentException.ThrowIfNullOrWhiteSpace(s) |
.NET 8+ | null/""/全空白 |
同上 |
ArgumentException.ThrowIfEqual / ThrowIfNotEqual |
.NET 8+ | 值相等性不符 | ArgumentException |
public void Rename(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name); // 一条顶三条
}
适用场景:正则/路径/邮箱/文件名合法性、枚举 IsDefined 校验、连接字符串关键字、集合中不允许出现特定元素、多参数互斥约束。
2.2 ArgumentNullException
语义:方法契约要求"必填"的引用参数传入了 null。这是调用方的 bug,不是"没找到数据"。
关键成员:ParamName;消息固定为 "Value cannot be null. (Parameter 'x')"。
// 传统写法(所有框架版本可用)
public void Save(string fileName, Stream content)
{
if (fileName == null)
throw new ArgumentNullException(nameof(fileName));
if (content == null)
throw new ArgumentNullException(nameof(content), "必须提供可写的内容流");
}
// .NET 6+ 推荐写法:不需要 nameof,靠 [CallerArgumentExpression] 自动捕获
public void Save(string fileName, Stream content)
{
ArgumentNullException.ThrowIfNull(fileName);
ArgumentNullException.ThrowIfNull(content);
}
适用场景 :
- 公共 API / 构造函数的依赖注入参数(
IService、ILogger、委托回调)。 - 工具方法的核心入参(路径、SQL、表达式树、集合)。
- 不适用 :可选参数(给默认值)、"查不到"的情况(应返回 null /
TryXxx/ 抛KeyNotFoundException)。
两个需要特别注意的点:
① ThrowIfNull****不能直接用于 Nullable<T>(会装箱且语义奇怪),值类型判空请用手动判断或 if (value is null) 模式;
② .NET 7 起才有 void* 指针重载,非托管互操作场景要注意目标框架。
ArgumentNullException 可以拦截 null,但是不会拦截 ""。ArgumentNullException.ThrowIfNull("") 会直接放行 ""(等价于 string.Empty)是一个真实的、非 null 的字符串对象实例,它在堆上有地址、有 Length 属性(值为 0)。ThrowIfNull 内部只做一件事:if (arg is null) throw ...,所以空字符串对它来说完全合法,检查会静默通过。
| 写法 | null |
"" |
" " |
"abc" |
|---|---|---|---|---|
ThrowIfNull(s) |
❌ 抛 | ✅ 过 | ✅ 过 | ✅ 过 |
ThrowIfNullOrEmpty(s) (.NET 7+) |
❌ 抛 ArgumentNullException | ❌ 抛 ArgumentException | ✅ 过 | ✅ 过 |
ThrowIfNullOrWhiteSpace(s) (.NET 8+) |
❌ 抛 ANE | ❌ 抛 AE | ❌ 抛 ArgumentException | ✅ 过 |
2.3 ArgumentOutOfRangeException
语义:参数是数值型或有序量**,落在方法承诺的有效区间之外。**它是 ArgumentException 的子类,因此 catch ArgumentException 也能接住它。
关键成员 :ActualValue(把调用方传入的真实值带上,排查线上问题极其有用)、ParamName。
// 传统三参构造:paramName, actualValue, message
public byte[] ReadChunk(int count)
{
if (count <= 0)
throw new ArgumentOutOfRangeException(nameof(count), count, "必须大于 0");
}
// 索引/容量类:把越界值和上限都写进消息,是 BCL 的一贯风格
public T At<T>(IList<T> list, int index)
{
ArgumentNullException.ThrowIfNull(list);
if ((uint)index >= (uint)list.Count)
throw new ArgumentOutOfRangeException(nameof(index), index,
$"索引必须在 [0, {list.Count}) 范围内");
}
.NET 8+ 的数值校验全家桶(推荐优先用):
ArgumentOutOfRangeException.ThrowIfNegative(count); // < 0
ArgumentOutOfRangeException.ThrowIfZero(count); // == 0
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); // <= 0
ArgumentOutOfRangeException.ThrowIfGreaterThan(count, 100);// > 100
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1); // < 1
ArgumentOutOfRangeException.ThrowIfEqual(flag, 0); // == 0
适用场景:集合索引与容量、分页 pageSize/pageIndex、超时时间、重试次数、端口号、缓冲区大小、枚举转整型后的范围、日期区间(start > end)、Enum.ToObject 前的校验。
经验:"小于等于零"比"小于零"更常用。BCL 里表示数量、长度的参数几乎都要求 > 0,所以 ThrowIfNegativeOrZero 出场率远高于 ThrowIfNegative。
2.4 DuplicateWaitObjectException
语义 :一个同步对象(WaitHandle)数组里出现了重复项 ,导致等待语义不明确(同一个内核句柄被等待两次,唤醒计数无法确定)。
- 继承链:
ArgumentException→ 所以它也有ParamName。 - HRESULT:
COR_E_DUPLICATEWAITOBJECT (0x80131529)。 - 谁在抛 :几乎全是运行时自己在抛 ------
WaitHandle.WaitAll、WaitHandle.WaitAny发现数组中有重复 OS 句柄时;某些Monitor/线程原语实现路径也会产生。 - 你几乎不需要手动抛它 。它属于"框架级契约异常",就像
IndexOutOfRangeException一样,是平台替你表达的约束。
唯一可能自己抛的场景:自己写的并发原语 (比如:封装一个 WaitAnyEx、自定义 Barrier、基于句柄池的多路复用器),并且 API 也接受 WaitHandle[] 或类似的"对象集合"参数,此时为了与 BCL 行为一致,可以抛它:
public static int WaitAnyEx(WaitHandle[] handles, TimeSpan timeout)
{
ArgumentNullException.ThrowIfNull(handles);
if (handles.Length == 0)
throw new ArgumentException("等待数组不能为空", nameof(handles));
// 用引用相等判断重复(与 WaitHandle 内部逻辑一致)
for (int i = 0; i < handles.Length; i++)
for (int j = i + 1; j < handles.Length; j++)
if (ReferenceEquals(handles[i], handles[j]))
throw new DuplicateWaitObjectException(nameof(handles));
return WaitHandle.WaitAny(handles, timeout);
}
调用方该怎么处理 :一般不该 catch 后重试 (重试结果一样),应当记录日志并修复传入的数组来源;如果代码要兼容旧框架/UWP,直接 catch ArgumentException 更安全(该异常在某些 SKU 中未被导出)。
三、为什么推荐优先用 ThrowIf 系列(不只是少打字)
3.1 自动带参数名,消灭 nameof 抄错
// 这种 bug 真实存在:检查的是 a,写的却是 nameof(b)
if (a == null) throw new ArgumentNullException(nameof(b)); // 编译器不报错!
ArgumentNullException.ThrowIfNull(a); // 通过 [CallerArgumentExpression("argument")] 自动填入 "a"
3.2 性能更好
这些方法是 [DoesNotReturn] + 内联友好的静态 helper,JIT 能识别并做优化,且避免了在非异常路径上构造异常对象的开销(异常对象只在真的失败时才创建)。
.NET 分析器 CA1512 会直接把 if (...) throw new ArgumentOutOfRangeException(...) 标黄,提示替换成对应的 ThrowIfXxx。
3.3 版本对照速查
| 需求 | 老写法 | 最低版本 |
|---|---|---|
| 非 null | if (x==null) throw ... |
.NET 6 ThrowIfNull |
| 非空字符串 | if (string.IsNullOrEmpty(s)) throw ... |
.NET 7 ThrowIfNullOrEmpty |
| 非空白字符串 | if (string.IsNullOrWhiteSpace(s)) throw ... |
.NET 8 ThrowIfNullOrWhiteSpace |
| 数值范围 | if (n <= 0) throw ... |
.NET 8 ThrowIfNegativeOrZero 等 |
| 目标框架低于上述版本 | 自己封一层 helper(见下) | 任意 |
低版本框架的自救方案 (照抄 BCL 思路,几十行搞定):
internal static class Guard
{
public static void ThrowIfNull([NotNull] object? arg,
[CallerArgumentExpression(nameof(arg))] string? name = null)
{
if (arg is null) throw new ArgumentNullException(name);
}
public static void ThrowIfNullOrEmpty(string? arg,
[CallerArgumentExpression(nameof(arg))] string? name = null)
{
if (arg is null) throw new ArgumentNullException(name);
if (arg.Length == 0) throw new ArgumentException("值不能为空字符串", name);
}
public static void ThrowIfOutOfRange<T>(T value, T min, T max,
[CallerArgumentExpression(nameof(value))] string? name = null) where T : IComparable<T>
{
if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0)
throw new ArgumentOutOfRangeException(name, value, $"必须在 [{min}, {max}] 内");
}
}
注意:CallerArgumentExpression 只能修饰带默认值的 string 可选参数,且属性里的名字要和目标形参严格一致,否则静默失效。
四、最佳实践清单和完整示例
以下是几个简单的场景和处理方法推荐。
-
只在校验边界抛 :公共 API、构造函数、受保护的虚方法入口。私有方法内部多用
Debug.Assert;性能敏感的 hot path 上别做冗余校验(能被 JIT 内联消除的简单 null check 除外)。 -
顺序固定 :先 null,再内容/格式,最后范围与跨参数约束。这样失败消息永远指向"最靠前的那个错误",稳定可预期。
-
永远用
nameof或 ThrowIf 自动捕获 ,绝不手写字符串"path"。重构改名时前者会编译报错,后者不会。 -
别手动抛
NullReferenceException。它是运行时解引用 null 时的产物;参数为 null 属于"调用方违约",语义上必须是ArgumentNullException。二者在监控告警、调试器行为、Roslyn 分析上都不同。同理也别用Exception、ApplicationException做参数校验。 -
区分"参数错"和"状态错" :文件路径合法但文件不存在 → 抛
FileNotFoundException;流已关闭还去读 →ObjectDisposedException;迭代器已启动后修改集合 →InvalidOperationException。这些都不属于本节的 Argument 家族。 -
DTO/接口边界用验证框架,领域层用异常 :ASP.NET Core 入参建议走 FluentValidation / 数据注解(返回 400),服务层内部再用 Argument 异常(返回 500 或统一错误码)。两者混用会让前端拿到堆栈而不是字段级错误。
-
catch 时尽量按子类细分 :
try { svc.Do(x, n); }
catch (ArgumentNullException e) when (e.ParamName == nameof(x)) { /* 必传缺失 / }
catch (ArgumentOutOfRangeException e) { / 用 e.ActualValue 做降级 / }
catch (ArgumentException) { / 其他参数问题 */ } -
枚举与泛型 :
Enum.IsDefined校验非法整型 →ArgumentException;泛型where T : class时用ThrowIfNull,无约束泛型则手动判空并给出明确的参数名。
把以上场景实践串起来的简单示例:
public sealed class LogRotator
{
private readonly string _dir;
private readonly int _maxFiles;
private readonly long _maxBytes;
public LogRotator(string directory, int maxFiles, long maxBytesPerFile)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
if (directory.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
throw new ArgumentException("目录名包含非法字符", nameof(directory));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxFiles);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytesPerFile);
_dir = Path.GetFullPath(directory);
_maxFiles = maxFiles;
_maxBytes = maxBytesPerFile;
}
public void Rotate(ICollection<string> files, int startIndex, int count)
{
ArgumentNullException.ThrowIfNull(files);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
ArgumentOutOfRangeException.ThrowIfNegative(startIndex);
if (startIndex + count > files.Count)
throw new ArgumentOutOfRangeException(nameof(count), count,
$"从 {startIndex} 开始的 {count} 个元素超出集合大小 {files.Count}");
var handleArray = files.Select(f => new ManualResetEvent(false)).ToArray();
// 这里如果数组里有重复句柄,WaitAny 会自己抛 DuplicateWaitObjectException
// ------ 我们不用、也不应该替它抛
}
}
五、小小的总结
ArgumentNullException 管"有没有传",ArgumentException 管"传得对不对",ArgumentOutOfRangeException 管"传得超没超界",DuplicateWaitObjectException 是运行时替同步原语管的,基本不用碰。
能升级到高版本框架就一律用 ThrowIfXxx 系列,既短、又快、还不会把参数名写错。