关于Java Checked Exception机制的思考

读王垠《Kotlin和Checked Exception》引发的思考

Posted by caotc on March 10, 2018

一、程序设计语言中的异常机制

一般来说程序设计语言的异常机制主要分为两种,一种是以C语言为代表机制的错误码,一种是异常对象机制(多返回值语言可用多返回值模拟异常对象机制,可视作相同机制).

1.错误码机制

(一)含义

错误码机制主要以C语言为代表.

错误码机制就是指对于一个函数执行时发生的错误情况,使用函数本身的返回值返回事先约定好的特殊值,以此通知函数的调用者发生了异常的情况.

事实上,不必要看C语言,Java中某些函数也遗留了C语言风格的错误码机制.

例如Java中String类的indexOf函数的api doc中有这样一句:

If no such value of k exists, then {@code -1} is returned.

即如果没有查找到要找的值,那么返回值-1代表没有找到.

这其实就可以理解为是错误码机制,-1就是错误码,代码的含义就是没有找到.

(二)优点

(1)开销小,错误情况和正常情况下开销相同.而使用异常机制时异常通常是个对象,里面还记录了堆栈信息等,开销较大.

(2)便于不同语言模块之间的跨界传播.

(三)缺点

(1)可携带信息量少. 一个单纯的错误码能说明的信息较少,通常只能说明错误类型.比如Java中的ArrayIndexOutOfBoundsException异常可以告知调用者,发生越界异常的是哪个index值.而错误码机制是不能做到的.更不用提堆栈信息了.

错误码范围受函数本身正常返回值范围限制.

以上面提到的indexOf函数为例,这个函数的功能决定了其正常返回值为[0,Integer.MAX_VALUE],那么错误码只能在[Integer.MIN_VALUE,-1]之间选择.

(2)错误码范围受限导致某些时候没有足够的返回值错误码表示需要的情况. 例如一个除法函数的任何返回值都是可能的正常返回值,以至于根本没有错误码可用.

(3)错误码范围受限导致几乎不能在对类似的错误统一规定错误码.在异常机制中,一般以异常类型表示异常原因,一个异常类型可以在全局通用. 这导致程序员几乎不可能熟悉返回的错误码代表的含义,基本只能每次都去查文档或者手册.

(4)将错误传播到上层需要重新封装一套错误码并手动转换作为传播机制.

(5)错误码机制要求程序员在调用一个函数时非常了解该函数的错误码,才能正确处理可能出现的异常情况.通常情况下,很难保证文档及时更新并且没有遗漏,因为这是一个巨大的工作.

(6)没有语言级别机制的支持,错误码很容易被无意忽略. 即使API文档非常规范和更新及时,但是你无法保证函数调用者查阅了文档并且处理了应该处理的错误码.

(7)错误处理逻辑与正常程序逻辑混杂在一起,不易理解.

2.异常机制

(一)含义

维基百科是这样描述程序设计语言的异常机制的:

多数语言的异常机制的语法是类似的:用throw或raise抛出一个异常对象(Java或C++等)或一个特殊可扩展的枚举类型的值(如Ada语言);异常处理代码的作用范围用标记子句(try或begin开始的语言作用域)标示其起始,以第一个异常处理子句(catch, except, rescue等)标示其结束;可连续出现若干个异常处理子句,每个处理特定类型的异常。某些语言允许else子句,用于无异常出现的情况。更多见的是finally, ensure子句,无论是否出现异常它都将执行,用于释放异常处理所需的一些资源。

正如上面维基百科所说,在Java中,异常通过try-catch语句捕获.

关键词try后的一对大括号将一块可能发生异常的代码包起来,称为监控区域。Java方法在运行过程中出现异常,则创建异常对象。将异常抛出监控区域之 外,由Java运行时系统试图寻找匹配的catch子句以捕获异常。若有匹配的catch子句,则运行其异常处理代码,try-catch语句结束。

匹配的原则是:如果抛出的异常对象属于catch子句的异常类,或者属于该异常类的子类,则认为生成的异常对象与catch块捕获的异常类型相匹配。

try-catch语句还可以包括第三部分,就是finally子句。它表示无论是否出现异常,都应当执行的内容。

(二)优点

(1)可获得详细完整的信息. 异常对象内能封装包括堆栈信息在内的一切想要传达的异常信息.

(2)函数异常范围设计自由,可全局通用.

(3)无需额外的传播机制.

(4)可从语言层面进行对异常处理情况的分析和约束,如Java中就存在Checked Exception这样编译器约束强制处理的异常.

(5)异常处理逻辑和正常逻辑比错误码要分明.

(三)缺点

(1)由于通常要记录堆栈信息,异常对象生成的开销要比普通对象要大.

(2)跨模块边界传播没有错误码方便.

3.结论

从上面错误码机制和异常机制的优缺点相比较,我认为,除了少部分极端注重性能的环境以及跨边界的传播情况以外,语言内部使用时异常机制明显比错误码机制更优.

二、Java中的Checked Exception

1.含义

