异常像代码的“病历本”:它不会替你修复问题,但会明确告诉你,程序是在什么位置、因为什么条件失控的。本文把几类最常见的 C# 异常按“为什么会发生、应该怎么改、哪些场景必须捕获”重新整理,便于你在排错时快速判断是该补防御性检查、改写循环逻辑,还是保留运行时异常处理。
开发中最常见的 C# 异常有哪些
很多异常都不是“语法错”,而是程序在运行时遇到了不满足前提的输入、状态或资源。下面这些类型,基本覆盖了日常开发里最常见的一批问题。

NullReferenceException:访问了实际为 null 的对象
这是最常见的运行时异常之一。典型场景有两种:对象根本没有初始化,或者某个方法在特定条件下返回了 null,但后续代码仍按“它一定有值”来使用。
// 场景1: 未初始化的对象
List<string> names = null;
Console.WriteLine(names.Count); // 异常: names 是 null
// 场景2: 方法返回了 null
string FindUserById(int id)
{
// 假设数据库里没找到id=999的用户
if (id == 999) return null;
return "Admin";
}
string user = FindUserById(999);
Console.WriteLine(user.ToUpper()); // 异常: user 是 null
处理这类问题,核心是把“可能为空”尽量提前表达出来,而不是等到调用成员时才爆炸。
- 防御性检查:在访问任何可能为
null的对象前,先做显式判断。if (user != null) { Console.WriteLine(user.ToUpper()); } - 使用
?.和??:这组运算符很适合把“空值分支”写得更短、更直接。// 如果 user 是 null,整个表达式直接返回 null,而不是抛出异常 string upperUser = user?.ToUpper(); // 如果 user 是 null,则使用 "Guest" 作为默认值 string displayName = user ?? "Guest"; // 组合使用 Console.WriteLine(user?.ToUpper() ?? "USER NOT FOUND"); - 开启可空引用类型检查:让编译器提前提示潜在风险。
#nullable enable // 开启可空引用类型检查 string? user = FindUserById(999); // ? 表示 user 可以为 null Console.WriteLine(user.ToUpper()); // 编译器会在这里发出警告!
如果你的业务里“查不到就返回空值”很常见,那么比起到处补 if,更值得优先考虑的是统一返回约定,并配合可空标注把风险前移到编译阶段。
IndexOutOfRangeException:索引超出了集合边界
当你用无效索引访问数组、List<T> 或其他基于索引的集合时,就会抛出这个异常。无效索引包括小于 0,或者大于等于集合元素数量。
int[] scores = { 98, 76, 100 };
// 场景1: 访问不存在的索引
Console.WriteLine(scores[3]); // 有效索引是 0, 1, 2
// 场景2: 循环条件错误
for (int i = 0; i <= scores.Length; i++) { // 错误在于 i <= Length
Console.WriteLine(scores[i]); // 当 i 等于 scores.Length (3) 时 BOOM!
}
// 场景3: 对空集合进行索引访问
var emptyList = new List<string>();
Console.WriteLine(emptyList[0]); // BOOM!
这类错误通常不是因为数据复杂,而是因为边界条件写错了。排查时优先看两处:循环终止条件,以及访问前是否确认集合里真的有元素。
- 循环条件用
<,不要写成<=for (int i = 0; i < scores.Length; i++) { /* 安全 */ } - 优先使用
foreach:能不手写索引时,就尽量别手写。foreach (var score in scores) { /* 绝对安全 */ } - 直接访问前先做边界检查
int index = GetUserInput(); if (index >= 0 && index < scores.Length) { Console.WriteLine(scores[index]); } else { Console.WriteLine("索引无效!"); }
FormatException:输入格式不符合目标类型要求
当一个方法收到的参数格式不符合预期时,就可能抛出 FormatException。最常见的情况,是把字符串解析成数字、日期等类型时失败。
string userInput = "twelve";
int number = int.Parse(userInput); // "twelve" 不是有效的整数格式
string dateString = "2023-30-01"; // 无效的日期 (30月)
DateTime date = DateTime.Parse(dateString);
如果输入来自用户、配置文件、接口响应或外部文本,就不该默认它一定合法。这里最实用的处理方式不是捕获异常,而是改用不会抛异常的尝试型 API。
- 优先使用
TryParse:这是应对FormatException的最佳实践。string userInput = Console.ReadLine(); if (int.TryParse(userInput, out int number)) { Console.WriteLine($"转换成功: {number}"); } else { Console.WriteLine("输入无效,请输入一个数字。"); }
TryParse 的价值在于,它把“失败也是正常分支”表达得很清楚。对用户输入这类高不确定性数据,通常比 Parse + try-catch 更合适。
参数错误与类型转换错误,应该怎么看
前面几类异常更多是“用值时出错”,这一组则更像“接口契约没满足”。一个是传参本身不合法,另一个是你假定对象可以转成某种类型,但实际并不成立。

