异常基础
本教程共 100 篇 · 第 73 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
73. 异常基础
本节目标:学完你能在可能出错的代码外加 try/catch 兜底,用 finally 做清理,并理解异常会沿调用栈自动向上传递。
程序运行时总有”预料之外”:读了一个空字符串去转数字、数组下标越了界、除数是零。这些情况下 C# 会抛出一个异常(exception)。异常处理(exception handling)就是给程序系上”安全带”,让它在出错时不至于直接崩溃。
为什么需要异常处理
在没有异常机制的语言里,程序员通常用”返回错误码”来报告失败——每个方法多返回一个整数,0 代表成功,非 0 代表某种错误。问题在于:错误码很容易被调用方忽略,方法链一长,谁都忘了检查,错误就一路带着走到离源头很远的地方才爆发,那时早已无从查起。
C# 的异常机制反其道而行:一旦出错就”中断当前流程、一路向上冒”,除非有人显式接住。这逼着错误无法被悄悄忽略——要么你处理它,要么程序明确停下并给出堆栈。换句话说,异常让”出错了”这件事想藏都藏不住。
Note异常(exception)不等于”错误(error)“。它是 C# 用来传递”运行期意外状况”的一个对象,本身也是普通类,携带消息、堆栈等诊断信息。
try / catch / finally 三件套
异常处理的三个关键字是 try、catch、finally:
try:把”可能出错”的代码包起来。catch:出错时接管,决定怎么处理。finally:不管出没出错,都一定会执行,常用来释放资源。
namespace CSharpDemo;
int Divide(int a, int b) => a / b;
try
{
int result = Divide(10, 0);
Console.WriteLine(result);
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"出错:{ex.Message}");
}
finally
{
Console.WriteLine("无论是否出错,这里都会执行");
}
Note一旦 try 里抛了异常,控制权立刻跳到匹配的 catch,try 中剩余的代码不再执行。catch 处理完,再走 finally。
捕获具体的异常类型
catching 时要写清楚”捕获哪一类异常”。C# 里所有异常都源自 System.Exception,但更推荐捕获具体类型,比如除零是 DivideByZeroException、格式错误是 FormatException。
namespace CSharpDemo;
string input = "不是数字";
try
{
int n = int.Parse(input);
Console.WriteLine(n);
}
catch (FormatException)
{
Console.WriteLine("输入的不是合法整数");
}
Tip不要把所有错误都用一个
catch (Exception)笼统吞掉。捕获具体类型,才能针对性处理,也更便于排查问题。
异常的传播:沿调用栈向上冒泡
实际项目里,异常往往不是直接抛在你写的 try 里,而是在更深的方法中。CLR 会沿着调用栈(call stack)一层层往上找能接住的 catch;找到就停下,找不到就让程序终止。
namespace CSharpDemo;
void Level3() => int.Parse("abc"); // 这里抛出 FormatException
void Level2() => Level3();
void Level1() => Level2();
try
{
Level1();
}
catch (FormatException ex)
{
Console.WriteLine($"在顶层捕获:{ex.Message}");
}
这段代码的异常从 Level3 冒到 Level2、Level1,最后被最外层的 catch 接住。这就是”栈展开(stack unwinding)”。
Note如果一路上没有任何 catch 接住异常,程序会终止并把错误显示给用户。所以关键的入口处要有兜底处理。
Tip正因为异常会自动向上冒泡,你不必在每一层都写 try/catch。只在”能真正处理这个错误的地方”去接——比如最外层给用户一个友好提示,中间层放手让它穿过。
finally:不管怎样都要收尾
有些动作必须执行,比如关掉打开的文件、释放连接。finally 就是干这个的——即使 try 里 return 了、抛异常了,它也会跑。
namespace CSharpDemo;
try
{
Console.WriteLine("尝试打开资源");
throw new InvalidOperationException("模拟故障");
}
finally
{
Console.WriteLine("释放资源(一定会执行)");
}
Tip现代 C# 更推荐用下一章要讲的
using来管理资源,但 finally 仍是”必须执行收尾逻辑”的通用手段。
异常里有什么信息
每个异常对象都带着有用的诊断信息:Message 是文字描述,StackTrace 是出错时的调用路径。调试时看 StackTrace,能直接定位到哪一行炸了。
namespace CSharpDemo;
try
{
int.Parse("x");
}
catch (Exception ex)
{
Console.WriteLine($"消息:{ex.Message}");
Console.WriteLine($"堆栈:{ex.StackTrace}");
}
Note
ex.StackTrace记录的是”从抛出异常的那行,一路到被捕获的调用路径”。它是你排查线上问题的第一手线索,记日志时务必带上。
多个 catch:从具体到宽泛
一个 try 可以配多个 catch,把最具体的异常放最前面,最宽泛的(Exception)放最后。CLR 自上而下匹配第一个命中的。
namespace CSharpDemo;
try
{
int.Parse(Console.ReadLine());
}
catch (FormatException)
{
Console.WriteLine("格式不对");
}
catch (OverflowException)
{
Console.WriteLine("数字太大或太小");
}
catch (Exception ex)
{
Console.WriteLine($"其他错误:{ex.Message}");
}
Note顺序写反(把
catch (Exception)放最前)会编译报错——宽泛的必须殿后,否则后面的具体 catch 永远没机会执行。
异常过滤器:catch 时加 when(C# 6)
除了按顺序匹配类型,还可以用 when 在 catch 上加条件,只有条件成立才接住。这样能在”同一类型”里按细节分流处理。
namespace CSharpDemo;
try
{
int.Parse("abc");
}
catch (FormatException ex) when (ex.Message.Contains("abc"))
{
Console.WriteLine("是因为含字母导致的格式错误");
}
catch (FormatException)
{
Console.WriteLine("其它格式错误");
}
Tip
when过滤器的好处:异常对象本身不被”消耗”,匹配失败时还能继续往下找别的 catch。它比在 catch 内部再if判断更干净,也保留了原始堆栈上下文。
常见内置异常一览
| 异常 | 触发场景 |
|---|---|
| NullReferenceException | 对 null 调用成员 |
| ArgumentNullException | 参数为 null(应主动抛) |
| ArgumentOutOfRangeException | 参数超出允许范围 |
| FormatException | 字符串格式无法解析 |
| IndexOutOfRangeException | 数组下标越界 |
| InvalidOperationException | 对象状态不允许该操作 |
| DivideByZeroException | 整数除以零 |
Warning
NullReferenceException(空引用)是最常见的崩溃来源。它往往意味着代码假设了”某个对象一定不是 null”,但实际是 null。配合第 90 章的可空引用类型(string?等)能在编译期挡掉一大半这类问题。
catch 也可以不接变量
如果只关心”出错了要做什么”,不关心异常细节,catch 后面可以不写变量:
namespace CSharpDemo;
try { int.Parse("x"); }
catch { Console.WriteLine("发生错误"); }
Tip没有变量时编译器仍按类型匹配。不过日常更推荐写具体类型或
catch (Exception),方便记日志。
finally 里别再抛异常
finally 的职责是”清理”,本身不应再抛新异常,否则会掩盖原始异常,让排错更乱。清理动作(如关闭文件)尽量简单、用 try 包好。
Warning若 finally 里又抛了新异常,原始异常会被”顶掉”,你再也看不到最初出错的原因。清理代码务必轻量、稳当,必要时把清理本身也包一层保护。
与其它语言对比
- 对比 C/C++ 的返回码:C# 异常不会被”忘记检查”而悄悄忽略,错误更显眼。
- 对比 Java:两者异常体系相似,C# 不区分”受检(checked)/非受检”异常,写起来更轻,但也更靠自觉去处理。
- 对比 Go:Go 用多返回值
(结果, error)显式传递错误,C# 用异常中断流程。风格不同,没有对错,习惯即可。
常见疑问解答
问:要不要给每个方法都包 try/catch? 不要。过度捕获会让代码又臭又长,还容易把该暴露的问题藏起来。只在”能处理错误”或在”程序边界(入口、UI、接口)“做兜底。
问:catch 里什么也不写(空块)行不行? 强烈不建议。错误被静默吞掉,之后会在更远更诡异的地方爆发。真要忽略也要写注释说明原因,或至少记一行日志。
问:finally 和 return 谁先?
finally 一定先执行完,方法才真正返回。即使在 try 里写了 return,finally 也会抢在返回之前跑。
最佳实践速记
- try 只包”真正可能出错”的最小范围,别把整段逻辑都塞进去。
- 优先捕获具体异常类型,宽泛的
Exception放最后殿后。 - finally 做必做的清理,别在里面抛新异常。
- 别吞异常,别在每层都捕获。
- 调试看
Message和StackTrace,记日志两者都要留。
小结
try 包住可能出错的代码,catch 按具体类型接管,finally 做必做的清理;异常会沿调用栈自动向上传播,无人接住就终止程序。下一章我们反过来学:什么时候该由你主动 throw 把异常抛出去。