自在学
分类课程智能体订阅
分类课程AI导师价格
课程进度
8 / 11
上一节异步与序列化下一节LINQ 与数据
自在学

© 2025 - 2026 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程C#错误处理

错误处理

在软件开发过程中,错误与异常无处不在,例如参数非法、网络中断、磁盘故障或外部依赖不可用等。专业的错误处理不仅能够提升程序的健壮性与用户体验,也是工程质量的重要保障。 这节课我们介绍如何在 C# 中进行有效的错误处理,包括如何预防和检测异常、如何规范报告错误、如何优雅恢复以及如何进行彻底的资源清理。

错误处理


try/catch/finally

在 C# 里,try/catch/finally 是我们处理错误的“基本功”,就像船上的护栏和安全绳。我们写代码时,总会遇到一些“意外的浪花”——比如文件没找到、网络突然断开、用户输入了奇怪的数据。 如果我们什么都不做,程序一遇到这些问题就会“翻船”崩溃,用户体验很糟糕。

try/catch/finally 就像是给我们的程序加上一层保护。try 代码块里放的是“可能出错”的操作,比如读文件、访问网络。 catch 就像是“救生员”,一旦 try 里出错,catch 会立刻跳出来“接住”这个错误,让我们有机会优雅地处理它,比如给用户一个友好的提示,或者记录日志方便排查。 finally 则像是“收尾小队”,无论前面有没有出错,finally 里的代码都会被执行,非常适合用来做一些善后工作,比如关闭文件、释放资源、擦干净甲板。

我们可以通过 try/catch/finally,让程序在风浪中依然稳稳当当,不会因为一个小小的意外就全线崩溃。

csharp
using System;
using System.IO;
 
class Program
{
    static void Main()
    {
        try
        {
            string text = File.ReadAllText("data.txt"); // 可能抛出 IOException/UnauthorizedAccessException 等
            Console.WriteLine(text);
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine("找不到文件: " + ex.FileName);
        }
        catch (IOException ex)
        {
            Console.WriteLine("IO 出错: " + ex.Message);
        }
        finally
        {
            Console.WriteLine("无论如何都会执行:可用于收尾工作");
        }
    }
}

从具体到抽象捕获;finally 总会运行(即便 catch 未命中),适合做关闭资源、释放锁等收尾动作。


抛出异常:throw 与自定义消息

在 C# 里,抛出异常就像是我们拉响了“警报”,让程序知道“这里出事了”。当我们发现某个操作可能出错时,就可以用 throw 来抛出一个异常。

csharp
using System;
 
class Calculator
{
    public int Divide(int a, int b)
    {
        if (b == 0)
            throw new DivideByZeroException("分母不能为 0"); // 迅速失败,信息明确
        return a / b;
    }
}

我们可以把抛出异常想象成在工厂里拉响了紧急警报:如果机器出现了故障,工人们会立刻拉下警铃,让大家都知道哪里出了问题。 同样地,在 C# 里,当我们用 throw 抛出异常时,最好把“警报内容”写得清楚明白。 比如说,如果是分母为零导致的错误,我们就直接告诉大家“分母不能为 0”,这样看到异常的人一眼就能明白发生了什么。 异常消息越具体、越友好,后面排查和修复问题就越轻松。


重新抛出:throw; vs throw ex;

csharp
try
{
    MightFail();
}
catch (Exception ex)
{
    Console.WriteLine("记录日志: " + ex.Message);
    throw; // 保留原堆栈,更利于诊断
    // throw ex; // 会重置堆栈,不推荐
}

在 C# 里,如果我们在 catch 块里用 throw; 这种“裸抛”,其实就像是把刚才捕获到的异常原封不动地又扔了出去。 这样做有个很大的好处:异常的“来龙去脉”——也就是它的调用堆栈信息——会被完整保留下来。 举个生活中的例子,就像我们在传递一个快递时,没有拆开包装,也没有换箱子,快递单上的所有信息都还在,别人一看就知道它最初是从哪里发出来的。 如果我们用 throw ex;,就相当于把快递重新打包,原来的寄件信息就丢失了,后面查问题就会很麻烦。 所以,推荐用 throw;,这样方便我们后续定位和排查异常发生的根本原因。


异常筛选器:catch (...) when (条件)

在 C# 里,如果我们想在 catch 块里只处理某些特定类型的异常,就可以用 catch (...) when (条件) 这种“筛选器”。

举个生活中的例子,就像我们在超市买东西时,如果发现商品过期了,我们不会直接扔掉,而是会先检查一下它的保质期,如果还在保质期内,我们就会把它放回货架,继续卖给其他顾客。

