Effective java笔记6--异常

时间:2022-01-03 09:04:06

充分发挥异常的优点,可以提高一个程序的可读性、可靠性和可维护性。如果使用不当的话,它们也会带来负面影响。

一、只针对不正常的条件才使用异常

先看一段代码:

//Horrible abuse of exceptions. Don't ever do this!
try{
int i = 0;
while(true)
a[i++].f();
}catch(ArrayIndexOutOfBoundException e){
}

通过用抛出(throw)、捕获(catch)、忽略ArrayIndexOutOfBoundException的手段来达到终止无限循环的目的。如下面的标准模式是等价的:

for(int i =0;i<a.length;i++)
a[i].f();

企图利用Java的错误判断机制来提供性能,因为VM对每次数组访问都要检查越界情况,所以他们认为循环终止测试(i<a.length)是多余的,应该被避免。这种想法有三个错误:

一,因为异常机制的设计初衷使用于不正常的情形,所以很少会有JVM实现试图对它们进行优化,使得与显式的测试一样快速。
二,把代码放在try-cathch块中反而阻止了现代JVM实现本来可能要执行的某些特定优化。
三,对数组进行遍历的标准模式并不会导致冗余的检查,有些现代的JVM实现会将它们优化掉。
实际上,在现代的JVM实现上,基于异常的模式比标准模式要慢得多。

这条原则对于API设计也有启发,设计良好的API不应该被强迫他的客户端为了正常的控制流而使用异常。如果类具有“状态相关”的方法,即只有在特定的不可预知的条件下才可以被调用的方法,这个类往往也应该有个单独的“状态测试”方法,即指示是否可以调用这个状态相关的方法。例如,Iterable接口有一个状态相关的next方法,和相应的状态测试方法hasNext方法。这使得利用传统for循环对集合进行迭代的标准模式成为可能:

另一种提供单独的状态测试方法的做法是,如果“状态相关的”方法被调用时,该对象处于不适当的状态之中,他就会返回一个可识别的值,比如null。这种方法对于Iterator而言并不适合,因为null是next方法的合法返回值。

对于“状态测试方法”和“可识别的返回值”这两种做法,有些知道原则可以帮助你在两者之中做出选择。如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,使用可被识别的返回值可能很有必要的,因为在调用“状态测试”方法必须重复“状态相关”方法的工作,从性能的角度考虑,就应该使用可识别的返回值。如果其他所有方面都等同的,那么“状态测试”方法则略优于可被识别的返回值。他提供了更好的可读性,对于使用不当的情形,可能更加易于检测和改正:如果忘了去调用状态测试方法,状态相关的方法就会抛出异常,使这个bug变的很明显;如果忘了去检查可识别的返回值,这个bug就很难被发现。

总而言之,异常是为了在异常情况下使用而设计的,不要将他们用于普通的控制流,也不要编写迫使他们这么做的API。

二、对于可恢复的条件使用被检查的异常,对于程序错误使用运行时异常

Java一共有三种可抛出的结构(trhowable):被检查的异常(checkedException),运行时异常(runtimeException),错误(error)。我们最常见的是前两者,但是什么时候该使用哪种异常,可能是一件非常让人头疼的事情。为此,搜索“checkedException runtimeException”关键字,可参考http://www.cnblogs.com/duanxz/p/3426025.html

其实,什么时候该用哪一个,在本条的题目中就已经做出了非常精辟的回答。如果希望使用者可以恢复,继续执行程序,应该使用checkedException,通过抛出一个被检查的异常,你强迫调用者在一个catch子句中处理该异常,或者将它传播到外面。而如果是程序错误,遇到这个错误后顶多是写个日志、报告个MessageBox,就只能停止程序,就应该使用runtimeException。

用运行时异常来指明程序错误。大多数的运行时异常都是表面前提违例(precondition violation)。所谓前提违例是指API的客户没有遵守API规范建立得约定。例如,关于数组访问的约定指明了数组的下标值必须在零和数组长度减1之间。ArrayIndexOutOfBoundsException表明这个前提被违反了。

虽然JLS(Java语言规范)并没有要求,但是按照惯例,错误往往被JVM保留用于指示资源不足、约束失败,或者其他使程序无法继续执行的条件。由于这已经是一个几乎被普遍接受的惯例,所以最好不要在实现任何新的Error子类。你所实现的所有的未被检查的抛出结构都应该是RuntimeException的子类(直接或间接的)。

