〇、前言
前文已经列举了全部有哪些常见的异常类型,本文继续介绍下各个类别的异常特点。
本文主要介绍对象状态与操作合法性异常,主要包括四种:InvalidOperationException、ObjectDisposedException、NotSupportedException、NotImplementedException、PlatformNotSupportedException、TimeoutException。
点击链接可以查看本系列文章:C# Exception 详解系列。
一、参数校验类异常总览
**此类异常的共同点是:调用方的代码语法没错、参数也没错,错的是"时机"或"环境"。**主要应对以下三个问题------对象还活着吗?对象现在处于允许该操作的状态吗?这个能力到底有没有? 选型的本质就是在这三者之间做区分。
| 异常类型 | 核心含义 | 永久性 | 继承关系 | 重试有用? | 首选替代 |
|---|---|---|---|---|---|
| InvalidOperationException | 状态/时序不对 | 否(重排序可能成功) | Exception |
看情况 | 拆接口、状态机 |
| ObjectDisposedException | 已释放 | 是 | InvalidOperationException |
无用 | 生命周期治理 |
| NotSupportedException | 设计上无此能力 | 是 | Exception |
无用 | 换窄接口 / CanXxx 探测 |
| NotImplementedException | 还没写 | 否(将来会有) | Exception |
无用 | 补全实现 |
| PlatformNotSupportedException | 当前平台无此能力 | 是(换机器才行) | NotSupportedException |
无用 | 运行时平台分支降级 |
| TimeoutException | 等超时了 | 否(重试可能成功) | Exception |
谨慎(幂等性) | CancellationToken |
最容易选错的三对:
ArgumentExceptionvsInvalidOperationException:前者是"你传的东西不对",后者是"东西对,但现在不能做"。判断标准:同样的参数换个时机能不能成功?能 → InvalidOperation。NotSupportedExceptionvsNotImplementedException:设计问题 vs 进度问题。选反了调用方会做出错误决策(比如绕开你这个实现,或者傻傻等你发版)。TimeoutExceptionvsOperationCanceledException:同步/协议超时 vs 异步取消。前者在System.Runtime,后者在System.Threading,连命名空间都不一样。
关键记忆点:ObjectDisposedException 继承自 InvalidOperationException;PlatformNotSupportedException 继承自 NotSupportedException。所以 catch 基类时会"顺带"捕获子类,顺序写反就会吞掉更精确的信息。
以下是几条通用纪律,需要注意。
- 带上上下文 :message 里写清"当前状态 / 期望状态 / 哪个对象 / 哪个平台 / 超时多少毫秒"。这些异常在线上通常意味着流程缺陷,光看类型不够定位。
- XML 文档标注 :
/// <exception cref="...">,让调用方在 IDE 里就能看到前置条件。 - 能用布尔/枚举/接口表达的,不要用异常 :
TryOpen()、CanWrite、IReadOnlyList<T>都优于"调了再 catch"。 - 不要在 hot path 上用它们做控制流 :
foreach里靠 catchNotSupportedException判断能否写入,性能会很难看。 - catch 顺序从细到粗 :先
ObjectDisposedException再InvalidOperationException;先PlatformNotSupportedException再NotSupportedException。 - 自定义异常要克制 :这六种已经覆盖了 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判断)
常见误用:
- 把参数错包成它 :
index越界应抛ArgumentOutOfRangeException,不是 InvalidOperation。 - 用它掩盖设计缺陷 :如果某方法"常常不能调用",说明接口太胖,应拆成
ICanStart/IStartable等小接口,让不支持的状态在编译期就不可见。 - 消息里不带状态信息 。好的 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)之外的运行时分支
最佳实践:
- 优先用
RuntimeInformation.IsOSPlatform/OperatingSystem.IsWindows()做主动分支 ,优雅降级或返回替代方案,别让用户跑到深处才撞异常。 - 消息里写明"哪个平台支持、当前平台有什么替代手段"。
- 单元测试要用
[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/TaskCanceledExceptionHttpClient超时 →TaskCanceledExceptionTask.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;
}
}
同一个类里五种异常各归其位,调用方靠类型就能判断"我该重连、该换实现、该升级版本、还是该换操作系统"。
四、小小的总结
这六种异常处理的都是"参数没错,但调用依然失败"的情况,核心在于区分失败的原因究竟是对象还活着、能力是否存在还是时机对不对。
按性质可分为三组:
- 生命周期问题只有 ObjectDisposedException,表示对象已释放且不可恢复,前提是类型实现了 IDisposable,否则应退回 InvalidOperationException;
- 能力问题包含 NotSupportedException(设计上就不支持)、NotImplementedException(暂时没写完,上线前应清零)以及 PlatformNotSupportedException(当前平台不支持),三者均属永久性失败,重试毫无意义;
- 时序与环境问题则是 InvalidOperationException(状态或顺序不对,换个时机可能成功)和 TimeoutException(等待策略耗尽,不等于操作未发生)。
**选型时依次自问:对象释放了吗?能力是设计上没有、还没写,还是平台不支持?状态不对吗?等太久了吗?**每个答案对应不同的修复方向:治理生命周期、收窄接口或补全实现、修正调用时序、改进超时与取消策略。
需要留意三个易错点:**ArgumentException 与 InvalidOperationException 的区别在于同样参数换个时机能否成功;后两者属于设计问题与进度问题,选反会误导调用方;同步超时用 TimeoutException,异步取消则走 OperationCanceledException,混用会导致 catch 逻辑失效。**此外,继承关系决定了 catch 顺序必须从细到粗,先捕获子类再捕获父类。