ArgumentException 家族:把错误拦在方法入口
ArgumentException 表示传给方法的参数不合法;它下面两类尤其常见:
ArgumentNullException:不该为null的参数被传成了null。ArgumentOutOfRangeException:参数值超出了允许范围。
这类异常的重点,不是“怎么补救”,而是“尽早失败”。也就是在方法入口处用卫语句把错误参数直接挡住,避免它在更深层的位置演变成更难追踪的问题。
public void SetUserName(string name)
{
// 卫语句:主动防御,而不是等着用到 name 时再爆炸
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("用户名不能为空或仅包含空白字符。", nameof(name));
}
this.UserName = name;
}
public void SetDiscount(double percentage)
{
if (percentage < 0 || percentage > 1)
{
throw new ArgumentOutOfRangeException(nameof(percentage), "折扣必须在0和1之间。");
}
this.Discount = percentage;
}
// 调用者犯错
myObject.SetUserName(null); // 会立即捕获到 ArgumentException,而不是后面的 NullReferenceException
如果你在设计公共方法、库接口或业务服务层,参数校验通常比事后排查更有价值。它能把错误责任明确地留在调用边界,而不是把问题拖到对象内部状态被破坏之后。
InvalidCastException:类型转换假设不成立
当代码在运行时执行显式类型转换,但源类型无法转换为目标类型时,就会出现 InvalidCastException。
object myObject = "Hello World";
// 这是一个字符串,不能被强制转换为一个 StringBuilder
StringBuilder sb = (StringBuilder)myObject;
如果你无法完全确定对象真实类型,就不应该直接强转。更稳妥的做法,是把“是否兼容”显式写出来。
- 使用
as运算符StringBuilder sb = myObject as StringBuilder; if (sb != null) { sb.Append("..."); } else { Console.WriteLine("对象不是 StringBuilder 类型。"); } - 使用
is和模式匹配if (myObject is StringBuilder sb) { sb.Append("..."); }
as/is 与强制转换的关系,和 TryParse 与 Parse 很像:当你对结果不完全确定时,用安全分支;只有在你明确知道类型兼容时,才使用强制转换,让逻辑错误尽早暴露。
对象状态错误和 I/O 异常,哪些必须运行时处理
并不是所有异常都能靠前置判断彻底避免。尤其当问题来自对象当前状态,或者来自文件系统这类外部环境时,运行时处理往往是必须的。

InvalidOperationException:当前对象状态不允许这次操作
这类异常的关键不在参数,而在对象“此刻不适合被这样使用”。常见例子包括:遍历集合时修改集合,或者在枚举器已经走到末尾后继续读取当前值。
// 场景1: 修改正在迭代的集合
var numbers = new List<int> { 1, 2, 3, 4 };
foreach (var number in numbers) {
if (number == 2) {
numbers.Remove(number); // 不能在 foreach 循环中修改集合
}
}
// 场景2: 使用已耗尽的迭代器
var enumerator = numbers.GetEnumerator();
while(enumerator.MoveNext()) { }
// 迭代器已到末尾
Console.WriteLine(enumerator.Current); // BOOM! (或返回默认值,取决于实现)
遇到这类异常,排查思路通常是看“对象的生命周期”和“调用顺序”是否合理,而不是只盯着某一行代码本身。很多时候,问题出在对象状态转换的时机不对。
IOException 家族:外部资源问题要靠 try-catch 兜底
所有与输入/输出操作相关的错误,都属于这一大类。常见成员包括:
FileNotFoundException:尝试访问一个不存在的文件。DirectoryNotFoundException:路径中的某个目录不存在。UnauthorizedAccessException:程序没有足够权限访问文件或目录。
这一类异常和前面几类最大的区别是:它往往不能只靠“操作前先判断”解决。即使你先写了 File.Exists,也无法保证检查结束到实际读取之间,文件不会被删除、占用或权限变化。这就是典型的竞态条件。
因此,I/O 操作的最佳实践通常是:可以做必要的前置提示,但真正的安全边界仍然要落在 try-catch 上。
try
{
string content = File.ReadAllText(@"C:secretdata.txt");
// ... process content ...
}
catch (FileNotFoundException ex)
{
Console.WriteLine("错误:文件未找到。请确认文件路径是否正确。");
}
catch (UnauthorizedAccessException ex)
{
Console.WriteLine("错误:权限不足。请尝试以管理员身份运行程序。");
}
catch (IOException ex) // 兜底处理其他I/O错误
{
Console.WriteLine($"发生了一个I/O错误: {ex.Message}");
}
这里的关键不是“把所有异常都吃掉”,而是针对可预期的失败方式分别给出可操作的反馈,再用更宽的 IOException 做兜底。
什么时候该前置校验,什么时候该捕获异常
把上面这些异常放在一起看,其实可以归纳成一个很实用的判断标准:
- 如果错误主要来自可预见的输入或调用约定,优先用前置校验避免它,比如
null检查、边界判断、TryParse、卫语句。 - 如果错误主要来自运行时状态或外部环境,就要接受它无法被彻底预判,并用异常处理兜住,比如集合状态错误、文件系统访问失败。
- 如果你非常确定某个假设必须成立,也可以保留显式异常,让逻辑错误尽早暴露,而不是静默吞掉。
真正成熟的异常处理,不是“所有地方都包一层 try-catch”,也不是“所有错误都靠判断语句拦住”,而是根据错误来源决定边界放在哪里。
结语
理解异常,最终目的是提升代码的可预期性。知道某类异常为什么发生,你就能更快判断当前问题是输入不可信、状态管理有漏洞,还是外部资源本身不稳定。把这些高频异常各自的触发条件和处理策略分清楚,后续排错会省下很多时间。









