对象状态与操作合法性异常【C# Exception 详解系列(二)】

〇、前言

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

本文主要介绍对象状态与操作合法性异常,主要包括四种:InvalidOperationException、ObjectDisposedException、NotSupportedException、NotImplementedException、PlatformNotSupportedException、TimeoutException。

点击链接可以查看本系列文章:C# Exception 详解系列。

一、参数校验类异常总览

**此类异常的共同点是:调用方的代码语法没错、参数也没错,错的是"时机"或"环境"。**主要应对以下三个问题------对象还活着吗?对象现在处于允许该操作的状态吗?这个能力到底有没有? 选型的本质就是在这三者之间做区分。

异常类型 核心含义 永久性 继承关系 重试有用? 首选替代
InvalidOperationException 状态/时序不对 否(重排序可能成功) Exception 看情况 拆接口、状态机
ObjectDisposedException 已释放 是 InvalidOperationException 无用 生命周期治理
NotSupportedException 设计上无此能力 是 Exception 无用 换窄接口 / CanXxx 探测
NotImplementedException 还没写 否(将来会有) Exception 无用 补全实现
PlatformNotSupportedException 当前平台无此能力 是(换机器才行) NotSupportedException 无用 运行时平台分支降级
TimeoutException 等超时了 否(重试可能成功) Exception 谨慎(幂等性) CancellationToken

最容易选错的三对:

  1. ArgumentException vs InvalidOperationException :前者是"你传的东西不对",后者是"东西对,但现在不能做"。判断标准:同样的参数换个时机能不能成功?能 → InvalidOperation。
  2. NotSupportedException vs NotImplementedException :设计问题 vs 进度问题。选反了调用方会做出错误决策(比如绕开你这个实现,或者傻傻等你发版)。
  3. TimeoutException vs OperationCanceledException :同步/协议超时 vs 异步取消。前者在 System.Runtime,后者在 System.Threading,连命名空间都不一样。

关键记忆点:ObjectDisposedException 继承自 InvalidOperationException;PlatformNotSupportedException 继承自 NotSupportedException。所以 catch 基类时会"顺带"捕获子类,顺序写反就会吞掉更精确的信息。

以下是几条通用纪律,需要注意。

  1. 带上上下文 :message 里写清"当前状态 / 期望状态 / 哪个对象 / 哪个平台 / 超时多少毫秒"。这些异常在线上通常意味着流程缺陷,光看类型不够定位。
  2. XML 文档标注 :/// <exception cref="...">,让调用方在 IDE 里就能看到前置条件。
  3. 能用布尔/枚举/接口表达的,不要用异常 :TryOpen()、CanWrite、IReadOnlyList<T> 都优于"调了再 catch"。
  4. 不要在 hot path 上用它们做控制流 :foreach 里靠 catch NotSupportedException 判断能否写入,性能会很难看。
  5. catch 顺序从细到粗 :先 ObjectDisposedException 再 InvalidOperationException;先 PlatformNotSupportedException 再 NotSupportedException。
  6. 自定义异常要克制 :这六种已经覆盖了 99% 的状态类错误。只有当"调用方需要单独 catch 并做特殊恢复"时,才值得派生自己的异常(并记得设 HResult 与 Serializable)。

二、各个异常简介与简单示例

2.1 InvalidOperationException ------ "现在不行"

语义:对象处于某个合法但不适合本次操作 的状态。它不是永久的否定(换个时序就能成功),也不是参数错误。这是本组里最常用、也最容易被滥用的一类。

典型 BCL 案例:

  • SqlConnection 未 Open() 就执行 ExecuteReader

  • IEnumerator.MoveNext() 时发现集合被修改(内部靠 version 计数器检测),抛 InvalidOperationException:"Collection was modified; enumeration operation may not execute."

  • List<T>.Remove() 在只读包装上

  • LINQ 的 Single() / First() 在空序列上;Enumerable.Cast<T>() 转型失败

  • 在 foreach 进行中修改集合;DataTable 在 BeginLoadData 期间写入

    // 一个简单的示例:
    public class OrderProcessor
    {
    private enum State { Created, Started, Completed }
    private State _state = State.Created;
    private readonly List _items = new();

    复制代码
      public void Start()
      {
          if (_state != State.Created)
              throw new InvalidOperationException(
                  $"Order cannot be started in {_state} state.");
          _state = State.Started;
      }
      public void AddItem(string item)
      {
          if (_state == State.Completed)
              throw new InvalidOperationException("Order already completed.");
          _items.Add(item);
      }
      public IEnumerable<string> GetItems()
      {
          foreach (var it in _items)   // 调用方若在此期间修改 _items,MoveNext 会抛 InvalidOperationException
              yield return it;
      }

    }

