2.5 异常处理
不知道大家有没有听说过“正常化偏见”(normalcy bias)这个词。所谓正常化偏见指的是人们的一种心理倾向,对于一些偶然发生的情况,一旦发生了便会不自觉地忽略其危害。
在之前发生的大地震1中,虽然发布了海啸预警,但据说还是有很多人,由于觉得“这次也没什么大不了的”、“海啸不会袭击这里的”而不幸遇难。我认为,这并不是说那些人很愚蠢,而是说明人类是很容易受到“正常化偏见”这种心理倾向的影响的。如果遇到同样的状况,换做你和我的话,很可能也会做出同样错误的判断。
1 这里指的是 2011 年 3 月 11 日发生的日本东北地方太平洋近海地震,震级达到 9.0 级。
“一定没问题的”
程序员也是人,同样无法逃脱正常化偏见的影响。对于程序运行中所发生的异常情况,总是会觉得“这种情况一般不会出现的”、“所以不解决也没关系”。例如,大家可能会这样想:“配置文件肯定会被安装进去的,因此不必考虑配置文件不存在的情况”,“网络通信中丢包之类的 问题 TCP 层会帮忙搞定的,因此应该不用考虑通信失败的情况”。总是把情况往好的方面设想,这样的心理在程序员中很常见。
然而,正如墨菲定律2所说的,即便是极少会发生的情况,只要有发生的可能性,早晚是会发生的。说不定就有人不小心把配置文件手动删除了,也说不定就在网络通信过程中路由器断电了,计划永远赶不上变化。一旦发生异常情况,就该怪自己平时没做好应对了。“哎呀,早知道当初就该好好应对的”,现在才意识到这一点,也只能是马后炮了。
2 墨菲定律(Murphy's Law),原来的表述是“凡是可能出错的事都会出错”(Anything that can go wrong will go wrong),意思是说,任何一个事件只要发生的概率大于零,就不能假设它不会发生。
软件开发的历史,就是和 bug 斗争的历史。最早的 bug 是由于一只臭虫(bug)卡在组成计算机的继电器中所引发的。在开发软件的过程中,几乎不会有人想到会有虫子卡在电路里面吧,这真是一个意外。不过,在软件开发中,还是必须对各种事态都做出预计才行。
用特殊返回值表示错误
那么,作为例题,我们来举一个非常简单的打开文件操作的例子。在 C 语言中,将文件以读方式打开的程序如图 1 所示。
#include <stdio.h> int main() { FILE *f = fopen("/path/to/file","r"); if (f == NULL) { puts("file open failed"); } else { puts("file open succeeded"); } }图 1 C 语言中的文件打开操作
C 语言的打开文件函数 fopen,会将位于指定路径的文件以指定的模式(读 / 写 / 追加)打开。打开成功时,返回指向 FILE 结构体的指针;打开失败时,则返回 NULL。
让 fopen 返回 NULL 的原因有很多,全部列举出来实在太难了。下面举几个有代表性的例子:
· 文件不存在
· 没有权限访问该文件
· 该进程中已打开的文件数量太多
· 指定的路径不是一个文件而是一个目录
· 内核内存不足
· 磁盘已满
· 指定了非法的路径地址
上面这些只不过是失败原因的一部分而已,感觉很头大吧。
在 C 语言中,表示错误的主要方式是通过“特殊返回值”。大多数情况下,和 fopen 一样,通过返回 NULL 来表示错误。
容易忽略错误处理
使用特殊返回值这个方法不需要编程语言的支持,是一种非常简便的方法,但它有两个重大的缺点。
第一,由于对错误的检测不是强制进行的,可能会出现没有注意到发生了错误而继续运行程序的情况。如果没有注意到文件打开失败,依然去访问 FILE 结构体的话,整个程序就会出错崩溃。仅仅因为要打开的文件不存在就崩溃的程序,实在是太差劲了。
对于文件不存在这种比较常见的状况,一般来说大概不会疏于应对。不过,发生概率比较低的意外情况却很容易被忽略。例如,分配内存的函数 malloc,在内存不足时会返回 NULL 以表示错误,这一点在文档上写得清清楚楚,却还是有很多程序没有做出应对。如果写一个总是返回 NULL 的 malloc 函数连接上去的话,就会惊奇地发现居然有那么多程序根本就不检查 malloc 的返回值。
第二,原本的程序容易被错误处理埋没。错误处理是对意外性的异常事态所做的应对,并不是我们本来想做的事。然而,正如之前讲过的,我们又不能忽略错误的存在,于是本来只是配角的错误处理部分就会在程序中喧宾夺主。
我想执行的是一系列简单的操作:打开文件,从文件中逐行读取内容,加工之后输出到标准输出设备(stdout)。而实际的代码却变成了十分繁琐的内容:打开文件……打开了吗?没打开的话显示错误信息然后程序结束;读取 1 行内容……读取成功了吗?没成功的话,如果到了文件末尾则程序结束,如果没到文件末尾,则忽略该行;将读取的内容进行加工,然后输出结果;加工过程中如果发生错误,别忘了对错误进行处理……
你有没有觉得太麻烦了?这种感觉太正常了。不过,受过良好训练的 C 语言程序员则不会有任何怨言,因为他们多年以来一直都在重复着这样的辛苦工作。
Ruby 中的异常处理
那么,对于这样的“错误地狱”,编程语言方面又提供了怎样的支持呢?
正如上面所总结的,其实问题点有两个:没有检查错误就继续运行,错误处理将原本的程序埋没。
于是,在比较新的语言中,采用了称为异常(exception)的机制,以减轻错误处理的负担。一旦发生意外情况,程序中就会产生异常,并同时中断程序运行,回溯到当前过程的调用者。经过逐级回溯,到达程序顶层之后,输出错误信息,并停止程序的运行。不过,如果明确声明了“在这里捕获异常”的话,异常就会被捕获,并进行错误处理。
图 2 是将图 1 程序所执行的操作,用 Ruby 来编写的程序。当调用用来打开文件的 open 方法时,会返回一个 File 对象。如果发生错误,open 的执行就会中断。在这里我们没有对捕获异常进行声明,因此产生的异常就不会被捕获,程序会显示错误信息,并终止运行。异常事态发生时的运行终止和错误信息的输出都是自动完成的,这样一来程序便可以集中完成它的本职工作。
f = open("/path/to/file", "r") puts("file open succeeded")图 2 Ruby 中的文件打开操作
在 Ruby 中,对异常的捕捉使用 begin 语句来 完成。begin 所包围的代码中如果产生了异常,则会执行 rescue 部分的代码(图 3)。
begin f = open("/path/to/file", "r") puts("file open succeeded") rescue puts("file open failed") end图 3 Ruby 中的异常处理
由于有了这样的异常处理机制,在 C 语言流派中的显式错误检查所具有的那两个问题得到了一定的缓解。也就是说,当意外状况发生时,通过自动中断程序运行的方式,避免了每进行一步操作都要显式地检查错误,从而也就避免了程序中充满错误检查代码的问题。
不过,当产生异常时也不能总是让程序结束运行,当显式声明需要进行错误处理时,可以恢复产生的错误,并让程序继续运行。
产生异常
下面我们来看看如何人为产生异常。产生异常,可以使用 raise 方法。在 Ruby 中,raise 并不是一个保留字,而是一个方法。raise 方法被调用时,会创建一个用来表示异常的对象,并中断程序运行。在这个过程中,如果存在与异常对象匹配的 rescue 代码,则跳转到该处进行异常处理。
raise 方法的调用有好几种方式,可以根据状况选择合适的调用方式。首先,最基本的方式是仅指定一条错误信息。
raise "something bad happens"这条语句会产生一个 RuntimeError 异常。如果不在意异常的类型,只要表达出有错误信息就可以的话,用这种方式是没有问题的。
下面这种方式同时指定了异常类和错误信息。
raise TypeError, "wrong type given"这里指定了 Exception 类的一个子类作为异常类。raise 会在内部创建一个指定类的实例,并中断当前程序的运行。第 2 种方式中,还有一个可选的第 3 参数,这个参数可以传递一个数组,用于保存回溯(backtrace,即从哪个函数的第几行进行的调用)信息。
如果要在 rescue 部分中重新产生异常,可以在 raise 方法中指定一个异常对象。
raise exc在这种方式中,包含回溯在内的异常信息都被保存在对象中,从而可以将异常抛给位于其上层的代码进行处理。
还有最后一种方式,即可以省略所有的参数,直接调用 raise 方法。如果在 rescue 中用这种方式进行调用的话,会重新产生最近产生过的一个异常。如果在 rescue 外面的话,则会产生一个错误信息为空的 RuntimeError。
更高级的异常处理
用于异常处理的 rescue,会捕获到 begin 所包围的区域中产生的异常,但在这个范围内可能产生的异常往往不止一种。通过在 rescue 后面指定异常的种类(类),就可以针对不同种类的异常分别做出不同的应对(图 4)。更详细的异常信息可以通过在“=>”后面指定变量名来获取。
begin f = open("/path/to/file", "r") puts("file open succeeded") rescue Errno::ENOENT => e puts("file open failed by ENOENT") rescue ArgumentError => e puts("file open failed by ArgumentError") end图 4 对多个异常的处理
产生异常时的应对方法,原则上分为两种。一种是中断运行。由于异常产生时会跳转到 rescue,因此可以说中断运行是异常处理的默认方式。
当然,有些情况下,我们并不希望整个程序都停止运行。例如,编辑器要读取一个文件,即便指定文件名不存在,也不能光弹出一条错误信息就退出了吧?这种情况下,应该通过异常处理程序弹出一个警告对话框,然后返回并重新接受用户输入才对。这其实也是中断运行的一个变种。
另一种应对方法,是消除产生异常的原因并重试。为此,Ruby 中有一个 retry 语句,在 rescue 中调用 retry 的话,会跳转回相应的 begin 处重新运行。
图 5 中的程序就是应用 retry 的一个例子。这次我们用 open 方法以写模式打开一个名为 /tmp/foo/file 的文件。然而,/tmp/foo 这个目录不存在的话,就会产生异常。于是在 rescue 中,我们用 mkdir 创建该目录,然后再执行 retry。这样一来,程序会返回 begin 的部分重新运行,这次 open 就可以成功打开文件了。
begin f = open("/tmp/foo/file", "w") puts("file open succeeded") rescue Errno::ENOENT => e puts("file open failed by ENOENT") Dir.mkdir("/tmp/foo") retry rescue ArgumentError puts("file open failed by ArgumentError") end图 5 调用 retry 进行重试
通过 retry 可以在异常处理中实现重试的操作,非常方便。不过它也有一个缺点,那就是如果在 retry 之前没有仔细检查是否对产生异常的条件进行了充分应对的话,就很有可能陷入死循环。
在异常处理完成之后,有时还需要转移到上层的异常处理程序做进一步处理。刚才已经讲过,在 rescue 中直接调用 raise 就可以重新产生异常(图 6)。例如,如果要直接显示顶层错误信息的话,就可以使用这种方式。
begin # 可能会产生异常的处理 rescue # 异常处理程序。输出消息 puts "exception happened" # 重新产生异常 raise end图 6 重新产生异常
Ruby 中的后处理保证
rescue 是用来在产生异常的时候进行错误处理的,除此之外,还有一种方式,可以执行一些无论是否产生异常都需要进行的一些清理工作。
以打开文件的操作为例,当处理完成后,无论是正常结束,还是产生了异常,都必须将文件关闭。
在 Ruby 中,使用 open 方法可以保证将打开的文件进行关闭操作(图 7)。如果在调用 open 方法时附加一个代码块,当代码块执行完毕后,就会自动关闭文件。
open("/path/to/file", "r") do |f| # 对f的处理 end图 7 带代码块的 open
那么,这样的机制如果要自己来实现的话,该如何做呢?
在 Ruby 中,可以使用 ensure。在 begin 部分中如果指定了 ensure,则 begin 部分执行完毕后必定会执行 ensure 部分。这里所说的“执行完毕”,包括执行到代码末端而正常结束的情况,也包括产生异常,或者通过 break、return 等中途跳出的情况。只要使用 ensure,就可以实现和带代码块的 open 调用同样的功能(图 8)。
def open_close(path, mode, &block) f = open(path, mode) begin block.call(f) ensure f.close end end图 8 ensure 必定会被执行
ensure 的起源是来自 Lisp 的 unwind-protect 函数。这个函数名的意思是,当访问磁带设备出错时,防止(protect)出现磁带没有回卷(unwind)的情况。
其他语言中的异常处理
刚才我们将了 Ruby 中的异常处理,当然,其他语言中也具备异常处理的功能。例如在 Java 中,对应关系是这样的:
begin → try rescue → catch ensure → finally在 C++ 中 try 和 catch 是和上面相同的,不过没有 finally。在 C++ 中,可以通过栈对象的析构函数(函数结束时必定会被调用)来实现相当于 ensure 的功能。
Java 的检查型异常
Java 的异常处理具有其他语言所不具备的特性,即每个方法都需要显式地声明自己可能会产生什么样类型的异常。图 9 是 Java 中的方法定义(节选)。在数据类型、方法名和参数之后,有一段形如 throws 异常的代码,用于声明可能会产生的异常。
void open_file() throws FileNotFoundException { return new FileReader("/path/to/file"); }图 9 Java 的方法定义(带异常)
并且,在 Java 中调用某个方法时,对于在该方法定义中所声明的异常,如果没有用异常处理来进行捕获,且没有用 throws 继续抛给上层的话,就会产生一个编译错误,因为异常已经成为方法的数据类型的一部分了。像这样的异常被称为检查型异常(checked exception)。在广泛使用的编程语言中,Java 应该是第一个采用检查型异常的语言。
检查型异常可以由编译器对遗漏捕获的异常进行检查,从这个角度来说,这个功能相当有用,也是符合 Java 的一贯策略的,正如在 Java 中采用静态数据类型来主动规避类型不匹配的思路是一样的。
不过,检查型异常也遭到了一些批判。异常之所以被称为异常,本来就因为它很难事先预料到。明知如此,还非要在代码中强制性地事先对异常做好声明,以避免产生编译错误,这实在是太痛苦了。
在有些情况下,Java 的方法会抛出如 SQLException 和 IOException 这样的异常,尽管实际上这些错误跟数据库和文件没什么关系。很显然,这是由于在实现这些功能时所调用的方法抛出了这些异常,但将这些实现的详细信息展现给用户是完全没有必要的。
尽管如此,如果每次都一定要按照方法的含义去更换异常的类型,或者为了避免编译器出错而硬着头皮写代码去捕获异常,这就显得本末倒置了。数据类型的问题也是一样,碰到编译错误,也就是把编译器给“惹毛了”。如果说因为真正的程序错误惹毛了编译器也就算了,要是仅仅因为异常的类型稍稍不合就大发雷霆的话,那这个编译器也太神经过敏了。而且,如果只是为了迁就编译器就非要编写一大堆异常处理代码的话,那异常本身的便利性就全都白费了。
话说,大家千万别误会,检查型异常也是有优点的。只不过,从我个人来看,比起一个十分严格的,像对错误零容忍的老师一样的编译器来说,我还是更喜欢 Ruby 这样相对比较宽容的语言吧。
Icon 的异常和真假值
异常也有比较特别的用法,为此我们来介绍一种叫做 Icon 的语言。Icon 是由美国亚利桑那大学开发的,用于字符串模板匹配等处理的编程语言。它诞生于 1977 年,是一种非常古老的语言。
在 Icon 中,异常(在 Icon 中称为失败)是通过“假”来表示的。也就是说,当对表达式求值时,如果没有产生异常,则结果为真,反之则结果为假。因此,像:
if 表达式这样的条件判断,并不是 Ruby 等一般语言中“表达式结果为真时”的判断方式,而是“表达式求值成功时(没有产生异常)”的意思。也就是说,像:
a < b这样一个简单的表达式,在一般语言中它的判断方式为:将 a 和 b 进行比较,当 b 较大时为真,两者相等或 b 较小时为假。而在 Icon 中它的判断方式为:将 a 和 b 进行比较,两者相等或 b 较小时产生异常,否则返回 b 的值。因此,在 Icon 中:
a < b < c这样的表达式是比较正当的。对这个表达式进行求值时,由于 a < b 的比较结果为真时,表达式的求值结果为 b,则接下来会对 b < c 进行求值。如果最开始的比较结果为假,则整个表达式的求值就失败了,后面的比较操作实际上并没有被执行。这种方式真的非常独特。
在 Ruby 等以真假来求值的语言中,要得到相同的结果,必须要写成这样:
a < b && b < c说句题外话,在 Python 中其实也是可以写成:
a < b < c这样的,不过这并不是说 Python 具备像 Icon 这样的运行模块,而只是其语法分析器可以识别连续的比较运算符,最终还是要将表达式转换成:
a < b && b < c这样的形式。
在以异常为基础的 Icon 中,从文件中逐行读取内容并输出的程序写成下面这样:
while write(read())好像语序有点奇怪吗? Icon 中就是这样写的。
首先,read 函数从标准输入读取 1 行数据,当读取成功时则返回读取到的字符串。write 函数将通过参数得到的字符串写到标准输出。通过这样的方式,就完成了“读取一行内容并输出”的操作。
读懂这段程序的关键,在于将这个读取一行的操作作为 while 循环的条件判断来使用。Icon 的 while 语句的逻辑是“执行循环直到条件判断表达式失败”,因此,write(read()) 这个操作将被循环执行,直到失败为止,而在读取到文件末尾时,read 函数会失败,这个失败会被 while 语 句的条件判断捕获,从而结束循环。习惯了一般语言的人,可能会感觉很异样,因为这个 while 循环并没有循环体,却可以执行所需的操作,不过当你明白了其中的逻辑,也就觉得顺理成章了。
此外,在 Icon 中,还有一种叫做 every 的控制结构,它可以对所有的组合进行尝试,直到失败为止。Icon 的这种求值方式,由于包含了“继续求值到达到某种目标为止”的含义,因此被称为目标导向求值(Goal-directed evaluation)。例如:
every write((1 to 3) + (2 to 3))表示将 1 到 3 的数,和 2 到 3 的数,用不同的排列组合来输出它们的合,即 1+2、1+3、2+2、2+3、3+2、3+3,运行结果为:
3 4 4 5 5 6在一般语言中,这样的运算需要通过两层循环来完成。运用异常和目标导向求值,可以在无显式循环的情况下,对排列组合运算进行描述,这一点实在是很有意思。
综上所述,在 Icon 中,异常和真假值的组合非常强大,应用范围也很广,颇具魅力。在最初设计 Ruby 的时候,我也曾经认真思考过,到底要不要采用 Icon 这样的真假求值机制,结果却还是采用了用 nil 和 false 表示“假”,其余都表示“真”这样的正统方式。当时,如果做出另一种不同的判断的话,也许 Ruby 这个语言的性质就会发生很大的改变呢。
Eiffel 的 Design by Contract
从异常这个角度来看,还有一种很有意思的语言,叫做 Eiffel 3。Eiffiel 中强调了一种称为 Design by Contract(契约式设计,简称 DbC)的概念。即所有的方法(在 Eiffel 中称为子程序)都必须规定执行前需要满足的条件和执行后需要满足的条件,当条件不能满足时,就会产生异常。
3 Eiffel 是一种面向对象编程语言,诞生于 1986 年,设计者是 Bertrand Meyer(1950— )。
这样的思路,就是将对子程序的调用,看作是一种“只要兑现满足先验条件的约定,后验条件就必定得到满足”的契约。
Eiffel 的子程序定义代码如图 10 所示。Eiffel 中异常没有类型的区别,这也是强调 DbC 设计方针的结果,和其他的语言有所不同。
-- Eiffel中“--”开头的是注释 -- 方法定义 command is require -- 先验条件 local -- 局部变量声明 do -- 子程序正文 ensure -- 后验条件 rescue -- 异常处理 -- 通过retry返回do重新执行 end图 10 Eiffel 的方法定义
大家应该可以看出,Eiffel 的异常处理中所使用的保留字(ensure、rescue、retry),在 Ruby 中得到了继承。具体的含义可能有所不同,但 Ruby 开发早期确实参考了 Eiffel 中的保留字。
异常与错误值
像 C 语言这样完全不支持错误处理的语言中,异常状况只能通过错误值来表示。那么,在具备异常功能的语言中,是不是所有的错误都可以通过异常来表示呢?
以我的一己之见,大部分情况下都应该使用异常,不过也有一些情况下用错误值更好。例如,在 Ruby 中也有一些情况是需要用错误值的。
对 Hash 的访问算是一个例子。在 Ruby 中,访问 Hash 时如果 key 不存在的话,并不会产生异常,而是会返回 nil(在 Python 中则会产生异常)。
hash[key] # => 不存在时返回nil也就是说,这要看对访问 Hash 时 key 不存在这一情况到底能做出何种程度的预计。如果 key 不存在的情况完全是超出预计的,错误就应该作为异常来处理;反之,如果 key 不存在的情况在某种程度上是预计范围内的,那么就应该返回错误值。
不过,在某些情况下,我们希望将 key 不存在的情况作为错误来产生异常,并且保证要将其捕获。在这种情况下,可以使用 Hash 类中的 fetch 方法,用这个方法的话,当 key 不存在时就会产生异常。
小结
对于程序员来说,错误处理虽然不希望发生,但也不能忽视,是个很麻烦的事情。异常处理功能就是为了将程序员进行错误处理的负担尽量减轻而产生的一种机制。21 世纪的编程语言中,绝大部分都具备了异常处理功能,我想这也是编程语言实现了进化的一个证据吧。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论