csharp
try
{
    MightFail();
}
catch (Exception ex) when (ex.Message.Contains("暂时") )
{
    Console.WriteLine("临时性错误,准备重试...");
}

我们可以把异常筛选器想象成一道“智能门禁”。当异常发生时,这道门会先检查异常是不是我们关心的那一类,只有符合特定条件的异常才能进来被处理。 比如说,有时候我们只想对“临时性错误”做出反应,而其他类型的异常则让它们继续往上传递,不要在这里被“吞掉”。 这样做的好处是,我们既能精准地处理想要关注的问题,又不会丢失那些重要的异常信息,方便后续排查和定位。 就像在机场安检时,只有带有特殊标记的行李才会被单独检查,其他的行李则正常通过,不会被耽误。这样既高效又安全。


多个 catch 的顺序:先具体后一般

我们可以把多个 catch 想象成一个“多层过滤网”。当异常发生时,这些过滤网会一层一层地检查异常,只有符合特定条件的异常才能通过。

csharp
try
{
    UseFile();
}
catch (FileNotFoundException ex)
{
    // 更具体
}
catch (IOException ex)
{
    // 较一般
}
catch (Exception ex)
{
    // 最一般
}

从小网眼到大网眼地“兜住”。大网在前会把小网“遮住”。


自定义异常类型

在 C# 这门语言里,我们不仅可以使用系统自带的异常类型,还能根据自己的业务需求,亲手“打造”属于自己的异常类型。这样做的好处是什么呢?就像我们在家里给每个房间贴上不同的门牌号,谁住哪一间一目了然。 自定义异常能让代码在遇到特殊情况时,把“出错的原因”表达得更清楚、更有针对性。

比如说,我们在做一个钱包扣款的功能时,如果余额不足,直接抛出系统的 Exception,别人一看只知道“出错了”,但具体是哪里出错、为什么出错,可能就要费一番功夫去查。 而如果我们自定义一个“余额不足异常”,那一眼就能看明白:哦,原来是钱不够了!

csharp
using System;
 
public class BalanceNotEnoughException : Exception
{
    public decimal Balance { get; }
    public decimal Attempt { get; }
    public BalanceNotEnoughException(decimal balance, decimal attempt)
        : base($"余额不足:当前 {balance},尝试扣款 {attempt}")
    {
        Balance = balance; Attempt = attempt;
    }
}
 
class Wallet
{
    public decimal Balance { get; private set; }
    public Wallet(decimal initial) => Balance = initial;
    public void Withdraw(decimal amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
        if (Balance < amount) throw new BalanceNotEnoughException(Balance, amount);
        Balance -= amount;
    }
}

溢出与边界:checked/unchecked

在 C# 这门语言里,整数类型(比如 int、long)在做加法、减法、乘法这些运算时,如果结果超出了它们能表示的最大或最小范围,会发生“溢出”。 这种溢出有点像我们往一个装满水的杯子里继续倒水,多出来的水就会溢出来。但是,C# 默认情况下有时候会“悄悄”让溢出的结果绕回去(比如 int 超过最大值会变成负数),不会主动告诉我们出错了。

那么,我们怎么才能让 C# 在溢出时主动提醒我们呢?这时候就要用到 checked 关键字了。checked 就像给杯子加了一个报警器,只要水一溢出来,立刻响铃报错。 而如果我们觉得溢出没关系,愿意让它悄悄发生,那就可以用 unchecked,相当于把报警器关掉,水溢出来也不管。

csharp
checked
{
    try
    {
        int x = int.MaxValue;
        x += 1; // 在 checked 上下文将抛 OverflowException
    }
    catch (OverflowException)
    {
        Console.WriteLine("检测到溢出");
    }
}
 
unchecked
{
    int y = int.MaxValue + 1; // 不抛异常,绕回负数
}

在需要精确数值安全时开启 checked;性能敏感且可接受环绕时可用 unchecked。


异步里的异常:await 会把异常抛回

在 C# 里,当我们用 await 等待一个异步方法时,如果这个异步方法抛出了异常,那么这个异常会被“抛回”到 await 所在的上下文。

csharp
using System;
using System.Threading.Tasks;
 
async Task<int> GetAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("远程失败");
}
 
async Task Demo()
{
    try
    {
        int v = await GetAsync(); // 这里抛出,进入 catch
    }
    catch (Exception ex)
    {
        Console.WriteLine("捕获到: " + ex.Message);
    }
}
 
// 仅事件处理器允许 async void;其他请使用 Task 返回,便于等待与统一处理

并发等待 Task.WhenAll 时,可能有多个异常,需要汇总或记录全部。