适用场景:

  • 有明确状态机 的对象(连接、事务、订单、任务、流)
  • 需要先调用 Init/Open/BeginXxx 的资源型对象
  • 枚举器、迭代器、延迟执行(yield / LINQ)的合法性校验
  • 多线程下"已被别人先做完"的竞态(配合 Interlocked 判断)

常见误用:

  1. 把参数错包成它 :index 越界应抛 ArgumentOutOfRangeException,不是 InvalidOperation。
  2. 用它掩盖设计缺陷 :如果某方法"常常不能调用",说明接口太胖,应拆成 ICanStart / IStartable 等小接口,让不支持的状态在编译期就不可见。
  3. 消息里不带状态信息 。好的 message 要写明"当前是什么状态、期望什么状态",便于线上排查。

2.2 ObjectDisposedException ------ "已经死了"

语义:对象的生命周期已结束,任何成员(除再次 Dispose 外)都不应被调用。它是 InvalidOperationException 的子类,但语义更窄、更可诊断。

需要特别注意的地方:

  • Dispose 必须幂等 ,第二次调用不该抛异常;抛的是"其他成员在 Dispose 之后被调用"。

  • 标准做法是在每个公开成员开头做 disposed 检查;字段 _disposed 建议用 bool(简单场景)或结合 GC.SuppressFinalize。

  • .NET 7+ 提供了 ObjectDisposedException.ThrowIf(bool condition, object instance),一行搞定且消息格式统一。

  • 不要从终结器(finalizer)里抛;也不要指望托管对象在终结阶段仍可用。

  • 异步场景注意:DisposeAsync 后仍有 await 在跑,属于典型的"已释放仍使用"。

    public sealed class LogWriter : IDisposable
    {
    private readonly StreamWriter _writer;
    private bool _disposed;

    复制代码
      public LogWriter(string path) => _writer = new StreamWriter(path, append: true);
    
      public void Write(string message)
      {
          // .NET 7+ 推荐写法:ObjectDisposedException.ThrowIf(_disposed, this);
          if (_disposed) throw new ObjectDisposedException(nameof(LogWriter));
    
          _writer.WriteLine($"{DateTime.Now:O} {message}");
      }
      public void Flush()
      {
          if (_disposed) throw new ObjectDisposedException(nameof(LogWriter));
          _writer.Flush();
      }
      public void Dispose()
      {
          if (_disposed) return;     // 幂等
          _writer.Dispose();         // 释放资源
          _disposed = true;          // 标志置位必须在释放之后(避免释放失败时误判为已释放)
      }

    }

    // 调用方
    var log = new LogWriter("app.log");
    log.Dispose();
    log.Write("oops"); // ObjectDisposedException: Cannot access a disposed object. Object name: 'LogWriter'.

适用场景:

  • 所有实现 IDisposable / IAsyncDisposable 的类型(流、连接、句柄包装、缓存文件、定时器)
  • 池化对象归还后再使用
  • HttpClient 被 Dispose 后复用(非常常见的线上 bug)

与 InvalidOperationException 的取舍。若类型没有实现 IDisposable,就用 InvalidOperationException。ObjectDisposedException 是 Dispose 模式的专属信号 ,监控告警和日志检索时它能直接指向生命周期管理问题,因此值得单独使用。

2.3 NotSupportedException ------ "设计上就没有"

语义:成员存在于接口/基类契约中,但当前实现从设计上就无法提供 。这不是临时状态,重试一万次也一样。

