在之前,我们学习了用于执行常规任务的C#语句,包括编写方法、声明变量、用操作符对值进行运算等。但我们一直没有提到程序可能出错的问题。
事实上,没有谁能保证自己的代码总是能像希望的那样工作。在实际环境中总是会有各种各样的原因造成出错,并且是超出你控制范围的(你永远不知道你的用户会干多离谱的事)。所以我们希望有一种得体的方式处理运行时发生的错误:要么进行纠正,要么在无法纠正的情况下原地爆炸报告错误原因。因此,作为第Ⅰ部分的最后一章,我们将学习C#如何通过抛出异常来通知发生了错误,如何用 try
, catch
和 finally
语句来捕捉并处理这些异常。
通过本章的学习,你将能在用户让你闭眼开车时要求他提供日志你将进一步掌握C#语言,为顺利学习第Ⅱ部分的内容打下牢固的基础(大嘘)。
处理错误
写mod的生活并非总是一帆风顺。mod可能天天NullRef,用户可能给你找茬,AI可能总是莫名其妙卡住。甚至附近的一次闪电都能导致电源或者网络故障。错误可能在程序运行的任何阶段发生,其中许多都不是程序本身的问题(可能是你的问题)。那么,如何检测并尝试修复呢?
人们多年来为此研发了大量机制,如UNIX采用的典型方案要求在每次方法出错时都由操作系统设置一个特殊全局变量,每次调用方法后都检查该全局变量来判断方法是否成功执行。这种方式显然太坐牢了,所以和大多数面向对象编程语言一样,C#使用异常来处理错误。为了写健壮的C#应用程序,必须很好地掌握异常。
尝试执行代码和捕捉异常
正如前面所说,如果你使用那种传统的技术手动为每个语句添加错误检测代码,那不仅费时费力,而且还很容易出错。另外,如果每个语句都需要错误处理逻辑来管理每个阶段都可能发生的每个错误,那你的mod将很快被错误处理逻辑塞得一团糟。幸好,你是一名使用C#的开发者,借助异常和异常处理程序,你可以很容易地区分实现程序主逻辑的代码和处理错误的代码。为了写支持异常处理的代码,我们引入 try
和 catch
关键字。
try { int a = int.Parse(Console.ReadLine()); int b = int.Parse(Console.ReadLine()); int answer = a + b; Console.WriteLine(answer); } catch (FormatException ex) { // 在这里处理异常 Console.WriteLine("输入值无法被转换为int!"); // 输出一下异常 Console.WriteLine(ex); }
在这段程序中,我们从控制台读入两个整数并输出它们相加的结果。通常,我们的输入是合法的,但你很难保证用户不在你的酒吧点炒饭总是能给出正常的输入。比如,输入值是”裙裙可爱“的时候该怎么处理呢?很显然你无法将它转换为一个 int
。此时 int.Parse()
方法会抛出一个 FormatException
对象,然后你编写的 catch
代码块就会像一个方法一样捕捉这个异常(你可以把它当成一个传入方法的参数),并运行你的错误处理代码。
注意,如果没有发生异常,catch
中的代码将不会被执行。
异常处理
上面的代码只是异常处理的一个简单示例,为了更好地使用异常处理机制,我们还需要了解更多。
未经处理的异常
如果你的代码抛出了异常,但在由内向外遍历了所有调用方法后也找不到对应的 catch
程序,那么你的程序就会原地爆炸发生未经处理的异常并终止。如果你使用Visual Studio调试运行程序,那么发生这样的事件时Visual Studio会暂停应用程序并显示下面的对话框:
这时你可以像之前使用断点一样检查和更改局部变量的值,尝试找到发生错误的代码。你还可以使用调试工具栏和各种调试窗口,从抛出异常的位置单步调试代码。
使用多个catch处理程序
事实上,不同的错误发生时可能抛出不同类型的异常。为了解决这个问题,我们可以为同一 try
块使用多个 catch
处理程序。接下来的例子中,int.Parse()
方法可能抛出 FormatException
,而执行算数运算时可能发生溢出(如果你自己在基元数据类型那一节查找了资料,你应该知道这是什么),因此我们可以写下面的代码:
try { int a = int.Parse(Console.ReadLine()); int b = int.Parse(Console.ReadLine()); int answer = a + b; // 你可以尝试输入2147483647和114514,看看会发生什么事 Console.WriteLine(answer); } catch (FormatException) { Console.WriteLine("非法的数字输入!"); } catch (OverflowException) { Console.WriteLine("输入的值计算后超出了int类型的最大值"); }
需要注意的是,如果 catch
块中的代码抛出异常,那么它的异常会传播到调用当前代码的方法,而不是由临近的其他 try
代码块处理。换言之,异常会“传播”到调用栈的上一级。
捕捉多个异常
C#具有相当完善的异常捕捉机制。.NET定义了许多异常类型,包括程序可能抛出的大多数异常。一般我们不可能为每个可能的异常都写对应的 catch
处理代码块——甚至用户总能给你整出花活整出点意想不到的异常。那么,如何保证所有可能的异常都被捕捉并处理呢?
事实上,异常用继承层次结构进行组织,这个层次结构由多个“家族”构成(继承的概念将在第12章详细讨论)。上面例子中的两个异常 OverflowException
和 FormatException
都是 SystemException
家族的成员。这个家族也包含许多别的异常。而 SystemException
本身又是 Exception
家族的成员。而 Exception
是所有异常的“老祖宗”。如果你捕捉了 Exception
异常,就相当于捕获了所有异常。
接下来的例子中我们展示一种极为常见的通用异常处理程序:
try { int a = int.Parse(Console.ReadLine()); int b = int.Parse(Console.ReadLine()); int answer = a + b; Console.WriteLine(answer); } catch (Exception ex) { Console.WriteLine($"发生异常:{ex}"); // 输出异常信息 }
如果你决定要捕捉 Exception
,那么你可以将 catch
后面的括号和参数省略。但我并不推荐你这样做,因为不接收参数的 catch
块可能丢失关于异常的重要信息。因此你应该尽量捕获异常对象,并利用其包含的重要信息。
还有另一个问题,如果有多个 catch
块能对同一个异常进行处理会发生什么?假如一个 catch
块捕捉转换数字发生错误时抛出的 FormatException
,而随后又有一个 catch
块捕捉 Exception
,最终会怎样呢?
答案是,异常发生后,由“运行时”(Runtime)发现的第一个匹配的异常处理将会运行。如果先捕捉了所有异常,那么后面所有其他的 catch
块都不会运行。因此,在使用 try
块时,应该先将较具体的 catch
处理程序放在前面,较为通用的处理程序放在后面,最后再捕捉 Exception
。
异常过滤器
有一种使用 when
关键字的语法,可以让你指定异常处理程序的额外运行条件。比如,如果你想捕获除了内存不足时抛出的 OutOfMemoryException
之外的所有异常,你可以采用下面的写法:
try { int answer = int.Parse(Console.ReadLine()) + int.Parse(Console.ReadLine()); Console.WriteLine(answer); } catch (Exception ex) when (ex.GetType()!=typeof(OutOfMemoryException)) { // ... }
when
关键字后面的括号中接收一个布尔表达式,仅在该表达式为真时才运行对应的 catch
块。
异常的传播
异常发生时,如果没有找到匹配的 catch
块,那么异常将会“传播”到上一级的方法中。
为了理解这种现象,你可以参考下面的例子:
static void ProcessAdd() { int lhs = int.Parse(Console.ReadLine()); int rhs = int.Parse(Console.ReadLine()); int answer = lhs + rhs; Console.WriteLine(answer); } static void ProcessMinus() { int lhs = int.Parse(Console.ReadLine()); int rhs = int.Parse(Console.ReadLine()); int answer = lhs + rhs; Console.WriteLine(answer); } static void Main(string[] args) { string operation = Console.ReadLine(); try { if (operation == "+") { ProcessAdd(); } else if (operation == "-") { ProcessMinus(); } } catch (FormatException) // 如果没有用到异常对象,你可以省略ex标识符 { Console.WriteLine("输入值无法转换为数字!"); } catch (OverflowException) { Console.WriteLine("算术运算结果超出int范围"); } catch (Exception ex) when(ex is not OutOfMemoryException) // 一种模式匹配的写法 { Console.WriteLine($"发生了异常:{ex}"); } }
运行上面的代码,尝试输入一些非法值,你会发现,尽管我们没有分别在 ProcessAdd()
和 ProcessMinus()
中分别编写异常处理代码,但它们都被 Main()
中的对应 catch
块捕获。这种传播的机制能避免你写大量重复的异常处理代码。因此,在编写模组时,你应该选择合适的地方放置异常处理程序。
抛出异常
有时当我们编写的方法(以及别的什么东西)接收到非法的输入时,可以选择抛出异常(当然,你也可以选择返回像-1或者null这样的默认值,如何设计不是本教程讨论的内容)。为了“抛出”——也就是引发一个异常,我们需要使用 throw
语句。例如,下面的方法用来将月份的数字转换为对应的字符串:
switch (month) { case 1: return "January"; case 2: return "February"; case 3: return "March"; case 4: return "April"; case 5: return "May"; case 6: return "June"; case 7: return "July"; case 8: return "August"; case 9: return "September"; case 10: return "October"; case 11: return "November"; case 12: return "December"; default: throw new ArgumentOutOfRangeException("不存在的月份"); // 输入了不存在的月份数值 }
调用该方法,并以调试模式运行程序,传入一个非法的 month
值,Visual Studio将能捕获到对应的异常。
然后你可以在 Main()
或者是调用该方法的上级方法中处理对应的异常,向用户提供必要的信息并记录日志。
使用finally块
你需要注意,只要异常发生,程序执行的流程就会改变。这意味着你无法保证一个语句结束后,下一条语句一定会执行,因为该语句可能抛出异常。我们之前也提到,catch块处理代码执行结束后会从整个try-catch块的下一条语句开始执行。而有时某些语句的执行是非常必须的,例如某个语句用于释放某个语句获取的资源——比如文件句柄,甚至是数据库连接。如果该资源不被释放,那么它将无法再被其他语句访问。因此,我们引入了 finally
块,C#保证无论异常是否发生都会执行 finally
块中的语句。例如,如果我们使用下面的代码来读取文本文件(尽管实际情况下我们几乎不这样写),为了保证 reader
对象被释放,我们使用一个try-finally结构:
StreamReader reader = new StreamReader("1.txt"); try { string line = reader.ReadLine(); while(line != null) { Console.WriteLine(line); line=reader.ReadLine(); } } finally { if (reader != null) { reader.Dispose(); } }
这样,即使在读取文件时发生了异常,finally
块也将保证 reader.Dispose()
语句得到执行,在第14章我们会介绍解决该问题的另一种方案(使用using语句)。
第一部分就到这里结束了。在第二部分中,我们将介绍C#的对象模型,学习类和结构的使用,并了解面向对象编程的基本特点。