CSharp异常处理学习笔记
一、什么是异常
异常,就是程序运行的时候出现的错误。
举个例子:你想把一个空字符串 "" 转成整数,编译器不会报错(语法没问题),但程序一跑起来就会出错。这种"运行时才暴露出来的错误",就是异常。
string str = "";
int val = int.Parse(str); // 运行时这里会抛异常:输入字符串格式不正确
如果不对异常做处理,程序会直接中断退出,后面的代码都不会执行。
异常处理要解决的问题就是:程序出错了,但不要直接崩掉,而是由我们自己接管,做出合理的处理,让程序尽量保持正常运行。
二、异常处理的四个关键字
C# 的异常处理基于四个关键字:try、catch、finally、throw。
| 关键字 | 作用 |
|---|---|
try |
把容易出错的代码包起来,后面紧跟一个或多个 catch 块 |
catch |
捕获 try 块里抛出的异常,做对应处理 |
finally |
无论有没有异常,都会执行的代码块 |
throw |
手动抛出一个异常 |
可以把它们理解成一个流程:
try里"试一下",出错了catch"接住它",最后finally"收尾(不管出没出错都收)"。
三、try-catch 语句
3.1 基本语法
try
{
// 可能会引起异常的语句
}
catch (异常类型1 e1)
{
// 处理异常类型1
}
catch (异常类型2 e2)
{
// 处理异常类型2
}
finally
{
// 可以没有 finally,也可以有
// 不管有没有异常,都会执行
}
3.2 一个完整小例子
int val;
try
{
string str = "";
val = int.Parse(str); // 这里会抛出异常
}
catch (Exception e)
{
Console.WriteLine($"异常消息:{e.Message}");
}
finally
{
val = -1; // 无论是否异常,最后都会把 val 设为 -1
}
Console.WriteLine($"val = {val}"); // 输出:val = -1
运行结果:
异常消息:输入字符串的格式不正确。
val = -1
注意看:虽然 int.Parse(str) 出错了,但程序没有崩溃,catch 把异常接住了,finally 和后面的代码照常执行。
四、finally 的两个要点
- 无论如何都会执行:不管
try里有没有抛异常,不管catch有没有接住,finally都会跑。 - 即使 try 或 catch 里有
return,finally依然会先执行,再真正返回。
static int Test()
{
try
{
return 1;
}
finally
{
Console.WriteLine("finally 执行了");
}
}
// 先打印 "finally 执行了",再返回 1
finally 常用来做收尾工作:关闭文件、关闭数据库连接、释放资源等。
特殊说明:只有极少数情况
finally不会执行,比如程序进程被强制杀死、Environment.Exit()被调用、finally块里自己又抛了异常等。面试题会考到,见后面。
五、C# 异常类的继承结构
所有异常都直接或间接派生自 System.Exception 类。
System.Exception
├── System.SystemException ← 系统预定义异常的基类
│ ├── System.IO.IOException
│ ├── System.IndexOutOfRangeException
│ ├── System.NullReferenceException
│ ├── System.DivideByZeroException
│ └── ...(还有很多)
└── System.ApplicationException ← 应用程序(自定义)异常的基类
└── 我们自定义的异常类
两个重要分支:
- System.SystemException:所有系统预定义异常的基类,比如除零、空引用、数组越界等。
- System.ApplicationException:支持由应用程序自己产生的异常,所以我们自定义的异常应该继承这个类。
六、常见预定义异常类
| 异常类 | 描述 |
|---|---|
| System.IO.IOException | 处理 I/O 错误(读写文件等) |
| System.IndexOutOfRangeException | 数组索引超出范围 |
| System.ArrayTypeMismatchException | 数组类型不匹配 |
| System.NullReferenceException | 引用了空对象(null) |
| System.DivideByZeroException | 除以零 |
| System.InvalidCastException | 类型转换出错 |
| System.OutOfMemoryException | 内存不足 |
| System.StackOverflowException | 栈溢出 |
七、多个 catch 块的匹配顺序
一个 try 后面可以跟多个 catch,用来分别处理不同类型的异常。程序会从上往下找第一个匹配的 catch。
int val;
int a = 12;
int b = 0;
try
{
string str = "";
val = int.Parse(str); // 这里先抛 FormatException
int count = a / b; // 这行不会执行到
}
catch (FormatException e)
{
Console.WriteLine($"格式异常:{e.Message}");
}
catch (DivideByZeroException e)
{
Console.WriteLine($"除零异常:{e.Message}");
}
重要规则:子类(具体异常)必须写在父类(Exception)前面。
try { ... }
catch (FormatException e) { ... } // 具体异常在前,正确
catch (Exception e) { ... } // 兜底,放最后
如果反过来,把 catch (Exception e) 写在最前面,后面的 catch 就永远执行不到,编译器会直接报错。
八、throw 与异常的向上传递(重点)
8.1 手动抛出异常
if (b == 0)
{
throw new DivideByZeroException("除数不能为 0");
}
8.2 异常在调用层面的处理原则
这是实战中非常重要的一个原则:
如果存在方法调用,异常捕获应该在"调用层面"进行;被调用的方法内部一般不捕获异常。即便方法内部不得不捕获,也不能把异常"消化掉",而要把异常继续向上抛,让上方的调用者能捕获到。
看一个对比例子。
错误做法:方法内部把异常"吃掉"了
static int Divide(int a, int b)
{
try
{
return a / b;
}
catch (Exception e)
{
Console.WriteLine($"内部处理:{e.Message}");
return 0; // 把异常吞掉了,返回一个"假结果"
}
}
// 调用处
int result = Divide(12, 0);
Console.WriteLine(result); // 输出 0,调用者根本不知道出错了
问题:调用者拿到一个 0,误以为计算成功,实际上已经出错了,后续可能引发更大的问题。
正确做法:内部不捕获,或捕获后向上抛,由调用者统一处理
static int Divide(int a, int b)
{
// 方法内部不捕获异常,让异常自然向上抛
return a / b;
}
// 调用处统一处理
try
{
int result = Divide(12, 0);
Console.WriteLine(result);
}
catch (DivideByZeroException e)
{
Console.WriteLine($"调用处捕获:{e.Message}");
}
如果方法内部不得不做点处理(比如记录日志),处理完要重新抛出:
static int Divide(int a, int b)
{
try
{
return a / b;
}
catch (DivideByZeroException e)
{
Console.WriteLine("记录日志:" + e.Message);
throw; // 重新抛出,让上层也能捕获到
}
}
这里要特别区分两个写法:
throw;—— 重新抛出原来的异常,保留原始堆栈信息,推荐。throw e;—— 重新抛出时会重置堆栈信息,导致丢失异常最初发生的位置,不推荐。
这也是一个高频面试考点。
九、自定义异常
当系统自带的异常不够用,我们可以自己定义异常类。按照规范,自定义异常应继承 System.ApplicationException(也可以直接继承 Exception,但继承 ApplicationException 更符合语义)。
// 1. 定义自己的异常类
public class AgeException : ApplicationException
{
public AgeException(string message) : base(message) { }
}
// 2. 使用自定义异常
static void SetAge(int age)
{
if (age < 0 || age > 150)
{
throw new AgeException("年龄不合法,必须在 0 到 150 之间");
}
}
// 3. 调用处捕获
try
{
SetAge(200);
}
catch (AgeException e)
{
Console.WriteLine($"自定义异常:{e.Message}");
}
十、一个完整的综合示例
把上面的知识点串起来,写一个能跑通的小程序:
using System;
class Program
{
static void Main()
{
try
{
int[] arr = { 1, 2, 3 };
Console.WriteLine(arr[10]); // 数组越界
}
catch (IndexOutOfRangeException e)
{
Console.WriteLine($"捕获到数组越界:{e.Message}");
}
catch (Exception e) // 兜底
{
Console.WriteLine($"其他异常:{e.Message}");
}
finally
{
Console.WriteLine("无论出不出错,这行都会执行");
}
Console.WriteLine("程序正常结束,没有崩溃");
}
}
运行结果:
捕获到数组越界:索引超出了数组界限。
无论出不出错,这行都会执行
程序正常结束,没有崩溃
十一、面试题
1. try-catch-finally 的执行顺序是怎样的?
答:先执行 try 块中的代码。如果没有异常,执行完 try 后跳入 finally;如果有异常,则跳到第一个匹配的 catch 处理,处理完再执行 finally。finally 是最后一步,保证一定会执行(特殊情况除外)。
2. finally 一定会执行吗?什么情况下不会执行?
答:绝大多数情况下会执行,包括 try 或 catch 中有 return 时也会先执行 finally。以下情况不会执行:
- 进程被强制终止(如
kill、断电、程序崩溃)。 - 调用了
Environment.Exit()强制退出程序。 try或catch块中陷入了死循环。finally块自身抛出了异常(会中断finally的继续执行)。
3. 如果 try 块里有 return,finally 还会执行吗?返回的是哪个值?
答:finally 会执行,且在 return 真正返回之前执行。但如果 finally 里也写了 return,它会覆盖 try 里的返回值。所以一般不建议在 finally 中写 return。
4. catch (Exception) 和 catch(不带参数)有什么区别?
答:catch (Exception e) 捕获异常并用变量 e 保存,可以访问异常信息(如 e.Message);catch 不带参数同样能捕获所有异常,但拿不到异常对象,无法查看具体信息。两者都建议放在所有具体 catch 之后作为兜底。
5. 多个 catch 块的匹配规则是什么?顺序有要求吗?
答:程序按 catch 的书写顺序,从上往下找第一个类型匹配的块执行。要求具体异常在前,通用异常(Exception)在后。如果把 catch (Exception e) 放在最前面,会编译报错,因为后面的 catch 永远不可达。
6. throw; 和 throw e; 有什么区别?
答:
throw;重新抛出原始异常,保留完整的堆栈信息,能定位到异常最初发生的位置。throw e;会重置堆栈信息,堆栈从当前这一行重新开始,丢失最初的异常发生位置,不利于排查问题。
因此重新抛出时推荐使用 throw;。
7. 自定义异常类应该继承哪个类?如何实现?
答:应继承 System.ApplicationException(该类的本意就是支持应用程序自定义异常;直接继承 Exception 也可以)。实现时提供构造方法,并调用基类构造:
public class MyException : ApplicationException
{
public MyException(string message) : base(message) { }
}
使用时通过 throw new MyException("...") 抛出,用 catch (MyException e) 捕获。
8. 异常处理有性能开销吗?是不是应该到处都用 try-catch?
答:有开销。抛出和捕获异常会涉及堆栈展开、异常对象创建等操作,比正常流程慢。因此不要滥用 try-catch,也不要靠异常来做正常的流程控制。正确的原则是:
- 能提前判断的情况用
if判断,比如除零前先判断b != 0。 - 真正"无法预知、无法提前判断"的运行时错误才用 try-catch。
- 捕获异常通常放在调用层面统一处理,而不是每个方法内部都包一层。
9. 方法内部捕获了异常,为什么调用处就捕获不到了?
答:异常一旦被某个 catch 捕获并处理(没有重新抛出),就不会继续向上传播。所以如果方法内部把异常"吞掉"了,调用处自然就捕获不到。正确做法是:方法内部不捕获,或捕获后处理完用 throw; 重新向上抛,让调用层面统一捕获处理。
10. using 语句和异常处理有什么关系?
答:using 语句在编译后等价于 try-finally 结构,用来保证资源(如文件流、数据库连接)在使用完后被自动释放(调用 Dispose 方法),即使发生异常也能正确释放,从而避免资源泄漏。

浙公网安备 33010602011771号