典型 BCL 案例:

  • Stream 只读时 CanWrite == false,调用 Write → NotSupportedException

  • Array / 固定大小列表实现 IList.Add → NotSupportedException

  • ReadOnlyCollection<T>.Add

  • Encoding.GetEncoding("unsupported") 的部分场景(更多是 ArgumentException)

  • ICollection<T>.IsReadOnly == true 时的修改方法

    public class ReadOnlyStream : Stream
    {
    private readonly Stream _inner;
    public ReadOnlyStream(Stream inner) => _inner = inner;

    复制代码
      public override bool CanRead => true;
      public override bool CanWrite => false;   // 关键:先提供探测属性
      public override bool CanSeek => _inner.CanSeek;
    
      public override long Length => _inner.Length;
      public override long Position { get => _inner.Position; set => throw new NotSupportedException(); }
    
      public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
    
      public override void Write(byte[] buffer, int offset, int count) =>
          throw new NotSupportedException("This stream is read-only.");
    
      public override void Flush() { }
      public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
      public override void SetLength(long value) => throw new NotSupportedException();

    }

适用场景:

  • 实现"宽接口"(IList、IDbCommand、Stream、ICollection)的子集能力时
  • 适配器/装饰器模式中底层能力缺失
  • 抽象基类中某些操作对部分子类无意义

**最佳实践:能探测就别让调用方靠异常编程。**BCL 的标准模式是 CanXxx 属性 + 抛异常 双保险:

复制代码
// 推荐:先问,再做
if (stream.CanWrite)
    stream.Write(buf, 0, len);

// 不推荐:做了再说,靠异常当分支
try { stream.Write(buf, 0, len); }
catch (NotSupportedException) { /* 哦,原来不能写 */ }

文档里要明确写清"哪些成员会抛",并在 XML 注释中标注 <exception cref="NotSupportedException">。

设计层面的反思。如果发现自己经常为 Add/Remove 抛 NotSupportedException,优先考虑换接口:用 IReadOnlyList<T> 代替 IList<T>,用 IEnumerable<T> 代替 ICollection<T>。让编译期和 IDE 提示阻止非法调用,比运行时抛异常好一个数量级。

2.4 NotImplementedException ------ "还没做"

语义:代码路径暂时不存在 ,属于开发期占位。它与"不支持"的区别在于意图:将来会有,只是现在没有。
HResult 是 0x80004001 (E_NOTIMPL),而 NotSupportedException 是 0x80131515------调试器里一眼可分。

复制代码
public class PaymentService : IPaymentService
{
    public Task PayAsync(decimal amount) => throw new NotImplementedException("微信支付尚未接入,预计 Q4 完成");

    // IDE 自动生成接口实现时默认就填这个,方便你逐个实现
    public Task RefundAsync(string id) => throw new NotImplementedException();
}

适用场景:

  • IDE 生成接口/重写方法后的占位(这是它最主要、最正当的用途)
  • 大功能分迭代交付时,先让项目编译通过
  • 极少见的情况:基类提供可选钩子,子类无需覆盖时(此时更推荐 virtual 空实现或抽象类)

不该用的地方:

  • 永远不该出现在生产代码的正常运行路径上 。上线前必须清零,否则用户会看到一个比 NotSupportedException 更难理解的错误。
  • 不要拿它表达"这个子类永远不会有此能力"------那是 NotSupportedException。
  • 不要拿它表达"参数组合还没处理"------那是 ArgumentException 或 InvalidOperationException。

记忆口诀:NotImplemented = 时间问题;NotSupportedException = 设计问题。

2.5 PlatformNotSupportedException ------ "这台机器跑不了"

语义:API 编译进去了(通常是共享代码库 / multi-targeting),但当前 OS 或运行时无法提供底层能力 。它是 NotSupportedException 的子类,因此 catch 父类时会被一并捕获------catch 顺序要注意。