这个“特征”就是,是否在调用方法时必须俘获它可能抛出的异常。对于checkedException是必须的,而对于runtimeException是不强求的。同样,在方法的描述中对可能抛出的checkedException要求用throws关键字予以明示,而runtimeException则不需要。但,请切记,这两点区别,只是两者表现出来的不同特征。而绝不是以要不要写某些代码来决定使用哪种异常的。

说到Java对Exceptin的设计,在这一点上至少 .Net 的设计团队就有着不少的不同看法。Java对checkedException的要求(必须在方法声明中明示,调用方必须try-catch)在带来好处(给所有的可检查的异常以规划良好的处理、思路清晰方便调试、在没有文档的情况下也可以很好的了解方法可能抛出的异常 等等)的同时,也带来了弊端(写法比较复杂、try-catch块使缩进加深、默认情况下程序中必须处理掉所有的checkedException 等等)。于是 .Net 的设计团队“另辟蹊径”(之所以加引号,是因为它们的本质还是一样的),创造了现在我们在.Net中看到的Exception,它是“随心所欲”的,尤其是对于初学者,它的简单,甚至可以不必在意的特点也广收欢迎。或许可以理解为“.Net中的Exception都是Java中的RuntimeException”(我不知道我这种理解或是比喻是否有问题),总之.Net是以放低对严谨性、调试便利性的要求,而换来简单易用的优势。但是,还有一点就是在没有文档的情况下无法获知你将要调用的方法都会抛出哪些异常。虽然MSDN做的很好,但是,我们自己写的API呢?只顾代码,不顾文档的“懒人”们可要注意了。

二、避免不必要地使用被检查的异常

被检查的异常是Java程序设计语言的一个很好的特性。与返回代码不同,它们强迫程序员处理例外的条件,大大提高了可靠性。然而,过分使用被检查的异常会是API用起来非常不方便。如果一个方法会抛出一个或者多个被检查的异常,那么调用该方法的代码必须在一个或者多个catch块中处理这些异常,或者它必须声明这些异常,以便让它们传播出去。无论哪种方法,都给程序员增添了不可忽视的负担。

如果正确地使用API并不能阻止这种异常的产生,并且一旦产生了异常,使用API的程序员可以采取有用的动作,那么这种负担被认为是正当的。

"把被检查的异常变成未被检查的异常"的一种技术是,把这个要抛出异常的方法分成两个方法,其中第一个方法返回一个boolean,表明是否应该抛出异常。这是一种API转换,它实际是调了调用顺序:

//Invocation with checked exception
try{
obj.action(args);
}catch(TheCheckedException e){
//Handle exceptional condition
...
}
转换为:
//invocation with state-testing method and unchecked exception
if(obj.actionPermitted(agrs)){
obj.action(args);
} else {
//Handle exceptional condition
...
}

这种转换并不总是合适的,但是,凡是在合适的地方,它都会使用API用起来更加舒服,虽然后者的调用序列没有前者漂亮,但是这样得到的API更加灵活。如果不介意由于调用失败导致的线程终止,那么嗨可以是下面更为简单的调用形式:

obj.action(args);

三、尽量使用标准的异常
    重用现有的异常有多方面的好处:第一,使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。第二,对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥桌程序员不熟悉的异常。第三,异常类越少,意味着内存占用(footprint)越小,并且装载这些类的时间开销也越小。

常用的有:

IllegalArgumentException,当调用者传递的参数值不合适的时候,这个异常就会被抛出来。
IllegalStateException,若给定了接收对象的状态,如果调用非法的话,则通常会抛出这个异常。如,一个对象在被正确地初始化之前,调用者企图使用这个对象,那么这个异常就会被抛出来。

可以这么说,所有的错误的方法调用都可以被归结为非法参数或者非法状态,但是,其他还有一些标准异常也被用于某些特定情况下的非法参数和非法状态。如NullPointerException、IndexOutOfBoundException等。

另一个通用的异常是ConcurrentModifiedException。

UnsupportedOperationException,如果一个对象并不支持所请求的方法,那么这个异常将会被抛出。

ArithmeticException和NumberFormatException有关计算的异常。

