5.9 用异常实现基本错误处理
本节将探讨如何利用异常处理机制来解决错误报告的问题。方法利用异常处理将有关错误的信息传给调用者,同时不需要使用返回值或显式提供任何参数。代码清单5.22略微修改了第1章的HeyYou程序(代码清单1.16)。这次不是请求用户输入姓氏,而是请求输入年龄。
代码清单5.22 将string转换成int
输出5.11展示了结果。
输出5.11
System.Console.ReadLine()的返回值存储在ageText变量中,然后传给int数据类型的Parse()方法。该方法获取代表数字的string值并转换为int类型。
初学者主题:42作为字符串和整数
C#要求每个非空值都有一个良好定义的类型。换言之,数据不仅值很重要,它的类型也很重要。所以,字符串42和整数42完全不同。字符串由4和2这两个字符构成,而int是数值42。
基于转换好的字符串,System.Console.WriteLine()语句以月份为单位打印年龄(age*12)。
但是,用户完全有可能输入一个无效的整数字符串。例如,输入“forty-two”会发生什么?Parse()方法不能完成这样的转换。它希望用户输入只含数字的字符串。如果Parse()方法接收到无效值,它需要某种方式将这一事实反馈给调用者。
5.9.1 捕捉错误
为通知调用者参数无效,int.Parse()会抛出异常[1]。抛出异常会终止执行当前分支,跳到调用栈中用于处理异常的第一个代码块。
由于当前尚未提供任何异常处理,所以程序会向用户报告发生了未处理的异常。如系统中没有注册任何调试器,错误信息会出现在控制台上,如输出5.12所示。
输出5.12
显然,像这样的错误消息并不是特别有用。为解决问题,需要提供一个机制对错误进行恰当的处理,例如向用户报告一条更有意义的错误消息。
这个过程称为捕捉异常。代码清单5.23展示了具体的语法,输出5.13展示了结果。
代码清单5.23 捕捉异常
输出5.13
首先用try块将可能抛出异常的代码(age=int.Parse())包围起来。这个块以try关键字开始。try关键字告诉编译器:开发者认为块中的代码有可能抛出异常,如果真的抛出了异常,那么某个catch块要尝试处理这个异常。
try块之后必须紧跟着一个或多个catch块(或一个finally块)。catch块(参见稍后的“高级主题:常规catch”)可指定异常的数据类型。只要数据类型与异常类型匹配,对应的catch块就会执行。但假如一直找不到合适的catch块,抛出的异常就会变成一个未处理的异常,就好像没有进行异常处理一样。图5.1展示了最终的程序流程。
例如,假定输入“forty-two”,int.Parse()会抛出System.FormatException类型的异常,控制会跳转到后面的一系列catch块(System.FormatException表明字符串格式不正确,无法进行解析)。由于第一个catch块就与int.Parse()抛出的异常类型匹配,所以会执行这个块中的代码。但假如try块中的语句抛出的是不同类型的异常,执行的就是第二个catch块,因为所有异常都是System.Exception类型。
如果没有System.FormatException catch块,那么即使int.Parse抛出的是一个System.FormatException异常,也会执行System.Exception catch块。这是由于System.FormatException也是System.Exception类型(System.FormatException是泛化异常类System.Exception的一个更具体的实现)。
虽然catch块的数量随意,但处理异常的顺序不要随意。catch块必须从最具体到最不具体排列。System.Exception数据类型最不具体,所以它应该放到最后。System.FormatException排在第一,因为它是代码清单5.23所处理的最具体的异常。
无论try块的代码是否抛出异常,只要控制离开try块,finally块就会执行。finally块的作用是提供一个最终位置,在其中放入无论是否发生异常都要执行的代码。finally块最适合用来执行资源清理。事实上,完全可以只写一个try块和一个finally块,而不写任何catch块。无论try块是否抛出异常,甚至无论是否写了一个catch块来处理异常,finally块都会执行。代码清单5.24演示了一个try/finally块,输出5.14展示了结果。
图5.1 异常处理控制流程
代码清单5.24 有finally块但无catch块
输出5.14
细心的读者能看出蹊跷。“运行时”是先报告未处理的异常,再运行finally块。这种行为该如何解释?
首先,该行为合法,因为对于未处理的异常,“运行时”的行为是它自己的实现细节,任何行为都合法!“运行时”选择这个特定的行为是因为它知道在运行finally块之前,异常就已经是未处理的了。“运行时”已检查了调用栈上的所有栈帧,发现没有任何一个关联了能和抛出的异常匹配的catch块。
一旦“运行时”发现未处理的异常,就会检查是否在机器上安装了调试器,因为用户可能是软件开发者,正要对这种错误进行分析。如果是,就允许用户在运行finally块之前将调试器与进程连接。没有安装调试器,或用户拒绝调试,默认行为就是在控制台上打印未处理的异常,再看是否有任何finally块可供运行。注意由于这是“实现细节”,所以“运行时”并非一定要运行finally块,它完全可以选择做其他事情。
设计规范
·避免从finally块显式抛出异常(因方法调用而隐式抛出的异常可以接受)。
·要优先使用try/finally而不是try/catch块来实现资源清理代码。
·要在抛出的异常中描述异常为什么发生。如果可能,顺带说明如何防范更佳。
高级主题:Exception类继承
从C# 2.0起,所有异常都派生自System.Exception类。(从其他语言抛出的异常类型如果不是从System.Exception派生,会自动由一个从中派生的对象“封装”。)所以,它们都可以用catch(System.Exception exception)块进行处理。但更好的做法是写专门的catch块来处理更具体的派生类型(例如System.FormatException),从而获取有关异常的具体信息,有的放矢地处理,避免使用大量条件逻辑来判断具体发生了什么类型的异常。
这正是C#规定catch块必须从“最具体”到“最不具体”排列的原因。例如,用于捕捉System.Exception的catch语句不能出现在捕捉System.FormatException的catch语句之前,因为System.FormatException较System.Exception具体。
一个方法可以抛出许多异常类型。表5.2总结了.NET Framework的一些较为常见的类型。
表5.2 常见异常类型
高级主题:常规catch
可指定一个无参的catch块,如代码清单5.25所示。
代码清单5.25 常规catch块
没有指定数据类型的catch块称为常规catch块,等价于获取object数据类型的catch块,例如catch(object exception){...}。由于所有类最终都从object派生,所以没有数据类型的catch块必须放到最后。
常规catch块很少使用,因为没办法捕捉有关异常的任何信息。此外,C#不允许抛出object类型的异常,只有使用C++这样的语言写的库才允许任意类型的异常。
从C# 2.0起异常的行为稍微有别于之前的版本。在C# 2.0中,如果遇到用另一种语言写的代码,而且它会抛出不是从System.Exception类派生的异常,那么该异常对象会被封装到一个System.Runtime.CompilerServices.RuntimeWrappedException中,后者从System.Exception派生。换言之,在C#程序集中,所有异常(无论它们是否从System.Exception派生)都会表现得和从System.Exception派生一样。
结果就是,捕捉System.Exception的catch块会捕捉之前的块没有捕捉到的所有异常,同时,System.Exception catch块之后的一个常规catch块永远得不到调用。所以,从C# 2.0开始,假如在捕捉System.Exception的catch块之后添加了一个常规catch块,编译器就会报告一条警告消息[2],指出常规catch块永远不会执行。
设计规范
·避免使用常规catch块,用捕捉System.Exception的catch块代替。
·避免捕捉无法从中完全恢复的异常。这种异常不处理比不正确处理更好。
·避免在重新抛出前捕捉和记录异常。要允许异常逃脱(传播),直至它被正确处理。
5.9.2 使用throw语句报告错误
C#允许开发者从代码中抛出异常,代码清单5.26和输出5.15对此进行了演示。
代码清单5.26 抛出异常
输出5.15
如代码清单5.26的箭头所示,抛出异常会使执行从异常的抛出点跳转到与抛出的异常类型兼容的第一个catch块[3]。本例是第二个catch块处理抛出的异常,它在屏幕上输出一条错误消息。在代码清单5.26中,由于没有finally块,所以随后执行try/catch块后的System.Console.WriteLine()语句。
抛出异常需要有异常的实例。代码清单5.26使用关键字new后跟异常的数据类型创建了这样的实例。大多数异常类型都允许在抛出该类型的异常时传递消息,以便在发生异常时获取消息。
有时catch块能捕捉异常,但不能正确或完整地处理。这时可让该catch块重新抛出异常,具体方法是使用一个独立throw语句,不要在它后面指定任何异常,如代码清单5.27所示。
代码清单5.27 重新抛出异常
注意代码清单5.27中的throw语句是“空”的,没有指定exception变量所引用的异常。区别在于,throw;保留了异常中的“调用栈”信息,而throw exception;将那些信息替换成当前调用栈信息。而调试时一般需要知道原始调用栈。
设计规范
·要在捕捉并重新抛出异常时使用空的throw语句,以便保留调用栈。
·要通过抛出异常而不是返回错误码来报告执行失败。
·不要让公共成员将异常作为返回值或者out参数。抛出异常来指明错误,不要把它们作为返回值来指明错误。
避免使用异常处理来处理预料之中的情况
开发者应避免为预料之中的情况或正常控制流程抛出异常。例如,开发者应事先料到用户可能在输入年龄时输入无效文本[4],所以不要用异常来验证用户输入的数据。相反,应在尝试转换前对数据进行检查(甚至可以考虑从一开始就防止用户输入无效数据)。异常是专为跟踪例外的、事先没有预料到的、可能造成严重后果的情况而设计的。为预料之中的情况使用异常,会造成代码难以阅读、理解和维护。
在第2章中我们曾使用int.Parse()方法将字符串转换为整型数值。Parse()方法的问题在于,由于用户输入的内容有可能不是正确的数字,所以只能调用Parse()尝试转换一下,并且做好捕捉转换失败异常的准备。但是抛出异常的成本相对较高,因此如果能够有不抛异常的转换方法会更好。基于这个原因,应该使用TryParse()系列转换方法,比如int.TryParse()。该方法要求使用out关键字通过参数返回转换结果,因为它的返回值是bool类型,被用于表示转换是否成功。代码清单5.28演示了使用int.TryParse()方法执行转换。
代码清单5.28 使用int.TryParse()执行转换
使用TryParse()方法就无须再为字符串到数值的转换使用try/catch块。
前文提到了抛出异常的成本较高,这里的成本主要是指程序的运行效率。此外,和大多数语言一样,C#在抛出异常时会产生些许性能损失——相较于大多数操作都是纳秒级的速度,它可能造成毫秒级的延迟。人们平常注意不到这个延迟——除非异常没有得到处理。例如,执行代码清单5.22的程序并输入一个无效年龄,由于异常没有得到处理,所以当“运行时”在环境中搜索可以加载的调试器时,你会感觉到明显延迟。幸好,程序都已经在关闭了,性能好坏也无谓了。
设计规范
·不要用异常处理正常的、预期的情况,用它们处理异常的、非预期的情况。
[1] 本书使用“抛出异常”而非“引发异常”。——译者注
[2] 具体警告消息是“上一个catch子句已捕获所有异常。抛出的所有非System.Exception派生的异常均被包装在System.Runtime.CompilerServices.RuntimeWrappedException中”。——译者注
[3] 技术上说也可能被一个兼容的catch筛选器捕捉。
[4] 通常,开发者必须假定用户会采取非预期的行为,所以应防卫性地写代码,提前为所有想得到的“愚蠢用户行为”制订对策。