参数校验类异常(Argument Exception)【C# Exception 详解系列一】

〇、前言

前文已经列举了全部有哪些常见的异常类型,本文就开始介绍下各个类别的异常特点。

本文主要介绍参数校验类异常(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 系列,既短、又快、还不会把参数名写错。

相关推荐
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(九)
前端·后端
全栈弄潮儿2 小时前
Python实战第8期:函数
后端·python·编程语言
码事漫谈2 小时前
你让 AI “尽力做”,它反而可能做不好
后端
孟健3 小时前
LLM API 安全防线:从 OpenCode 盗刷事件到 LiteLLM 多模型容灾实操
人工智能·后端·架构
程序员cxuan3 小时前
我用 WorkBuddy + ima 把公众号文章做成了知识库。
人工智能·后端·程序员
程序员cxuan4 小时前
玩了一天 REA,我号快没了
人工智能·后端·程序员
卷无止境4 小时前
暗流与破局:AI时代的代码安全审查工具全景指南
后端·python
smallyoung4 小时前
告别N+1:Spring Boot 一键数据翻译框架体系
java·后端·架构
程序猿DD4 小时前
OctaFuse Gateway 2.16.0:新增 ElevenLabs 的支持、厂商原生端点重构、后台管理优化
后端