C# 异常处理
程序运行总会遇到意外——文件不存在、网络中断、参数非法、内存不够。异常处理就是优雅地应对这些意外,让程序不崩溃、给出有意义的错误信息。C# 的异常机制设计清晰、表达力强,这一篇我们完整过一遍。
一、异常是什么?
异常(Exception)是程序运行时发生的不正常情况。C# 所有异常都继承自 System.Exception。当异常发生:
- 程序停止当前执行流,寻找匹配的
catch。 - 找到则执行 catch 块,然后继续。
- 找不到则沿调用栈向上抛,直到顶层(Main 之外)导致程序终止。
异常 vs 返回错误码:异常更适合不可预期的错误(IO 失败、网络中断),返回值更适合预期的业务结果(用户没找到、密码错误)。
二、try / catch / finally 基本结构
try
{
int[] nums = { 1, 2, 3 };
Console.WriteLine(nums[10]); // 越界
}
catch (IndexOutOfRangeException ex)
{
// 捕获特定异常类型
Console.WriteLine($"索引错误: {ex.Message}");
}
catch (Exception ex)
{
// 捕获所有异常(放在最后)
Console.WriteLine($"未知错误: {ex.Message}");
}
finally
{
// 不管是否异常都执行(资源清理)
Console.WriteLine("清理完成");
}
// 异常传播:没匹配 catch 会向外抛
// finally 始终执行,即使 try/catch 里 return规则:
- try 块:可能抛异常的代码。
- catch 块:捕获并处理。从具体到一般排序(具体的放前,Exception 放最后)。
- finally 块:无论是否异常都会执行。用于资源清理(关闭文件、释放锁)。
- try 必须配 catch 或 finally(至少一个)。
三、throw 抛出异常
public decimal Divide(int a, int b)
{
if (b == 0)
throw new DivideByZeroException("除数不能为零");
return (decimal)a / b;
}
// 调用方
try
{
decimal r = Divide(10, 0);
}
catch (DivideByZeroException ex)
{
Console.WriteLine(ex.Message); // "除数不能为零"
}
// 重新抛出保留堆栈
try { /* ... */ }
catch (Exception ex)
{
Log(ex);
throw; // ✅ 保留原堆栈(不要写 throw ex;)
}
// 抛出新异常并包装原因
catch (Exception ex) when (ex is IOException or SQLException)
{
throw new MyAppException("数据处理失败", ex); // inner exception
}几个关键实践:
- 参数校验:公共方法入口检查参数,非法立即 throw(快速失败)。
- throw 重新抛出:用
throw;(不带 ex),保留原堆栈;不要写throw ex;会重置堆栈。 - 包装异常:底层 IO 异常包装成业务异常,通过 inner exception 保留原信息。
- 异常消息:对人类可读,包含足够信息定位问题。
四、常见 .NET 异常类型
// 常见系统异常(按需捕获)
Exception // 所有异常的基类
SystemException // 系统级异常基类
├── NullReferenceException // 访问 null 引用
├── IndexOutOfRangeException // 数组越界
├── ArgumentException // 参数无效(基类)
│ └── ArgumentNullException // 参数为 null
│ └── ArgumentOutOfRangeException
├── InvalidOperationException
├── InvalidCastException
├── FormatException // 字符串格式错误(Parse 失败)
├── OverflowException // 算术溢出
├── DivideByZeroException // 除零
├── NotImplementedException
├── TimeoutException
├── StackOverflowException // 栈溢出(不可捕获)
├── OutOfMemoryException // 内存不足(通常不可恢复)
├── IOException // IO 异常基类
│ ├── FileNotFoundException
│ └── DirectoryNotFoundException
└── ApplicationException(已不推荐使用)日常捕获的常见异常:
- NullReferenceException:最常见的 bug,通过 null 检查/可空引用避免。
- ArgumentException:参数校验失败。
- InvalidOperationException:对象状态不合法(如枚举时改集合)。
- IOException:文件/网络 IO 错误。
- HttpRequestException:HTTP 请求失败。
- TaskCanceledException:异步任务超时或取消。
五、自定义异常
业务异常建议自定义,让代码更清晰:
// 自定义异常:继承 Exception,以 Exception 结尾命名
public class UserNotFoundException : Exception
{
public string UserId { get; }
public UserNotFoundException(string userId)
: base($"用户 {userId} 不存在")
{
UserId = userId;
}
public UserNotFoundException(string userId, Exception inner)
: base($"用户 {userId} 不存在", inner)
{
UserId = userId;
}
}
// 抛出
public User GetUser(string id)
{
if (id == "deleted")
throw new UserNotFoundException(id);
return new User { Id = id };
}
// 捕获
try
{
GetUser("deleted");
}
catch (UserNotFoundException ex)
{
Console.WriteLine($"{ex.UserId} - {ex.Message}");
}自定义异常的规则:
- 继承
Exception(不推荐 ApplicationException,微软已弃用)。 - 名字以
Exception结尾。 - 提供三个构造函数:无参、(message)、(message, inner)。
- 可加业务相关属性(如 UserId、ErrorCode)。
- 标记 [Serializable](如需要跨 AppDomain/进程)。
六、when 异常过滤器(C# 6+)
catch 加 when 条件,更精细控制:
// when 异常过滤器(C# 6+):catch 加条件
try
{
Dangerous();
}
catch (HttpRequestException ex) when (ex.StatusCode == 404)
{
Console.WriteLine("资源不存在");
}
catch (HttpRequestException ex) when (ex.StatusCode >= 500)
{
Console.WriteLine("服务器错误");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"其他 HTTP 错误: {ex.Message}");
}
// 过滤器内可以打日志(不进入 catch 的话堆栈保留)
catch (Exception ex) when (Log(ex)) { }
// Log 返回 false 时不进入,继续向外抛when 的优势:
- 不进入时保留堆栈(不像 catch + re-throw 会重置堆栈)。
- 可记录日志(
when (Log(ex)))。 - 同类型不同条件多 catch 分支。
七、using 与 IDisposable(资源释放)
异常处理的一个重要场景是资源释放——文件、数据库连接、网络流。using 语句确保即使抛异常也能 Dispose:
// using 语句:自动 Dispose(即使抛异常)
using (var fs = new FileStream("a.txt", FileMode.Open))
{
// 用 fs
} // 离开花括号自动调 fs.Dispose()
// using 声明(C# 8+,无需花括号)
using var fs2 = new FileStream("a.txt", FileMode.Open);
// 在方法结束时自动 Dispose
// 实现 IDisposable 的类必须用 using 或手动 Dispose
// 如:FileStream、HttpClient、SqlConnection、Bitmap
public class Resource : IDisposable
{
public void Dispose()
{
// 释放资源
GC.SuppressFinalize(this);
}
}规则:
- 实现了
IDisposable的类必须释放(用 using 或手动 Dispose)。 - using 声明(C# 8+):
using var fs = ...;更简洁,方法结束自动 Dispose。 - 注意:
using不会管非托管资源(IntPtr、HGlobal),那要单独释放。
八、异常处理最佳实践
- 不要吞异常:空 catch 块是灾难。
catch (Exception) { }等于"假装没事",问题被掩盖。 - 捕获要具体:不要无脑
catch (Exception ex),捕获你能处理的具体类型。 - 不要用异常控制流:别用 try/catch 替代 if。预期结果用返回值,异常用于"异常"。
- 参数校验用 ArgumentException:不要返回 null,要 throw。
- 记录日志:catch 里至少 log,便于排查。
- 能恢复就恢复,不能就向上抛:别在中间层"消化"你处理不了的异常。
- 异常消息要清楚:包含发生了什么、为什么、怎么解决。
九、TryXxx 模式 vs 异常
// TryXxx 模式:返回 bool,不抛异常,适合"预期的失败"
if (int.TryParse("abc", out int n))
Console.WriteLine($"成功: {n}");
else
Console.WriteLine("不是数字");
// Parse 模式:抛异常,适合"不该失败"的场景
int n2 = int.Parse("123"); // 失败抛 FormatException
// 建议:用户输入用 TryParse,内部数据用 Parse
// Parse 性能更高(TryParse 在成功时一样快,失败时更快).NET 里大量 API 提供 TryXxx 版本:int.TryParse、Dictionary.TryGetValue、Queue.TryDequeue。
十、异步异常
// async 方法抛异常,会被包进 Task 里
async Task<int> GetAsync()
{
throw new InvalidOperationException("出错");
}
// 调用:await 时 unwrap 异常
try
{
await GetAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
// Task.WhenAny / WhenAll 异常
var t1 = Task.Run(() => throw new Exception("A"));
var t2 = Task.Run(() => throw new Exception("B"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex) when (ex.Message == "A")
{
// 只捕获第一个抛出的异常
}
// 想看所有异常:从 Task 异常属性读十一、全局异常处理
有些异常总会逃出 try/catch。需要全局兜底:
// 1. 主线程未捕获异常
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
var ex = (Exception)e.ExceptionObject;
LogError(ex);
};
// 2. Windows Forms / WPF
Application.ThreadException += (s, e) => LogError(e.Exception);
// 3. ASP.NET Core 全局中间件
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerPathFeature>()?.Error;
await LogAsync(ex);
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Internal Error");
});
});
// 4. TaskScheduler 未观察异常
TaskScheduler.UnobservedTaskException += (s, e) =>
{
LogError(e.Exception);
e.SetObserved(); // 标记已处理
};十二、性能注意
- 抛异常很贵:抓堆栈、跨栈传播,微秒级开销。
- 不要在循环里抛:几百万次抛异常会拖慢程序几十倍。
- 用 TryParse 替代 Parse + catch。
- 开发期异常无关紧要:可读性优先;性能瓶颈处再优化。
小结
异常处理是工程化的必备技能。记住几条:具体捕获、不要吞、throw 保留堆栈、using 释放资源、TryXxx 用于预期失败。下一篇我们看文件 IO——异常处理的最佳实战场景。
← 上一篇 C# LINQ
下一篇 C# 文件 IO →