代码的“病历本”:深入解读 C# 常见异常

向北走
向北走
发布时间:2026-08-24| 104人浏览过
原创

前言

写 C# 时,异常往往不是“突然出错”,而是代码在空值、边界、类型假设或外部资源上踩中了早就存在的风险点。下面这篇文章不只罗列异常名称,而是按实际排错路径拆开说明:哪些问题应该靠前置校验解决,哪些场景必须交给运行时的 `try-catch` 处理,你也能据此更快判断问题该从哪一层下手。

异常像代码的“病历本”:它不会替你修复问题,但会明确告诉你,程序是在什么位置、因为什么条件失控的。本文把几类最常见的 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

处理这类问题,核心是把“可能为空”尽量提前表达出来,而不是等到调用成员时才爆炸。

  1. 防御性检查:在访问任何可能为 null 的对象前,先做显式判断。
    if (user != null) {
        Console.WriteLine(user.ToUpper());
    }
    
  2. 使用 ?. 和 ??:这组运算符很适合把“空值分支”写得更短、更直接。
    // 如果 user 是 null,整个表达式直接返回 null,而不是抛出异常
    string upperUser = user?.ToUpper(); 
    
    // 如果 user 是 null,则使用 "Guest" 作为默认值
    string displayName = user ?? "Guest"; 
    
    // 组合使用
    Console.WriteLine(user?.ToUpper() ?? "USER NOT FOUND");
    
  3. 开启可空引用类型检查:让编译器提前提示潜在风险。
    #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!

这类错误通常不是因为数据复杂,而是因为边界条件写错了。排查时优先看两处:循环终止条件,以及访问前是否确认集合里真的有元素。

  1. 循环条件用 <,不要写成 <=
    for (int i = 0; i < scores.Length; i++) { /* 安全 */ }
    
  2. 优先使用 foreach:能不手写索引时,就尽量别手写。
    foreach (var score in scores) { /* 绝对安全 */ }
    
  3. 直接访问前先做边界检查
    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。

  1. 优先使用 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; 

如果你无法完全确定对象真实类型,就不应该直接强转。更稳妥的做法,是把“是否兼容”显式写出来。

  1. 使用 as 运算符
    StringBuilder sb = myObject as StringBuilder;
    if (sb != null) {
        sb.Append("...");
    } else {
        Console.WriteLine("对象不是 StringBuilder 类型。");
    }
    
  2. 使用 is 和模式匹配
    if (myObject is StringBuilder sb) {
        sb.Append("...");
    }
    

as/is 与强制转换的关系,和 TryParse 与 Parse 很像:当你对结果不完全确定时,用安全分支;只有在你明确知道类型兼容时,才使用强制转换,让逻辑错误尽早暴露。

对象状态错误和 I/O 异常,哪些必须运行时处理

并不是所有异常都能靠前置判断彻底避免。尤其当问题来自对象当前状态,或者来自文件系统这类外部环境时,运行时处理往往是必须的。

展示对象状态异常与文件 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”,也不是“所有错误都靠判断语句拦住”,而是根据错误来源决定边界放在哪里。

结语

理解异常,最终目的是提升代码的可预期性。知道某类异常为什么发生,你就能更快判断当前问题是输入不可信、状态管理有漏洞,还是外部资源本身不稳定。把这些高频异常各自的触发条件和处理策略分清楚,后续排错会省下很多时间。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。