第 28 节 · 第四部分 进阶特性

异常处理

可能出事的语句放进 try,catch 从上往下比、接住第一种对得上的异常,finally 不管出没出事都执行;每种异常都是一个派生自 Exception 的类。

时长 8:05授课计划第 11 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1什么是异常

异常就是程序运行期间出现的问题。先看一个什么都没处理的例子:25 除以 0。

C# · 第 11-16 行
11public void division(int num1, int num2)// division 方法:做除法
12{// division 开始
13 result = num1 / num2;// 25 / 0:整数除以零,在这里出事
14 Console.WriteLine("Result: {0}", result);// 这一行根本执行不到
15
16}// division 结束
运行结果
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.
   at ErrorHandlingApplication.DivNumbers.division(Int32 num1, Int32 num2) in 异常处理/nocatch/Program.cs:line 13
   at ErrorHandlingApplication.DivNumbers.Main(String[] args) in 异常处理/nocatch/Program.cs:line 20

程序直接崩溃:第一行写着“未处理的异常”(Unhandled exception),类型是 DivideByZeroException。异常处理就是把控制权从出事的地方,转到专门处理它的地方。

视频里用分拣台演示:后排轨道是 try 段,前排每道闸口是一个 catch,右边的盖章站是 finally;出事时小车挂上红牌(throw,抛出异常),所有闸口都不认,小车就掉进坑里,程序终止。真实程序里没有小车,它只是帮你看清控制权跳到了哪里。

2try、catch、finally

  • try { }:放可能出事的语句。
  • catch (异常类型 e) { }:可以排好几个,每个接一种异常。
  • finally { }:放无论如何都要执行的语句。

try 后面可以跟一个或多个 catch;也可以不写 catch,只跟一个 finally,这叫 try-finally 语句。

C# · 第 9-18 行
9 Console.WriteLine(scores[3]);// try 里:scores 只有 3 个元素(下标 0~2),读下标 3 越界
10}// try 结束
11catch (DivideByZeroException e)// 第一道闸口:只接除以零,不认,继续往下比
12{// 第一个 catch 开始
13 Console.WriteLine("除以零:" + e.Message);// 不执行
14}// 第一个 catch 结束
15catch (IndexOutOfRangeException e)// 第二道闸口:接下标越界,对上了
16{// 第二个 catch 开始
17 Console.WriteLine("下标越界:" + e.Message);// 打印提示和异常自带的消息 e.Message
18}// 第二个 catch 结束

后面还跟着一个 finally,打印“finally 执行了”:

运行结果
下标越界:Index was outside the bounds of the array.
finally 执行了

catch 从上往下一个个比,最多只执行一个。把能接住一切的 Exception 放在最前面,后面那个永远轮不到,编译器直接报错:

C# · 第 11-15 行
11catch (Exception e)// 笼统的 Exception 写在前面,什么异常都接
12{// 第一个 catch 开始
13 Console.WriteLine("出错了:" + e.Message);// 打印消息
14}// 第一个 catch 结束
15catch (IndexOutOfRangeException e)// 具体的写在后面:永远轮不到
编译报错
Program.cs(15,14): error CS0160: 上一个 catch 子句已经捕获了此类型或超类型(“Exception”)的所有异常

所以 catch 要先写具体的,再写笼统的。

3异常类

C# 里每一种异常都是一个类,都直接或间接派生自 System.Exception。下面有两个分支:ApplicationException 原本留给程序员自己定义异常用,SystemException 装的是 .NET 预先定义好的异常。也有预定义异常直接派生自 Exception,比如网络请求出错时的 HttpRequestException:

C# · 第 6-9 行
6Type t = typeof(System.Net.Http.HttpRequestException);// typeof 取到这个异常类的类型信息
7Console.WriteLine(t.Name + " → " + t.BaseType?.Name);// 打印类名和它的父类名(?. 表示父类为空就不往下取)
8t = typeof(AggregateException);// 再看 AggregateException
9Console.WriteLine(t.Name + " → " + t.BaseType?.Name);// 打印类名和父类名
运行结果
HttpRequestException → Exception
AggregateException → Exception

八种预定义异常

  1. IOException:读写文件、流这类输入输出出错。
  2. IndexOutOfRangeException:数组下标超出范围。
  3. ArrayTypeMismatchException:往数组里放了类型不匹配的元素。
  4. NullReferenceException:变量是空值,却去用它的成员。
  5. DivideByZeroException:整数除以零。
  6. InvalidCastException:强制类型转换失败。
  7. OutOfMemoryException:内存不够了。
  8. StackOverflowException:栈溢出,常见于方法没完没了地调用自己。

前七种都真的触发了一遍。Try 是程序里的辅助方法:运行传进来的一段代码,接住异常后打印它的类型名;() => 是 lambda 写法,把后面这段代码打包交给 Try。