Checked Exception机制是异常机制中较特殊的部分,在程序设计语言中也比较少见,也是本文要探讨的主要内容.

Java异常类关系图 上面是Java的异常类关系图,除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。

Checked Exception的特殊性在于,一旦一个函数抛出了Checked Exception,那么就会永久性地为所有调用者甚至调用者的所有上级增加负担.

2.理想使用方式

《Kotlin和Checked Exception》 王垠一文中,王垠大力称赞了Checked Exception机制的优点和重要性.

王垠以FileNotFoundException异常为例,认为Checked Exception可以帮助调用者更正确的进行出错处理.

这听起来似乎挺美好的.

3.实际应用情况

但是在实际应用中,却并不那么美好,实际应用中的Checked Exception,通常见不到FileNotFoundException这样具体的异常,而总是IOException这样模糊的异常.

并且调用函数除了直接将异常加入自己签名或者包装成运行时异常以外,真正的处理Checked Exception的代码通常是下面这样的:

try
{
    doSomeThing();
} 
catch (IOException e)
{
   log.error(e);
}

然而这正是王垠所说的最糟糕的异常处理代码.因为不知道函数里面会有什么异常出现,所以你的catch 语句里面也不知道该做什么。大部分人只能在里面放一条 log,记录异常的发生。

很显然,实际应用情况与王垠说的理想情况差的很远.

在几乎所有的 Java 框架和库中,包括 Java 标准库,Checked Exception 都从未以王垠理想的方式使用过。

4.理想使用方式失败原因

(1)Checked Exception最大的缺点(或者说是特点)就是破坏兼容性,成为函数兼容性检测的一部分,这显然与Java追求兼容性的目标背道而驰. 这使得实际应用中很少使用这种详细类型的受检异常.

以上面说的FileNotFoundException为例,一个操作文件的函数,原来只定义了FileNotFoundException这个Checked Exception,然后函数作者发现其实还需要增加权限不足异常.

然而如果函数作者加入了新的权限不足Checked Exception,那么就破坏了函数的兼容性,所有的调用代码都无法通过编译.

这使得函数的设计者必须初次设计的就预见了所有需要的Checked Exception,这与接口设计类似,近乎不可能的任务.

(2)处理详细类型的Checked Exception违背了面向接口编程的原则.

为了避免上面提到的破坏兼容性的问题,函数和库的设计者通常选择将抛出的Checked Exception类型设计为包括所有可能异常类型的高级超类异常,例如IOException.

同时,异常的详细类型实际上是与实现相关,而不是与接口相关的.

以InputStream接口的read函数为例:

1、如果是基于数组的实现,那么只会有超出容量的异常;

2、如果是基于磁盘文件的,可能文件突然被删、磁盘坏了、磁盘被unmount;

3、如果是远程文件还可能有超时、通信中断;

4、如果嵌套了ZipInputStream,可能会有解码错误。

5、。。。。。(无数种实现、无数种可能)

所以即使保证底层函数的Checked Exception设计的正确性,接口函数定义中Checked Exception也只能是IOException这样的父类.

而多态作为OOP语言最基础的特性,面向接口编程的原则意味着函数调用者通常只能拿到IOException这样无法得知具体异常原因的高级异常.

那么写出来的代码自然就如同王垠所说不知道该做什么,只能在里面放一条 log,记录异常的发生.

而如果去catch详细的子类异常,那么意味着对接口的实际类型做出了假设,这是完全违背面向接口编程原则的.

详细类型的Checked Exception与面向接口编程的原则矛盾的还不止是上面说的异常的详细级别问题.连接口的方法异常该定义为Checked Exception还是Runtime Exception都很难决定.

依旧以上面的InputStream接口的read函数为例:

如果把read抛出的异常设计为Checked Exception,那么在基于数组的实现时,明明根本不可能有异常,却还强迫调用者去catch异常,而且此时通常catch中只能什么都不做,这很显然是很糟糕的.

如果把read抛出的异常设计为RuntimeException,那么在基于需要抛出Checked Exception的实现时,由于接口签名的兼容性问题,没法在签名中声明Checked Exception,只能包装成RuntimeException在抛出去.

如果这种实现抛出Checked Exception是合理的,那么这种情况显然也不太好.

5.如何修正实际应用情况中只起到反效果的Checked Exception

Checked Exception糟糕的实际应用情况,使得把Checked Exception不分青红皂白包装成 RuntimeException 的“设计模式”,成为Java 程序员中公开的秘密.

这使得以100%兼容Java为目标的Kotlin中取消了Checked Exception机制.

接下来我们来分析到底是Checked Exception机制本身就没有必要,还是大家对Checked Exception使用方式错误?如何修正?

以下为个人分析观点:

(1)可避免(预测)异常不应该强迫程序员处理

异常按照是否可避免可分为可避免异常和不可避免异常. 可避免异常是指函数调用者可以提前避免的异常,Java中所有的RuntimeException都是可避免的异常.

NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException等所有运行时异常都是可避免异常.

不可避免异常指的是无法通过函数调用者正确使用就能避免的异常.比如网络连接失败异常.

