二十、异常与状态管理(Exception&State Management)
异常处理是现代编程语言中不可或缺的机制,.NET平台通过
try-catch-finally结构和强大的异常类型系统,为开发者提供了高效、类型安全的错误控制能力。在《CLR via C#》第20章中,Jeffrey Richter 以系统性的方式剖析了异常与程序状态之间的本质联系。本文将围绕该章节的精华内容展开讲解。
🔍 什么是“异常”?为什么要抛出异常?
异常代表方法未能完成其既定任务。它是一种信号,告诉调用者:当前方法执行失败,必须采取额外措施进行处理。
举例说明:
public void Transfer(Account from, Account to, decimal amount) {
if (from == null || to == null) throw new ArgumentNullException();
if (amount <= 0) throw new ArgumentOutOfRangeException();
// 执行转账逻辑
}
此设计比返回错误码更优雅,因它迫使调用方必须显式处理失败路径,确保系统稳定性。
⚙️ 异常处理机制详解(try-catch-finally)
.NET 使用标准结构处理异常:
try {
// 可能抛出异常的代码
}
catch (InvalidOperationException ex) {
// 针对 InvalidOperationException 类型的异常进行恢复处理
}
finally {
// 无论异常是否发生,都会执行(如释放资源)
}
💡 注意事项:
-
catch块必须从最具体的异常类型写起,逐步过渡到一般类型(如Exception)。 -
finally通常用于释放锁、关闭文件、回收资源等清理逻辑。
🧠 异常匹配机制:CLR如何决定走哪个catch?
当异常被抛出后,CLR 会从 try 所属的多个 catch 中查找第一个能匹配异常类型的块。类型匹配规则与继承一致。
示例:
catch (IOException ex) { ... } // 捕获 I/O 异常
catch (Exception ex) { ... } // 捕获所有异常(兜底)
匹配时的最佳实践是:按继承深度顺序排序,避免捕获过于宽泛的异常类型影响诊断。
🧰 如何访问异常信息?
-
可通过
catch (Exception ex)中的ex对象获取错误信息。 -
在调试器中使用
$exception变量,查看当前异常的详细堆栈、调用位置、异常源。
🛑 如果没有catch怎么办?
若抛出的异常未被任何 catch 块处理:
-
CLR 会沿调用堆栈逐层查找catch块。
-
若最终未处理,将引发程序崩溃(终止进程),并记录日志或转入系统默认异常处理程序。
🌩 异常处理基础
当 try 块中代码抛出异常后,CLR 会尝试在调用堆栈中找到匹配类型的 catch 块来处理异常。你有三种方式处理异常:
-
重新抛出同一个异常:保留原始异常信息传递到调用者。
-
抛出新的异常:包装原始异常以提供更多上下文信息。
-
不处理异常:执行完
catch后不再抛出异常,继续进入finally(若有)或后续代码。
异常对象可通过 catch (Exception e) 捕获,并访问其堆栈信息等,但不推荐修改这个异常对象
🧹 finally 块的作用与语义
finally 块用于执行清理操作,其代码无论是否发生异常都保证被执行。例如处理文件流时:
FileStream fs = null;
try {
fs = new FileStream(path, FileMode.Open);
} catch (IOException) {
// 错误处理
} finally {
if (fs != null) fs.Close(); // 确保文件关闭
}
📦 System.Exception 类详解
这是所有标准异常的基类,包含多个实用属性:
| 属性名 | 说明 |
|---|---|
Message |
错误说明,技术性描述 |
Data |
异常附带的键值集合 |
Source |
抛出异常的程序集名 |
StackTrace |
方法调用栈 |
TargetSite |
抛出异常的方法 |
HelpLink |
异常文档链接地址 |
异常对象的核心属性
System.Exception 是所有异常的基类,它提供了多个关键属性:
-
InnerException:指向引发当前异常的原始异常,形成链式追踪结构,方便定位根本原因。
-
HResult:用于跨越托管与非托管边界,保留 COM 异常的 HRESULT 值。
-
StackTrace:调用链的字符串表示,有助于开发者定位异常来源。
📌 注意:StackTrace 不是简单的字符串,它是通过调用 CLR 内部逻辑动态生成的,仅在异常真正被抛出之后才有值
重新抛出异常的陷阱
代码中常见的错误是使用 throw e; 重新抛出异常,这样会重置异常的起始位置,导致 StackTrace 误导调试。
catch (Exception e)
{
// 错误方式:重设了堆栈起点
throw e;
}
正确方式是使用裸 throw;,保留原始堆栈信息:
catch (Exception e)
{
// 正确方式
throw;
}
🧠 深入理解:虽然 CLR 保留了正确的位置,但操作系统的错误报告如 Windows Error Reporting 会显示最后一次 throw 的位置,影响实地调试
堆栈信息的完整性和 JIT 优化
默认情况下,JIT 编译器可能会将一些方法“内联”,使得这些方法在堆栈跟踪中缺失。
为避免这一点:
[MethodImpl(MethodImplOptions.NoInlining)]
public void DoNotInline()
{
...
}
此外,使用 /debug 编译开关或设置 DebuggableAttribute 也可以控制 JIT 的内联行为,从而获取更完整的调试信息
标准异常类型体系
FCL 中提供了丰富的异常类层级体系,涵盖了系统错误、IO、反射、序列化、线程等领域。
常用异常包括:
NullReferenceExceptionArgumentException/ArgumentNullExceptionInvalidOperationExceptionIOException/FileNotFoundExceptionFormatException/OverflowException
这张树形图表明实际开发中不一定严格遵循“CLR异常派生自 SystemException,应用异常派生自 ApplicationException”的规则。
🧩 设计教训:过度依赖 ApplicationException 是反模式,建议自定义异常直接继承 Exception 并以“Exception”结尾命名,如 UserLoginFailedException
如何抛出异常(Throwing an Exception)
- 选择异常类型很重要:你应该抛出对调用者具有实际意义的异常类型,通常是
System.Exception的派生类。 - 避免抛出基类异常:比如
System.Exception或System.SystemException,因为这样会让调用者难以精确捕捉和处理。 - 版本控制影响:创建继承自已有异常类型的新异常可能会被之前处理基类的 catch 语句捕捉,导致不可预期的行为。
自定义异常类型(Defining Your Own Exception Class)
为了让异常更具语义性,同时支持序列化,书中定义了一个泛型异常基类:
[Serializable]
public sealed class Exception<TExceptionArgs> : Exception, ISerializable
where TExceptionArgs : ExceptionArgs
{
private readonly TExceptionArgs m_args;
public TExceptionArgs Args => m_args;
public Exception(TExceptionArgs args, string message = null, Exception inner = null)
: base(message, inner) => m_args = args;
public override string Message => (m_args == null) ? base.Message : base.Message + " (" + m_args.Message + ")";
}
还定义了一个简单的 ExceptionArgs 抽象类,用来封装参数:
[Serializable]
public abstract class ExceptionArgs {
public virtual string Message => string.Empty;
}
使用示例:
throw new Exception<DiskFullExceptionArgs>(
new DiskFullExceptionArgs(@"C:\"), "The disk is full");
减轻状态损坏:
一些建议
- 使用
finally块: 确保资源得到正确清理,防止资源泄漏。 - 代码契约: 使用代码契约来验证方法参数,确保输入数据的有效性。
- 约束执行区域 (CER): 减少 CLR 的不确定性,提高异常处理代码的可靠性。
- 事务: 使用事务来确保状态修改的原子性,要么全部成功,要么全部失败。
- 显式方法: 避免使用隐式操作,减少潜在的异常发生点。
- FailFast: 当状态已经损坏到无法恢复时,快速结束进程,避免程序继续运行造成更大的损失。
Unity中常见异常情景实践建议
using System;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
public class ExceptionDemo : MonoBehaviour
{
void Start()
{
SafeParseAndUse();
SafeFileOperation();
StartCoroutine(RunCoroutineSafely());
try
{
var result = DangerousOperation();
Debug.Log($"结果:{result}");
}
catch (InvalidOperationException ex)
{
Debug.LogError($"关键错误: {ex.Message}");
Environment.FailFast("系统已损坏", ex); // 致命异常处理
}
}
// 1. 使用 TryParse 避免 Parse 异常
void SafeParseAndUse()
{
string input = "123abc";
if (int.TryParse(input, out int number))
{
Debug.Log($"转换成功:{number}");
}
else
{
Debug.LogWarning("输入无效,无法转换为整数");
}
}
// 2. 使用 try-finally 管理资源
void SafeFileOperation()
{
FileStream fs = null;
try
{
fs = new FileStream("data.txt", FileMode.OpenOrCreate);
// 读写文件
}
catch (IOException ioEx)
{
Debug.LogError($"文件异常: {ioEx.Message}");
}
finally
{
fs?.Close(); // 确保关闭
}
}
// 3. 封装任务错误,避免抛出异常影响控制流
public static (bool success, string data, Exception error) LoadConfig()
{
try
{
string data = File.ReadAllText("config.json");
return (true, data, null);
}
catch (Exception ex)
{
return (false, null, ex); // 返回错误元组
}
}
// 4. Unity中协程异常捕获
System.Collections.IEnumerator RunCoroutineSafely()
{
try
{
yield return new WaitForSeconds(1);
throw new Exception("协程内错误");
}
catch (Exception ex)
{
Debug.LogError($"协程异常:{ex}");
}
}
// 5. 模拟致命错误
string DangerousOperation()
{
throw new InvalidOperationException("配置缺失,无法恢复");
}
}
作者:世纪末的魔术师
出处:https://www.cnblogs.com/Firepad-magic/
Unity最受欢迎插件推荐:点击查看
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

浙公网安备 33010602011771号