C# · 第 17-23 行
17Try(() => new MemoryStream().Seek(-1, SeekOrigin.Begin));// 读写位置移到开头前面:IOException
18Try(() => arr[3] = 1);// arr 只有 3 个元素,写下标 3
19Try(() => objs[0] = 1);// objs 实际是 string 数组,放进 int
20Try(() => Console.WriteLine(s.Length));// s 是 null,去取 Length
21Try(() => Console.WriteLine(10 / zero));// zero 的值是 0:10 / 0
22Try(() => Console.WriteLine((int)o));// o 里装的是字符串 "abc",硬转成 int
23Try(() => Console.WriteLine(new int[int.MaxValue].Length));// 一下子申请超大数组
运行结果
IOException
IndexOutOfRangeException
ArrayTypeMismatchException
NullReferenceException
DivideByZeroException
InvalidCastException
OutOfMemoryException

输出按顺序对应表里前七行。栈(存放方法调用记录的一块内存)溢出没法这样演示:它根本接不住,程序会直接结束。这些异常类只是表示某种错误,真正处理它的是你写的 catch。

家谱

运行结果
IOException → SystemException → Exception → Object
IndexOutOfRangeException → SystemException → Exception → Object
ArrayTypeMismatchException → SystemException → Exception → Object
NullReferenceException → SystemException → Exception → Object
DivideByZeroException → ArithmeticException → SystemException → Exception → Object
InvalidCastException → SystemException → Exception → Object
OutOfMemoryException → SystemException → Exception → Object
StackOverflowException → SystemException → Exception → Object
ApplicationException → Exception → Object

大部分直接派生自 SystemException,DivideByZeroException 中间还隔着一层 ArithmeticException。顺着往上全都通到 Exception,所以 catch (Exception e) 能接住一切。

4例子:接住除以零