小练习

  1. 在catch块中重新抛出异常,推荐使用哪个关键字来保持原堆栈?
  1. 多个catch块的顺序应该是?
  1. 在C#中,用于异常筛选器的关键字是?

4. throw 与 throw ex 的区别练习

在以下代码的 catch 块中,如何保持原堆栈信息:

  • MightFail() 方法可能抛出异常
  • 在 catch 块中需要记录日志,然后重新抛出异常
  • 要求:保持原始的堆栈跟踪信息,不要丢失异常发生的位置
  • 对比 throw; 和 throw ex; 的区别
csharp
using System;
 
class Program
{
    static void MightFail()
    {
        throw new InvalidOperationException("原始异常");
    }
 
    static void Main()
    {
        try
        {
            MightFail();
        }
        catch (Exception ex)
        {
            // 记录日志
            Console.WriteLine($"记录日志: {ex.Message}");
            
            // 方式1:使用 throw; 保留原堆栈(推荐)
            throw; // 保留完整的堆栈跟踪信息
            
            // 方式2:使用 throw ex; 重置堆栈(不推荐)
            // throw ex; // 会丢失原始异常的位置信息
        }
    }
}
console
记录日志: 原始异常
Unhandled exception. System.InvalidOperationException: 原始异常
   at Program.MightFail() in ...
   at Program.Main() in ...

说明:

  • throw;(推荐):
    • 保留完整的堆栈跟踪信息
    • 异常会显示原始发生的位置(MightFail())
    • 便于调试和定位问题
  • throw ex;(不推荐):
    • 重置堆栈跟踪信息
    • 异常会显示为从 catch 块抛出,丢失原始位置
    • 不利于调试和问题定位
  • 在需要重新抛出异常时,应该使用 throw; 而不是 throw ex;

5. catch 顺序练习

修正以下代码的 catch 块顺序:

  • UseFile() 方法可能抛出 FileNotFoundException 或 IOException
  • FileNotFoundException 是 IOException 的子类
  • 问题:当前的 catch 顺序不正确,FileNotFoundException 永远不会被捕获
  • 要求:调整 catch 块的顺序,确保更具体的异常先被捕获
csharp
using System;
using System.IO;
 
class Program
{
    static void UseFile()
    {
        // 可能抛出 FileNotFoundException 或 IOException
        string content = File.ReadAllText("nonexistent.txt");
    }
 
    static void Main()
    {
        try
        {
            UseFile();
        }
        // 正确顺序:先捕获更具体的异常
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"文件未找到: {ex.FileName}");
        }
        catch (IOException ex)
        {
            Console.WriteLine($"IO 错误: {ex.Message}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"其他异常: {ex.Message}");
        }
 
        // 错误示例:如果顺序反过来
        // catch (IOException ex) { }  // 这会先捕获 FileNotFoundException
        // catch (FileNotFoundException ex) { }  // 永远不会执行
    }
}
console
文件未找到: nonexistent.txt

说明:

  • catch 块的顺序必须从具体到抽象(从派生类到基类)
  • FileNotFoundException 是 IOException 的子类,必须先捕获
  • 如果先捕获 IOException,FileNotFoundException 也会被它捕获,后面的 catch 块永远不会执行
  • 编译器会警告不合理的 catch 顺序
  • 正确的顺序:FileNotFoundException → IOException → Exception

6. 异常筛选器练习

使用异常筛选器(when 关键字)只捕获临时性异常:

  • Run() 方法可能抛出各种异常
  • 要求:只捕获临时性异常(异常消息包含"暂时"或"临时"关键字)
  • 其他异常应该继续向上传播,不被这个 catch 块捕获
  • 使用 when 关键字添加筛选条件
csharp
using System;
 
class Program
{
    static void Run()
    {
        // 模拟可能抛出不同类型的异常
        throw new InvalidOperationException("暂时无法连接服务器");
        // throw new InvalidOperationException("永久性错误");
    }
 
    static void Main()
    {
        try
        {
            Run();
        }
        // 使用 when 筛选器:只捕获临时性异常
        catch (Exception ex) when (ex.Message.Contains("暂时") || ex.Message.Contains("临时"))
        {
            Console.WriteLine($"捕获到临时性异常: {ex.Message}");
            Console.WriteLine("准备重试...");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"捕获到其他异常: {ex.Message}");
            throw; // 重新抛出非临时性异常
        }
    }
}
console
捕获到临时性异常: 暂时无法连接服务器
准备重试...

说明:

  • when 关键字用于异常筛选器,可以在 catch 块中添加条件
  • 只有满足 when 条件的异常才会被这个 catch 块捕获
  • 不满足条件的异常会继续向上传播,被后面的 catch 块捕获
  • 这种方式可以精确控制哪些异常需要特殊处理
  • 异常筛选器在 C# 6.0 中引入,让异常处理更加灵活