典型 BCL 案例:

  • Microsoft.Win32.Registry 在非 Windows 平台

  • ProcessStartInfo.Verb = "runas"(提权)在非 Windows

  • WindowsIdentity / WindowsPrincipal 在 Linux

  • Console.Beep() 在部分 Unix 终端

  • Rfc2898DeriveBytes 的某些硬件加速路径、Vector 硬件内在指令在不支持的 CPU

  • MAUI / Blazor Hybrid 中调用桌面专属 API

    using System.Runtime.InteropServices;

    public static class OsHelper
    {
    public static void SetRegistryValue(string key, string value)
    {
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    throw new PlatformNotSupportedException(
    "Registry is only available on Windows. Use a config file on Linux/macOS.");

    复制代码
          Microsoft.Win32.Registry.CurrentUser.CreateSubKey(key).SetValue("App", value);
      }
    
      // 更好的做法:主动降级而不是抛
      public static string GetConfigPath() =>
          RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
              ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "app.cfg")
              : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".app.cfg");

    }

适用场景:

  • 多目标框架(netstandard / net8.0 + windows)的共享库
  • 容器化/跨平台迁移时保留旧 Windows 专属 API 的桩
  • 条件编译(#if WINDOWS)之外的运行时分支

最佳实践:

  1. 优先用 RuntimeInformation.IsOSPlatform / OperatingSystem.IsWindows() 做主动分支 ,优雅降级或返回替代方案,别让用户跑到深处才撞异常。
  2. 消息里写明"哪个平台支持、当前平台有什么替代手段"。
  3. 单元测试要用 [Fact(Skip = ...)] 或 OperatingSystem 守卫,避免 CI 在 Linux 上跑 Windows 用例全红。

2.6 TimeoutException ------ "等太久了"

语义:操作在约定时间内未完成。

注意:它表示等待策略耗尽,不代表对方失败、也不代表操作没发生(可能已经在执行,重复重试会造成双重扣款之类的问题)。

复制代码
public async Task<string> FetchWithTimeoutAsync(HttpClient client, Uri uri, TimeSpan timeout)
{
    using var cts = new CancellationTokenSource(timeout);
    try
    {
        return await client.GetStringAsync(uri, cts.Token);
    }
    catch (OperationCanceledException) when (!cts.Token.IsCancellationRequested)
    {
        // HttpClient 超时会抛出 TaskCanceledException(派生自 OperationCanceledException)
        throw new TimeoutException($"GET {uri} timed out after {timeout.TotalSeconds}s.");
    }
}

// 同步/协议级超时:串口、WCF、自定义握手
public byte[] ReadExactly(SerialPort port, int count, int millisecondsTimeout)
{
    var buf = new byte[count];
    var end = DateTime.UtcNow.AddMilliseconds(millisecondsTimeout);
    int read = 0;
    while (read < count)
    {
        if (DateTime.UtcNow > end)
            throw new TimeoutException($"Expected {count} bytes, got {read}.");
        port.ReadTimeout = Math.Max(1, (int)(end - DateTime.UtcNow).TotalMilliseconds);
        read += port.Read(buf, read, count - read);
    }
    return buf;
}

适用场景:

  • 同步阻塞式 IO、串口/套接字读写、协议握手
  • 分布式锁 / 信号量等待超时(SemaphoreSlim.Wait(timeout) 返回 false 时可转抛)
  • 外部系统调用的客户端封装(把底层的 TaskCanceledException 统一翻译成业务可读的 TimeoutException)
  • 缓存击穿防护中的"取不到就别等"

重要陷阱:TPL / 异步取消不用它:

  • CancellationToken 超时 → 抛 OperationCanceledException / TaskCanceledException
  • HttpClient 超时 → TaskCanceledException
  • Task.Wait/Result 超时 → 返回 false 或包在 AggregateException 里

也就是说:TimeoutException 是"协议/同步等待"的语言,OperationCanceledException 是"异步取消"的语言 。混用会让 catch 逻辑全部失效。另外,捕获 TimeoutException 后重试一定要问自己:上游是不是已经生效了?(幂等性 / 补偿机制)

三、把以上异常放在一个真实场景里对照

下面是一个假想的"消息发送器",四类异常各司其职:

复制代码
public class SmtpSender : IDisposable
{
    private bool _disposed;
    private bool _connected;
    private readonly int _timeoutMs;

    public SmtpSender(int timeoutMs = 5000) => _timeoutMs = timeoutMs;

    public void Connect()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_connected) throw new InvalidOperationException("Already connected.");
        // ... 真正连
        _connected = true;
    }

    public void Send(string to, string body)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (!_connected) throw new InvalidOperationException("Send requires Connect() first.");
        // ... 发送,带超时        
        // 组合:调用方超时 + 本类默认超时
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        linkedCts.CancelAfter(_timeoutMs);
        try
        {
            await _client.SendMailAsync(to, body, linkedCts.Token);
        }
        catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
        {
            // 是自己的超时触发的 → 转成业务可读的 TimeoutException
            throw new TimeoutException($"Send timed out after {_timeoutMs}ms.", ex);
        }
        // 调用方主动取消:原样抛出 OperationCanceledException,不要包成 TimeoutException
    }

    // 这个实现只会发文本,附件从设计上就不支持
    public virtual void SendWithAttachment(string to, string body, string path) =>
        throw new NotSupportedException("Attachments are not supported by this sender.");

    // 下个迭代才接的模板功能
    public void SendTemplate(string templateId, object model) =>
        throw new NotImplementedException("Template sending planned for v2.");

    // 只在 Windows 上可用的 SSO 登录
    public void LoginWithCurrentUser()
    {
        if (!OperatingSystem.IsWindows())
            throw new PlatformNotSupportedException("SSPI login requires Windows.");
    }

    public void Dispose()
    {
        if (_disposed) return;
        _connected = false;
        _disposed = true;
    }
}

