抛出异常
本教程共 100 篇 · 第 74 篇 · 更新于 2026-07-31 · 约 10 分钟阅读
74. 抛出异常
本节目标:学完你会在参数不合法或状态不对时主动抛出异常,并正确地用 throw; 重新抛出以保留原始堆栈。
上一章我们”接”异常,这章讲”抛”异常。很多时候该由你来喊停:方法收到的参数明显不对、对象处于一个不该出现的状态。这时候主动抛异常,比让程序带着错误数据继续跑要安全得多。
为什么该由你主动抛异常
设想一个 OpenAccount 方法收到负数余额。如果你不拦,程序会”假装”开了一个账户,之后利息、转账都基于这个非法数据运算,错误被稀释到各个角落,最后在某个完全不相干的地方炸出来,谁也猜不到根因。
主动抛异常等于在”错误刚露头”的地方就把它摁住,并附上一句清楚的诊断:“余额不能为负”。好处有三:错误在最近处暴露、调用方无法忽视、排错时直奔源头。一句话——好的异常是给未来的自己留的便签。
Note抛异常不是认输,而是”诚实地报告自己完成不了这次调用”。一个方法有权声明:在给定条件下我做不到,并说清为什么。
用 throw 主动抛异常
throw 后面跟一个异常对象。最常见的几个:ArgumentNullException(参数为空)、ArgumentException(参数不合法)、InvalidOperationException(对象状态不对)。
namespace CSharpDemo;
void Greet(string? name)
{
if (name is null)
throw new ArgumentNullException(nameof(name), "名字不能为空");
Console.WriteLine($"你好,{name}");
}
try
{
Greet(null);
}
catch (ArgumentNullException ex)
{
Console.WriteLine($"捕获:{ex.Message}");
}
Tip用
nameof(name)而不是写死字符串"name",这样以后改参数名,编译器会帮你同步,不会漏。
什么时候该主动抛
不是所有错误都要抛异常,下面几种情况最适合:
- 公开方法的入参不满足前置条件(比如不允许为 null)。
- 对象当前状态无法完成这次调用(如没初始化就使用)。
- 收到一个完全不可能的值,说明调用方用错了。
namespace CSharpDemo;
void SetAge(int age)
{
if (age < 0 || age > 150)
throw new ArgumentOutOfRangeException(nameof(age), "年龄必须在 0 到 150 之间");
Console.WriteLine($"年龄设为 {age}");
}
try
{
SetAge(200);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine(ex.Message);
}
Note别把异常当普通的流程控制(比如用抛异常来跳出循环)。异常开销大,应该只用于”真正的意外”。
重新抛出:throw; 与 throw ex; 的天壤之别
有时你在 catch 里想”先记个日志,再让上层处理”。这时要用 throw;(不带参数),它能保留原始堆栈信息。千万别写 throw ex;,那会重置堆栈,让调试者看不到真正出错的源头。
namespace CSharpDemo;
void DoWork()
{
try
{
int.Parse("abc");
}
catch (FormatException ex)
{
Console.WriteLine("已记录日志");
throw; // 正确:保留原始堆栈
// throw ex; // 错误:堆栈被重置,排查困难
}
}
try
{
DoWork();
}
catch (FormatException ex)
{
Console.WriteLine($"外层捕获,堆栈起点仍在 int.Parse:{ex.StackTrace}");
}
Warning
throw ex;会让异常的StackTrace从”这一行”重新开始,原始的出错点(如真正的int.Parse)被抹掉。线上排查时你只看到”在 DoWork 里抛的”,却不知道根因在更深的地方。这是新手最易犯、也最坑的错,记住口诀:重新抛出用throw;,绝不用throw ex;。
用 innerException 串起因果
如果你要抛一个”新异常”但想保留”原始异常”,把它作为第二个参数传进去,成为内部异常(inner exception)。
namespace CSharpDemo;
try
{
try
{
int.Parse("x");
}
catch (FormatException ex)
{
throw new InvalidOperationException("初始化失败", ex);
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine($"根因:{ex.InnerException?.Message}");
}
Note通过
ex.InnerException可以一路挖到最底层的根因。记日志时把整条异常(含 InnerException)序列化出来,才能让后来者顺藤摸瓜。
该抛哪一个标准异常
.NET 提供了一整套语义明确的异常,挑对类型能让调用方一眼懂错在哪:
| 异常 | 何时用 |
|---|---|
| ArgumentNullException | 参数不该为 null |
| ArgumentOutOfRangeException | 参数超出允许范围 |
| ArgumentException | 参数不合法(其他情况) |
| InvalidOperationException | 对象当前状态不支持此调用 |
| NotSupportedException | 操作根本不被支持 |
| FormatException | 字符串格式无法解析 |
Tip选异常类型时按”语义”而非”随意”。调用方可能专门
catch (ArgumentNullException)做不同处理,你抛错类型就决定了它能不能精准应对。
别吞掉异常
最危险的写法就是”捕获了却什么都不做”,这叫吞异常。错误被静默掩盖,问题会在更远的地方以更诡异的方式爆发。
namespace CSharpDemo;
try
{
int.Parse("abc");
}
catch (FormatException)
{
// 千万别只留一个空块:错误被吃掉了
throw; // 至少要重新抛出,或记日志后抛出
}
Note如果你确实”可以安全忽略”某个异常(极少见),也要写一行注释说明为什么,否则后来者会以为你忘了处理。
守卫子句:提前返回,代码更平
抛出异常配合”守卫子句(guard clause)“写校验,比层层嵌套的 if-else 清爽得多:不满足前置条件就立刻抛,后面的主逻辑才能平铺直叙。
namespace CSharpDemo;
void Transfer(BankAccount from, BankAccount to, decimal amount)
{
if (from is null) throw new ArgumentNullException(nameof(from));
if (to is null) throw new ArgumentNullException(nameof(to));
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
from.Balance -= amount; // 校验通过,主逻辑干净利落
to.Balance += amount;
}
class BankAccount { public decimal Balance; }
Tip守卫子句把”非法情况挡在门外”,主流程不再被
if/else包裹,读起来像一条直线。这是让方法变短变清晰的常用技巧,第 17 章也讲过”提前返回压平嵌套”的思路。
异常不是普通的流程控制
有人爱用异常来”跳出循环”或”返回错误码”,这是误用。异常的对象创建、栈展开都有开销,应只留给真正的意外。正常的业务分支用 if 和返回值表达。
Warning用异常当
goto、用抛异常来”返回”分支结果,会让性能下降、代码意图混乱。异常只为”意外”,正常逻辑请用条件判断与返回值。
throw 也能当表达式(C# 7)
从 C# 7 起,throw 可以出现在表达式位置,比如三元运算符或空合并里,省去写完整的 if 块。
namespace CSharpDemo;
string? name = null;
string finalName = name ?? throw new ArgumentNullException(nameof(name));
Console.WriteLine(finalName);
Note表达式形式的
throw常用于”给一个值的同时顺带校验”——一行搞定”为空就抛”。它和语句形式的throw行为完全一致,只是写得更紧凑。
与”返回错误码”对比
| 方式 | 优点 | 缺点 |
|---|---|---|
| 抛异常 | 无法被忽略,携带堆栈,调用链自动上冒 | 有性能开销,不能当普通流程用 |
| 返回错误码 / bool | 轻量、可控 | 容易被调用方忽略,错误会悄悄传递 |
经验:库方法的”非法输入/非法状态”用异常;高频、可预期的业务分支(如”查找不到就返回空”)用返回 null 或 bool 更自然。
常见疑问解答
问:参数校验每个方法都写一遍会不会太啰嗦?
公共 API 和边界处该写就写,内部小工具方法可适当放宽。也可借助 ArgumentException.ThrowIfNullOrEmpty(.NET 7 引入)等 .NET 内置的辅助方法一行搞定。
问:throw; 和 throw new 同类型异常有区别吗?
有。throw; 保留原堆栈起点;throw new XxxException(...) 会新建一个异常,堆栈从当前行算起,原始堆栈信息丢失(除非用 InnerException 接住)。能原样重抛就优先 throw;。
问:捕获后可不可以”改造”异常再抛?
可以,用带 InnerException 的新异常把原始异常包起来,既补充了你的上下文,又不丢根因。这正是 throw new InvalidOperationException("...", ex) 的用途。
最佳实践速记
- 参数/状态不合法就主动抛,别带病运行。
- 重新抛出用
throw;,绝不用throw ex;。 - 要保留根因用 InnerException 包装。
- 别吞异常,空 catch 是隐患。
- 异常留给”意外”,正常分支用返回值。
- 守卫子句 + 早抛,让主逻辑平铺直叙。
小结
主动 throw 适合参数校验和非法状态;重新抛出务必用 throw; 保留原始堆栈,避免 throw ex; 掩盖真相;需要保留根因时用 innerException。下一章我们更进一步,自定义属于自己业务规则的异常类型。