7. using 与 finally 练习

用两种方式确保 FileStream 被正确释放:

  • 方式1:使用 using 语句(推荐)
  • 方式2:使用 try/finally 手动释放
  • 要求:无论是否发生异常,都要确保 FileStream 被释放
  • 对比两种方式的优缺点
csharp
using System;
using System.IO;
 
class Program
{
    static void Main()
    {
        // 方式1:使用 using 语句(推荐)
        Console.WriteLine("方式1:using 语句");
        using (var fs1 = new FileStream("test1.txt", FileMode.Create))
        {
            // 使用文件流
            fs1.WriteByte(65);
        } // 自动调用 Dispose,释放资源
 
        // 方式2:使用 try/finally 手动释放
        Console.WriteLine("方式2:try/finally");
        FileStream? fs2 = null;
        try
        {
            fs2 = new FileStream("test2.txt", FileMode.Create);
            // 使用文件流
            fs2.WriteByte(66);
        }
        finally
        {
            // 确保资源被释放
            fs2?.Dispose();
        }
 
        // 方式1的简化写法(C# 8.0+)
        Console.WriteLine("方式1简化:using 声明");
        using var fs3 = new FileStream("test3.txt", FileMode.Create);
        fs3.WriteByte(67);
        // 方法结束时自动释放
    }
}
console
方式1:using 语句
方式2:try/finally
方式1简化:using 声明

说明:

  • 方式1:using 语句(推荐):
    • 语法简洁,自动调用 Dispose()
    • 即使发生异常也会自动释放资源
    • 适合实现 IDisposable 接口的类型
    • C# 8.0+ 支持 using var 简化写法
  • 方式2:try/finally:
    • 需要手动调用 Dispose()
    • 代码较长,容易遗漏
    • 适合需要更复杂释放逻辑的场景
  • 推荐使用 using 语句,代码更简洁、安全

8. 参数校验练习

对字符串参数 name 进行"非空且非空白"的校验:

  • 编写一个方法,接受字符串参数 name
  • 要求:如果 name 为 null、空字符串或只包含空白字符,抛出 ArgumentException
  • 异常消息应该明确指出参数名和问题原因
  • 使用 string.IsNullOrWhiteSpace() 方法进行校验
csharp
using System;
 
class Program
{
    static void Greet(string name)
    {
        // 参数校验:非空且非空白
        if (string.IsNullOrWhiteSpace(name))
        {
            throw new ArgumentException("参数不能为空或只包含空白字符", nameof(name));
        }
 
        Console.WriteLine($"你好,{name}!");
    }
 
    static void Main()
    {
        try
        {
            Greet("张三");      // 正常
            Greet("");          // 抛出异常
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"参数错误: {ex.ParamName} - {ex.Message}");
        }
 
        try
        {
            Greet(null);        // 抛出异常
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"参数错误: {ex.ParamName} - {ex.Message}");
        }
 
        try
        {
            Greet("   ");       // 抛出异常(只包含空白字符)
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"参数错误: {ex.ParamName} - {ex.Message}");
        }
    }
}
console
你好,张三!
参数错误: name - 参数不能为空或只包含空白字符
参数错误: name - 参数不能为空或只包含空白字符
参数错误: name - 参数不能为空或只包含空白字符

说明:

  • string.IsNullOrWhiteSpace(name) 检查字符串是否为 null、空字符串或只包含空白字符
  • ArgumentException 用于参数校验失败的情况
  • nameof(name) 获取参数名,避免硬编码字符串
  • 参数校验应该在方法开始处进行,快速失败(fail-fast)
  • 这种方式可以及早发现问题,避免后续处理出错
  • try/catch/finally
  • 抛出异常:`throw` 与自定义消息
  • 重新抛出:`throw;` vs `throw ex;`
  • 异常筛选器:`catch (...) when (条件)`
  • 多个 `catch` 的顺序:先具体后一般
  • 自定义异常类型
  • 溢出与边界:`checked`/`unchecked`
  • 异步里的异常:`await` 会把异常抛回
  • 小练习

目录

  • try/catch/finally
  • 抛出异常:`throw` 与自定义消息
  • 重新抛出:`throw;` vs `throw ex;`
  • 异常筛选器:`catch (...) when (条件)`
  • 多个 `catch` 的顺序:先具体后一般
  • 自定义异常类型
  • 溢出与边界:`checked`/`unchecked`
  • 异步里的异常:`await` 会把异常抛回
  • 小练习