C# · 第 15-23 行
15 result = num1 / num2;// try 里的除法:25 / 0 出事,result 还是 0
16}// try 结束
17catch (DivideByZeroException e)// 接住除以零的异常
18{// catch 开始
19 Console.WriteLine("Exception caught: {0}", e);// 直接打印 e:类型、消息,后面还带一长串堆栈信息
20}// catch 结束
21finally// 不管出没出事都执行
22{// finally 开始
23 Console.WriteLine("Result: {0}", result);// 打印 Result: 0
查看完整代码(34 行)
C#
1using System;// 引入 System 命名空间
2namespace ErrorHandlingApplication// 定义命名空间
3{// 命名空间开始
4 class DivNumbers// 定义 DivNumbers 类
5 {// DivNumbers 类开始
6 int result;// 字段 result:存除法结果
7 DivNumbers()// 构造函数
8 {// 构造函数开始
9 result = 0;// result 初值 0
10 }// 构造函数结束
11 public void division(int num1, int num2)// division 方法:num1 除以 num2
12 {// division 开始
13 try// 可能出事的语句放进 try
14 {// try 开始
15 result = num1 / num2;// 做除法
16 }// try 结束
17 catch (DivideByZeroException e)// 接住除以零
18 {// catch 开始
19 Console.WriteLine("Exception caught: {0}", e);// 打印异常
20 }// catch 结束
21 finally// 无论如何都执行
22 {// finally 开始
23 Console.WriteLine("Result: {0}", result);// 打印结果
24 }// finally 结束
25
26 }// division 结束
27 static void Main(string[] args)// 程序入口 Main 方法
28 {// Main 开始
29 DivNumbers d = new DivNumbers();// 创建 DivNumbers 对象
30 d.division(25, 0);// 25 除以 0
31 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
32 }// Main 结束
33 }// DivNumbers 类结束
34}// 命名空间结束
运行结果
Exception caught: System.DivideByZeroException: Attempted to divide by zero.
   at ErrorHandlingApplication.DivNumbers.division(Int32 num1, Int32 num2) in 异常处理/ex1/Program.cs:line 15
Result: 0

直接打印 e 会带出堆栈信息(以 at 开头,列出出事时一层层调用的方法和行号),只想要一句说明就打印 e.Message。把 0 改成 5 再跑:

C# · 第 30 行
30d.division(25, 5);// 25 / 5 = 5,没出事,catch 被跳过
运行结果
Result: 5

finally 照样执行。不管有没有异常它都会执行,关闭文件这类收尾工作就放在这里。

5自定义异常

C# · 第 21-26 行
21public class TempIsZeroException: ApplicationException// 自定义异常类,派生自 ApplicationException
22{// TempIsZeroException 类开始
23 public TempIsZeroException(string message): base(message)// 构造函数:用 base 把消息交给父类,存进 Message
24 {// 构造函数开始
25 }// 构造函数结束
26}// TempIsZeroException 类结束
C# · 第 32-35 行
32if(temperature == 0)// 温度是 0
33{// if 开始
34 throw (new TempIsZeroException("Zero Temperature found"));// throw:新建一个这种异常抛出去
35}// if 结束
查看完整代码(41 行)
C#
1using System;// 引入 System 命名空间
2namespace UserDefinedException// 定义命名空间
3{// 命名空间开始
4 class TestTemperature// 定义 TestTemperature 类
5 {// TestTemperature 类开始
6 static void Main(string[] args)// 程序入口 Main 方法
7 {// Main 开始
8 Temperature temp = new Temperature();// 创建 Temperature 对象,温度是 0
9 try// 可能出事的调用放进 try
10 {// try 开始
11 temp.showTemp();// 调用 showTemp:会抛出异常
12 }// try 结束
13 catch(TempIsZeroException e)// 正好接这一种自定义异常
14 {// catch 开始
15 Console.WriteLine("TempIsZeroException: {0}", e.Message);// 打印异常名和消息
16 }// catch 结束
17 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
18 }// Main 结束
19 }// TestTemperature 类结束
20}// 命名空间结束
21public class TempIsZeroException: ApplicationException// 自定义异常类
22{// TempIsZeroException 类开始
23 public TempIsZeroException(string message): base(message)// 构造函数:消息交给父类
24 {// 构造函数开始
25 }// 构造函数结束
26}// TempIsZeroException 类结束
27public class Temperature// 定义 Temperature 类
28{// Temperature 类开始
29 int temperature = 0;// 温度字段,值是 0
30 public void showTemp()// showTemp:显示温度
31 {// showTemp 开始
32 if(temperature == 0)// 温度是 0
33 {// if 开始
34 throw (new TempIsZeroException("Zero Temperature found"));// 抛出自定义异常
35 }// if 结束
36 else// 否则
37 {// else 开始
38 Console.WriteLine("Temperature: {0}", temperature);// 打印温度
39 }// else 结束
40 }// showTemp 结束
41}// Temperature 类结束
运行结果
TempIsZeroException: Zero Temperature found

微软现在建议自定义异常直接派生自 Exception(教程里派生自 ApplicationException 的说法已过时),改成这样再跑,输出一模一样:

C# · 第 21 行
21public class TempIsZeroException: Exception// 直接派生自 Exception

6抛出对象

在 catch 里也可以把接住的异常再抛出去,交给外层处理。注意 C# 关键字全是小写,要写 catch、throw,throw e 后面要加分号。

更要紧的是,重新抛出时写 throw; 和写 throw e; 效果不一样。下面的例子里,Inner 抛出异常,Middle 接住、记一笔、再抛出去,Main 最后接住,检查异常信息里还能不能看到最初出事的 Inner:

C# · 第 14-18 行
14catch (Exception e)// Middle 接住 Inner 抛出的异常
15{// catch 开始
16 Console.WriteLine("Middle 先记一笔:" + e.Message);// 先记一笔
17 throw;// 原样抛出:保留最初出事的位置
18}// catch 结束
运行结果
Middle 先记一笔:出错了
还能看到 Inner:True
C# · 第 17 行
17throw e;// 改成 throw e;:出事位置被改成 Middle
运行结果
Middle 先记一笔:出错了
还能看到 Inner:False
查看完整代码(31 行)
C#
1using System;// 引入 System 命名空间
2class Program// 定义 Program 类
3{// Program 类开始
4 static void Inner()// Inner:最初出事的地方
5 {// Inner 开始
6 throw new InvalidOperationException("出错了");// 抛出一个异常
7 }// Inner 结束
8 static void Middle()// Middle:中间转手
9 {// Middle 开始
10 try// 调用 Inner 放进 try
11 {// try 开始
12 Inner();// 调用 Inner,异常从这里冒出来
13 }// try 结束
14 catch (Exception e)// 接住
15 {// catch 开始
16 Console.WriteLine("Middle 先记一笔:" + e.Message);// 打印消息
17 throw;// 原样抛给外层
18 }// catch 结束
19 }// Middle 结束
20 static void Main(string[] args)// 程序入口 Main 方法
21 {// Main 开始
22 try// 调用 Middle 放进 try
23 {// try 开始
24 Middle();// 调用 Middle
25 }// try 结束
26 catch (Exception e)// 最外层接住
27 {// catch 开始
28 Console.WriteLine("还能看到 Inner:" + e.ToString().Contains("Inner"));// 异常信息里还有没有 Inner
29 }// catch 结束
30 }// Main 结束
31}// Program 类结束

写 throw e; 线索断了。所以在 catch 里原样转手,就写 throw;。

7小结

  • try 包住可能出事的语句,catch 从上往下比,最多接住一个;finally 不管出没出事都会执行。
  • 每种异常都是一个类,最后都派生自 Exception。
  • 自己定义异常就派生自 Exception,用 throw 抛出;原样转手写 throw;。

下一节:finally 最常见的用处是保证文件用完一定关上,那文件本身怎么打开、怎么读写呢?

检查题

5 道,选完马上告诉你对不对。

  1. 1没有 try/catch 时执行 result = 25 / 0;(变量),下一行 Console.WriteLine("Result…") 会怎样?

  2. 2try 里读 scores[3](数组只有 3 个元素),后面依次是 catch (DivideByZeroException)、catch (IndexOutOfRangeException)、finally,输出几行、是什么?

  3. 3把 catch (Exception e) 放在 catch (IndexOutOfRangeException e) 前面会怎样?

  4. 4教程例子改成 d.division(25, 5),输出是?

  5. 5Middle 接住异常后重新抛出,Main 检查异常信息里还有没有最初出事的 Inner:写 throw; 和写 throw e; 分别是?