同一个类里五种异常各归其位,调用方靠类型就能判断"我该重连、该换实现、该升级版本、还是该换操作系统"。

四、小小的总结

这六种异常处理的都是"参数没错,但调用依然失败"的情况,核心在于区分失败的原因究竟是对象还活着、能力是否存在还是时机对不对。

按性质可分为三组:

  1. 生命周期问题只有 ObjectDisposedException,表示对象已释放且不可恢复,前提是类型实现了 IDisposable,否则应退回 InvalidOperationException;
  2. 能力问题包含 NotSupportedException(设计上就不支持)、NotImplementedException(暂时没写完,上线前应清零)以及 PlatformNotSupportedException(当前平台不支持),三者均属永久性失败,重试毫无意义;
  3. 时序与环境问题则是 InvalidOperationException(状态或顺序不对,换个时机可能成功)和 TimeoutException(等待策略耗尽,不等于操作未发生)。

**选型时依次自问:对象释放了吗?能力是设计上没有、还没写,还是平台不支持?状态不对吗?等太久了吗?**每个答案对应不同的修复方向:治理生命周期、收窄接口或补全实现、修正调用时序、改进超时与取消策略。

需要留意三个易错点:**ArgumentException 与 InvalidOperationException 的区别在于同样参数换个时机能否成功;后两者属于设计问题与进度问题,选反会误导调用方;同步超时用 TimeoutException,异步取消则走 OperationCanceledException,混用会导致 catch 逻辑失效。**此外,继承关系决定了 catch 顺序必须从细到粗,先捕获子类再捕获父类。

相关推荐
架构技术专栏2 小时前
为什么 AI 公司都想做Personal Agent ?
后端
用户761016088472 小时前
黑马RabbitMQ 从入门到可靠性:收发、持久化、死信延迟一篇打通
后端
Bazingga2 小时前
Transformer与自注意力机制:一台"全员检索"机器的说明书
人工智能·后端
星辰云-2 小时前
# 基于Spring Boot的德州特产扒鸡商城系统(毕设|源码|论文|演示视频)
spring boot·后端·课程设计
武子康2 小时前
同为7/8,错误样本却不同:Agent Grader 分歧复算
人工智能·后端·agent
茉莉玫瑰花茶2 小时前
GO [ 内存与变量逃逸分析 ]
开发语言·后端·golang
星辰云-2 小时前
基于Spring Boot的旅游景点票务系统的设计与实现(毕设项目,附完整源码)
spring boot·后端·课程设计
Rain的Java大神之路3 小时前
百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成
java·后端·面试
山间小僧3 小时前
Agent Memory(三)团队记忆:以 TencentDB Agent Memory 为例
后端·agent