C# 中所有异常的基类只有一个,即 System.Exception。它位于异常类型继承树的最顶端,定义了异常对象必须具备的基础信息,例如错误描述 Message、堆栈跟踪 StackTrace、内部异常 InnerException 等。无论是 .NET 框架内置的 IOException、NullReferenceException,还是开发者自行创建的业务异常,最终都会沿着继承链汇聚到 Exception。掌握这个基类的意义在于,后续的 catch 匹配、异常包装和日志记录都会围绕它展开。

System.Exception 是所有异常类型的基类
在 .NET 异常体系中,System.Exception 处于继承链的最顶端。它下面可以分为两大分支:System.SystemException 和 System.ApplicationException。早期 .NET 开发指南曾建议用户自定义异常继承 ApplicationException,但这一建议后来被 .NET 团队认为并不合适,因为很多自定义异常和应用程序异常并不形成清晰边界,而且框架内部也没有严格遵循这一规则。现在更推荐的做法是直接从 Exception 派生自定义异常。
Exception 类本身提供了一组核心成员。Message 用于保存人类可读的错误说明,StackTrace 记录异常抛出时的调用堆栈,InnerException 允许在包装异常时保留原始异常对象,Data 是一个键值对容器,适合承载结构化上下文信息。此外还有 Source、HelpLink、HResult、TargetSite 等属性。理解这些成员有助于在日志中记录更完整的故障信息,而不是只输出 Message 而丢掉关键线索。
下面这个自定义异常展示了从 Exception 派生的基本形式,构造函数通过 base 调用把参数传递给基类,从而复用 Exception 的初始化逻辑。
public class OrderNotFoundException : Exception
{
public OrderNotFoundException() { }
public OrderNotFoundException(string message)
: base(message) { }
public OrderNotFoundException(string message, Exception innerException)
: base(message, innerException) { }
public OrderNotFoundException(string orderId, Exception innerException)
: base($"订单 {orderId} 不存在。", innerException) { }
}
catch 块的匹配顺序与基类的关系
由于异常捕获是按照类型兼容性进行匹配,继承关系会直接影响 catch 块的书写顺序。当 try 代码块抛出异常时,CLR 会从上到下检查每一个 catch 子句,一旦发现某个 catch 声明的异常类型与抛出对象的类型相同,或者该类型是抛出对象类型的基类,就进入该 catch。这意味着如果最上面的 catch 捕获了 Exception,后面的所有 catch 都不会被执行,编译器甚至会直接给出错误。
例如在一个文件读取操作中,如果先捕获 IOException,再捕获 FileNotFoundException,就会违反派生类优先原则。因为 FileNotFoundException 派生自 IOException,前一个 catch 已经覆盖了后一个。相反,正确顺序应当是先捕获最具体的 FileNotFoundException,再捕获 IOException,最后捕获 Exception。
try
{
string content = File.ReadAllText(path);
}
catch (FileNotFoundException)
{
// 文件不存在时执行特定处理
}
catch (IOException)
{
// 其他 I/O 错误
}
catch (Exception)
{
// 兜底:记录未预知的异常
}
有一种观点认为,捕获 Exception 是万能做法,可以保证程序不崩溃。但从工程角度看,这通常只适合作为全局最后防线,比如记录日志并友好提示,而不适合在业务逻辑中到处使用。因为捕获了基类异常后,你很难判断具体发生了什么,也容易把编程错误、内存不足等问题和普通业务失败混在一起。合理的策略是:能处理的派生异常尽量单独捕获,无法处理时再交给全局兜底或向上抛出。
throw 与 throw ex 对基类堆栈信息的影响
在 catch 块中重新抛出异常时,throw 和 throw ex 的差异常被忽略,但它关系到 Exception 类中 StackTrace 属性的准确性。throw 单独使用时,会重新抛出当前异常对象,保持原始 StackTrace 不变,这意味着调用链最顶端的出错位置仍然可以看到。而 throw ex 会把 ex 对象的抛出位置重置为当前 catch 语句,StackTrace 将从这里重新开始计算,之前的调用层级会被截断。
为什么这点重要?因为当异常被上层日志系统记录时,StackTrace 是定位问题的关键。如果堆栈被重置,日志可能显示错误发生在某个无关的 catch 位置,而不是真正产生故障的方法。例如一个仓储层调用 API 失败,业务层捕获后使用 throw ex 抛出,最终日志会指向业务层而不是底层 HTTP 客户端。应尽量使用 throw 来保持原始异常信息,如果需要增加额外上下文,则可以创建新异常并把原异常放入 InnerException。
try
{
ExecuteRemoteCall();
}
catch (Exception ex)
{
// 方案一:直接原样重抛,保留最初堆栈
// throw;
// 方案二:包装为新异常,并把原始异常传入 innerException
throw new BusinessException("远程调用失败,请稍后重试。", ex);
}
常见异常派生类型与自定义异常设计
System.Exception 之下有大量内置派生类型,理解它们能帮助开发者更精确地捕获和处理错误。SystemException 派生了若干常见异常:ArgumentException 是参数错误的总基类,它下面的 ArgumentNullException 和 ArgumentOutOfRangeException 用于更具体的场景;InvalidOperationException 表示当前对象状态不支持该操作;NotSupportedException 表示调用了未实现的方法;IOException 用于文件和网络等输入输出错误。数据库访问中常见 SqlException,Web 开发中可能出现 HttpRequestException,它们最终都继承自 Exception。
自定义异常时,除了选择正确的基类,还应遵循一致的构造函数模式。一个规范的自定义异常通常提供无参构造函数、只带 message 的构造函数、带 message 和 innerException 的构造函数。如果异常需要跨应用程序域或序列化场景,历史上还需要实现序列化构造函数,但随着 .NET Core 的演进,这一要求已经不再像 .NET Framework 时代那样严格。无论如何,带 innerException 的构造函数是包装底层异常的关键,它让上层能够在保留原始错误的同时补充业务语义。
[Serializable]
public class PaymentFailedException : Exception
{
public PaymentFailedException() { }
public PaymentFailedException(string message)
: base(message) { }
public PaymentFailedException(string message, Exception innerException)
: base(message, innerException) { }
}
在实际使用中,当支付服务抛出一个更底层的 HttpRequestException 或 TimeoutException 时,业务层可以捕获并包装为 PaymentFailedException,同时把原异常放在 innerException 中。这样调用方既能根据 PaymentFailedException 编写针对性的处理逻辑,又能通过 InnerException 查看真实原因。若只是捕获 Exception 然后抛出一个新的不带 innerException 的异常,原始错误就会丢失,排查成本会明显上升。
C#异常处理Exception基类自定义异常修改时间:2026-10-05 12:20:00