四、抛出的异常要适合相应的抽象

一个方法抛出的异常与它所执行的任务没有明显的关联关系,这种情况会使人不知所措,当一个方法传递一个由低层抽象抛出的异常时,往往会发生这样的情况。为了避免这个问题,高层的实现应该捕获低层的异常,同时抛出一个可以按照高层抽象进行解释的异常。这种做法被称为异常转译(Exception translaction),通过转译的方法将异常从低层的传播到高层。如下面的例子:

//Exception Translation
try{
//Use lower-level abstraction to do our bidding
}catch(LowerLevelException e){
throw new HigherLevelException(...);
}

一种特殊形式的异常转译被称为异常链接(exception chaining),如果低层的异常对于调试该异常被抛出的情形非常有帮助,那么使用异常链接是很适合的。在这种方法中,低层的异常被高层的异常保存起来,并且高层的异常提供一个公有的方法方法来获得低层的异常:

//Exception Chaining
try{
//Use lower-level abstraction to do our bidding
...
} catch(LowerLevelException e){
throw new HigherLevelException(e);
}

从1.4之后,异常链接可通过Throwable来支持,做法很简单,只要让你的高层异常的构造函数链接到Throwable(Throwable)即可:

//Exception chaining in release 1.4
HigherLevelException(Throwable t){
super(t);
}

尽管异常转译比不加选择地传递低层异常的做法有所改进,但是它也不能被滥用。如果可能的话,处理来自低层异常的最好做法是,在调用低层方法之前确保他们会成功执行,从而避免他们会抛出异常。如果无法阻止低层的异常,那么,其次的做法是,让高层来处理这些异常,从而将高层方法的调用者与低层的问题隔离开,在这种情况下,用某种适当的记录设施将低层的异常记录下来可能是比较合适的。

五、每个方法抛出的异常都要有文档

描述一个方法所抛出的异常,是正确使用这个方法所需文档的重要组成部分,因此,花点时间仔细地为每个方法抛出的异常做文档时特别重要的。

总是要单独地声明被检查的异常,并且利用Javadoc的@throws标记,准确地记录下每个异常被抛出的条件。

六、在细节消息中包含失败-捕获信息

为了捕获失败,一个异常的字符串表示应该包含所有“对该异常有贡献”的参数和域的值。为了确保在异常的字符串表示中包含足够的失败-捕获信息(failure-capture information),一种办法是在异常的构造函数中以参数形式引入这些信息。

七、努力使失败保持原子性

当一个对象抛出一个异常之后,我们总是期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败发生在执行某个操作的过程中间。一般而言,一个失败的方法调用应该使对象保持“它在被调用之前的状态”。具有这种属性的方法被称为具有失败原子性(failure atomic)。

有几种途径可以获得这种效果。最简单的方法莫过于设计一个非可变的对象,如果一个对象是非可变的,那么失败原子性是显然的。一种类似的获得失败原子性的办法是,对计算处理过程调整顺序,使得任何可能会失败的计算部分都发生在对象状态被修改之前。第三种获得失败原子性的办法没有那么常用,做法是编写一段恢复代码(recovery code),由它来解释操作过程中发生的失败,已经使对象回滚到操作开始之前的状态上。第四,在对象的一份临时拷贝上执行操作,当操作完成之后再把临时拷贝中的结果复制到原来的对象。例如,Collections.sort在执行排序之前首先把它的输入列表转储到一个数组中,以便降低在排序的内循环中访问元素所需要的开销。这是出于性能原因的做法,但是,作为一个附加的好处,它保证了即使排序失败,输入列表将会保持原样。

八、不要忽略异常

尽管这条建议看上去不是显而易见的,但是它却是常常被违反。要忽略一个异常非常容易,只需要将方法调用用一个try语句包围起来,并且包含一个空的catch块,如下所示:

//Empty catch block ignores exception -Highly suspect!
try{
...
}catch(SomeException e){
}

空的catch块会使异常达不到应有的目的,异常的目的是强迫你处理不正常的条件。忽略一个异常就如同忽略一个火警信号一样--若把火警信号器关掉了,那么当真正的火灾发生时,就没有人能看到火警信号了。或许可以侥幸逃过,但至少catch块也应该包含一条说明,用来解释为什么忽略掉这个异常是合适的。