无论函数调用者多么正确的操作,也无法避免自己控制和感知范围以外的异常,比如网络连接时对方的服务器挂了,比如网线断了等.

凡是可避免异常都不应该强迫程序员处理.

想象一下,假如不遵守这条规则地把NullPointerException设计为Checked Exception,那么你该怎么写代码?

你根本没法正常的写代码,因为每句代码都要求你处理这个异常,你只能把所有代码都用try块包括起来.

《Effective Java》中文版 第2版 Joshua Bloch(美)中的第59条避免不必要地使用受检的异常中提出:

“把受检的异常变成未受检的异常”的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个boolean,表明是否应该抛出异常.

这个建议与上面说的”凡是可避免异常都不应该强迫程序员处理”非常吻合.

以此原则来说,FileNotFoundException就不应被设计为Checked Exception而应该是Runtime Exception.

调用者应该在调用前就使用exists方法来保证传入的文件是存在的,这是正确使用函数的一种前提约定,本质是一种IllegalArgumentException,自然应该成为其子类.

当然,如果这个返回boolean的判断状态的方法,本身就需要做一次正式调用函数做的操作才能做到判断状态,那么意味着这就是无法被避免的异常.

例如上面提到的String类的indexOf函数,考虑到忽略错误码机制容易被忽略的特点,其实应当把无法匹配下标的情况设计为异常.

由于其判断可用性的函数contains本身内部就是用indexOf函数实现的,出于性能考虑,无法设计成可用性判断+实际操作函数+运行时异常的模式,这个异常应该被设计为受检异常,来强制提醒调用者注意匹配不到的情况.

考虑到异常的语义较重,破坏性较大,且该函数只有这一种异常(或无需知道具体失败原因时)的情况下,使用Java8的Optional设计来作为返回值也是一种不错的设计.

(2)无法避免且可恢复的异常使用Checked Exception

《Effective Java》中文版 第2版 Joshua Bloch(美)中的第58条对可恢复的情况使用受检异常,对编程错误使用运行时异常中提出:

如果期望调用者能够适当的恢复,对于这种情况就应该使用受检的异常

我比较赞同这句话,但是前提是异常是无法避免的异常.

可避免的异常,即使可以恢复,也不应该是Checked Exception.

所以我并不同意这条中提出的例子:

假设用户没有储存足够数量的钱,他企图在一个收费电话上进行呼叫就会失败,于是抛出受检的异常.这个异常应该提供一个询问方法,以便允许客户查询所缺的费用金额,从而可以将这个数值传递给电话用户.

对于上面的例子而言,假设以这种逻辑设计Checked Exception,那么这个方法的Checked Exception实在太多了.

可恢复但是可避免的异常情况有,用户冻结异常,用户未实名认证异常,用户呼叫对象不存在异常,用户呼叫对象冻结异常,用户呼叫对象停机异常等等.

你看这样一个方法将会有如此之多的异常,而这些异常明明都可以通过正确的调用来避免.

对于这些可以避免的异常情况,程序完全可能设计为通过检测发现问题所在后,提前引导客户去解决问题.

此时调用函数时已经保证了不会出现这些可避免的问题,然而却还是要被强迫要求处理,这显然很奇怪.

因此Checked Exception只适合类似网络连接中断这种无法避免且可恢复的异常.

(3)接口异常以接口含义而非具体实现为准

对于接口函数的异常定义问题,由于异常的详细类型实际上是与实现相关,而不是与接口相关的,无法通过上面两条原则来判定.

在这种问题上,我倾向的处理方式是将IOException这样的异常设计为Runtime Exception.

这可能与官方的想法相同,毕竟Java8中加入了UncheckedIOException类.

我认为如果把接口的无数种具体实现中的Checked Exception可能性考虑进去,因此把接口方法定义上Checked Exception,那么你会发现可以说任何接口函数都该定义Checked Exception,这样的做法显然很荒谬.

想象一下,Java8中加入的几个函数接口,其内部实现包括任何可能,如果考虑到Checked Exception可能性,在签名中加入了Exception的抛出.

那么你每次使用都只能先try再说了,然后这种Exception又会蔓延开来.

至于确实存在无法避免且可恢复的异常的实现,其函数签名与接口函数签名不兼容的问题.

该实现类中额外定义一个同含义,但是会抛出受检异常的函数,供调用时知道具体类型的调用者调用可能会是一个解决办法.

除非这个接口方法本身就是代表所有实现都可能抛出无法避免且可恢复的异常的,比如网络接口.

6.总结

Checked Exception有存在的意义,但是非常容易被滥用.JDK中的绝大部分情况就属于滥用Checked Exception机制.

应当坚持可避免(预测)异常不应该强迫程序员处理、无法避免且可恢复的异常使用Checked Exception、接口异常以接口含义而非具体实现为准三个原则,谨慎地使用Checked Exception.

参考列表

《Effective Java》中文版 第2版 Joshua Bloch(美)

异常处理

《Kotlin和Checked Exception》 王垠