第九章 异常
57、 只针对异常的情况才使用异常
也许你在将来会碰到下面这样的代码,它是基本异常模式的循环:
try{
int i = 0;
while(true)
range[i++].climb();
}catch(ArrayIndexOutOfBoundsException e){
}
这所以有些人会这么做,是因为他们企图利用Java的错误判断机制来提高性能,因为VM对每次数组访问都要检查越界情况,即使是使用for-each,他们认为正常的循环终止测试只是被编译器隐藏了,但在循环中仍然可见,这种考虑无疑是多余的。
实例上,在现代的JVM实例上,基本异常的模式比标准模式要慢得多。
异常应该只用于异常的情况下,它们永远不应该用于正常的控制流。
设计良好的API不应该强迫它的客户端为了正常的控制流程而使用异常。如Iterator接口有一个“状态相关”的next方法,和相应的状态测试方法hasNext,这使得利用传统的for循环对集合进行迭代的标准模式成为可能:
for(Iterator<Foo> i = coolection.iterator(); i.hasNext()){
Foo foo = i.next();
…
}
如果Iterator缺少hasNext方法,客户端将被迫改用下面的做法:
try{
Iterator<Foo> i = collection.iterator();
while(true){
Foo foo = i.next();
…
}
}catch(NoSuchElementException e){
}
另一种提供单独的状态测试方法的做法是,如果“状态相关的”方法被调用时,该对象处于不适当的状态之中时,它就会返回一个可识别的值,比如null。这种方法对于Iterator而言不合适,因为null是next就去的合法返回值。
对于“状态测试方法”和“可识别的返回值”这两种做法,有些告诫:如果对象在缺少外部同步的情况下被并发访问,或者可被外界改变状态,使用可被识别的返回值可能是很有必要的,因为在调用“状态测试”方法和调用对应的“状态相关”方法的时间间隔之中,对象的状态有可能会发生变化。但从性能角度考虑,使用可被识别的返回值要好些。如果所有其他方面都是等同的,那么“状态测试”方法则略优先可被识别的返回值。
总之,异常是为了在异常情况下使用而设计的,不要将它们用于普通的控制流,也不要缩写迫使它们这么做的API。
>>>《Practice Java》异常拾遗<<<
如果finally块中出现了异常没有捕获或者是捕获后重新抛出,则会覆盖掉try或catch里抛出的异常,最终抛出的异常是finally块中产生的异常,而不是try或catch块里的异常,最后会丢失最原始的异常。
如果在try、catch、finally块中都抛出了异常,只是只有一个异常可被传播到外界。记住,最后被抛出的异常是唯一被调用端接受到的异常,其他异常都被掩盖而后丢失掉了。如果调用端需要知道造成失几的初始原因,程序之中就绝不能掩盖任何异常。
请不要在try块中发出对return、break或continue的调用,万一无法避免,一定要确保finally的存在不会改变函数的返回值(比如说抛异常啊、return啊以及其他任何引起程序退出的调用)。因为那样会引起流程混乱或返回值不确定,如果有返回值最好在try与finally外返回。
不要将try/catch放在循环内,那样会减慢代码的执行速度。
如果构造器调用的代码需要抛出异常,就不要在构造器处理它,而是直接在构造器声明上throws出来,这样更简洁与安全。因为如果在构造器里处理异常或将产生异常的代码放在构造器之外调用,都将会需要调用额外的方法来判断构造的对象是否有效,这样可能忘记调用这些额外的检查而不安全。
58、 对可恢复的情况使用受检异常,对编程错误使用运用时异常
Java程序设计语言提供了三种异常:受检的异常(checked exception)、运行时异常(run-time exception)和错误(error)。关于什么时候适合使用哪种异常,虽然没有明确的规定,但还是有些一般性的原则的。
检测性异常通常是由外部条件不满足而引起的,只要条件满足,程序是可以正常运行的,即可在不修改程序的前提下就可正常运行;而运行时异常则是由于系统内部或编程时人为的疏忽而引起的,这种异常一定要修正错误代码后再能正确运行。受检异常对客户是有用的,而运行时异常则是让开发人员来调试的,对客户没有多大的用处。
在决定使用受检异常还是未受检异常时,主要的原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检的异常。抛出的受检异常都是对API用户的一种潜在的指示:与异常相关的条件是调用这个方法的一种可能的结果。
有两种未受检的异常:运行时异常和错误。在行为上两种是等同:它们都不需要捕获。如果抛出的是未受检异常或错误,往往就属于不可恢复的情形,继续执行下去有害无益。如果程序未捕获这样的异常或错误,将会导致线程停止,并出现适当的错误消息。
用运行时异常来表明编程错误。大多数的运行时异常都表示违返了API规约,API的客户同有遵守API规范。例如,数组访问的约定指明了数组的下标值必须在零和数组长度减1之间,ArrayIndexOutOfBoundsException表明了这个规定。
按照惯例,错误往往被JVM保留用于表示资源不足、约束失败,或者其他程序无法继续执行的条件。由于这已经是个几乎被普遍接受的惯例,因此最好不要再实现任何新的Error子类。因此,你实现的所有未受检异常都应该是RuntimeException的子类或间接是的。
总而言这,对于可恢复的情况,使用受检的异常;对于程序错误,则使用运行时异常。当然,这也不总是这么分明的。例如,考虑资源枯竭的情形,这可能是由于程序错误而引起的,比如分配了一块不合理的过大的数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大所造成的,这种情况可能就是可恢复的。API设计者需要判断这样的资源枯竭是否允许。如果你相信可允许恢复,就使用受检异常,否则使用运行时异常。如果不清楚,最好使用未受检异常(原因请见第59条)。
API的设计都往往会忘记,异常也是个完全意义上的对象,可以在它上面定义任意的方法,这些方法的主要用途是为捕获异常的代码而提供额外的信息,特别是关于引发这个异常条件的信息。如果没有这样的方法,API用户必须要懂得如何解析“该异常的字符串表示法”,以便获得这些额外的信息,这是极不好的作法。类很少以文档的形式指定它们的字符串表示的细节,因此,不同的实现,不同的版本,字符串表示可能会大相径庭,因此,“解析异常的字符串表示法”的代码可能是不可移植的,也是非常脆弱的。
因为受检异常往往指明了可恢复的条件,所以,这于这样的异常,提供一些辅助方法尤其重要,通过这些方法,调用都可以获得一些有助于恢复的信息。例如,假设因为没有足够的钱,他企图在一个收费电话上呼叫就会失败,于是抛出检查异常。这个异常应该提供一个访问方法,以便用户所缺的引用金额,从而可以将这个数组传递给电话用户。
59、 避免不必要地使用受检异常
受检异常与运行时异常不一样,它们强迫程序员处理异常的条件,大大增强了可靠性,但过分使用受检异常会使用API使用起来非常不方便。如果方法抛出一个或者多个受检异常,调用都就必须在一个或多个catch块中处理,或者将它们抛出并传播出去。无论是哪种,都会给程序员添加不可忽视的负担。当然,如果程序员能处理这一类异常,则不算做负担,但以下决对是负担:
}catch(TheCheckedException e){
throw new AssertionError();// 断言不会发生异常
}
或
}catch(TheCheckedException e){// 哦,失败了,不能恢复
e.printStackTrace();
System.exit(1);
}
上面两种方式都不是最好的处理方式,之所以程序员这样处理,是因为他们认为这根本就是一种不可恢复的异常(当然程序员这种认为要是正当的),如果使用API的程序员无法做得比这更好,那么未受检异常可能更为合适,所以异常设计者应将TheCheckedException设计成运行时异常会更好些。这种例子的反例就是CloneNotSupportedException,它是Object.clone抛出来的,而Object.clone应该只是在实现了Cloneable的对象上者可以被调用,这显然是API调用都未实现该接口所导致,除非程序实现该接口,否是不可恢复的,所以CloneNotSupportedException应该设计成运行时异常或许更加合理一些。
如果方法只抛出单个受检异常,也会导致该方法不得在try块中,在这种情况下,应该问自己,是否有别的途径来避免API调用者使用受检的异常。这里提供这样的参考,我们可以把抛出的单个异常的方法分成两个方法,其中一个方法返回一个boolean,表明是否该抛出异常。这种API重构,把下面的调用:
try{//调用时检查异常
obj.action(args);//调用检查异常方法
}catch(TheCheckedExcption e){
// 处理异常条件
...
}
重构为:
if(obj.actionPermitted(args)){//使用状态测试方法消除catch
obj.action(args);
}else{
// 处理异常条件
...
}
这种重构并不总是合适的,但在合适的地方,它会使用API用起来更加舒服。虽然没有前者漂亮,但更加灵活——如果程序员知道调用肯定会成功,或不介意由调用失败而导致的线程终止,则下面为理为简单的调用形式:
obj.action(args);
如果你怀疑这个简单的调用是否成功,那么这个API重构则可能就是恰当的。这种重构之后的API在本质上等同于第57条件的“状态测试方法”,并且,同样的告诫也要遵循(告诫参考57)。
60、 优先使用标准异常
java平台提供了一组基本未受检查的异常,它们满足了绝大多数API的异常抛出的需要。
重用现有的异常好处最主要的是,易学和易使用,与已经熟悉的习惯用法一致。第二可读性更好,不会出现不熟悉的异常。
最经常被重用的异常是IllegalArgumentException。当调用者传递的参数值不合适的时候,往往就会抛出这个异常。如需要一个正数而传递的是一个负数时。
另一个经常被重用的异常是IllegalStateException。如果因为对象的状态而使用调用非法,则会抛出。如某个对象被正确初始化之前就调用。
可以这么说,所有错误的方法调用都可以被归结为非法参数或者非法状态,但是,还有更具体的异常用于非法参数和非法状态。例如在参数不允许为null或传递数组索引越界时,就应该抛出NullPointerExcption异常与IndexOutOfBoundsException异常,而不是IllegalArgumentException。
另一个值得了解的能用异常是ConcurrentModificationException。如果一个对象被设计专用于单线程或者与外部同步机制配合使用,一旦发现它正在或已经被修改,就抛出该异常。
最后一个通用的异常是UnsupportedOperationException。如果对象不支持所请求的操作,就会抛出这个异常。
前面列举的最是最常用的异常,但在有些情况下,这些也是可重用的,如ArithmeticException和NumberFormatException异常可用在算术对象中。
一定要确保抛出的异常的条件与该异常的文档中的描述的条件是一致的,如果希望稍微增加更多的失败-捕获信息,可以把现有的异常进行子类化。
61、 在无法避免底层异常时,抛出与系统直接相关的异常
如果方法抛出的异常与所执行的任务没有明显的联系,这种情形将会使人不知所措,当底层的异常传播到高层时往往会出现这种情况。这了使人困惑之外,抛出的底层异常类会污染高层的API(高层要依赖于底层异常类)。为了避免这个问题,高层在捕获底层抛出的异常的同时,在捕获的地方将底层的异常转换后再重新抛出会更好:
// 异常转换
try {
// 调用底层方法
...
} catch(LowerLevelException e) {
//捕获底层抛出的异常后并转换成适合自己系统的异常后再重新抛出
throw new HigherLevelException(...);
}
下面是个来自AbstractSequentialList类中的底层异常转换的实例,该数是List的一个抽象类,它的直接子类为LinkedList,在这个例子中,按照List<E>接口中的get方法的规范(规范中说到:如果索引超出范围 (index < 0 || index >= size()),就会抛出IndexOutOfBoundsException异常),底层方法只要可能抛出异常,我们就需要转换这个异常,下面是AbstractSequentialList类库的做法:
/**
* Returns the element at the specified position in this list.
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= size()}).
*/
public E get(int index) {
ListIterator<E> i = listIterator(index);
try {
return i.next();//Iterator的next会抛出NoSuchElementException运行时异常
} catch(NoSuchElementException e) {
/*
* 但接口规范是要求抛出IndexOutOfBoundsException异常,所以需要转换。当然这种
* 转换也是合理的,因为该方法的功能特性就是按索引来取元素,在索引越界的情况
* 下抛出NoSuchElementException也是没有太大的问题的(当然劈开规范来说的),但
* 抛IndexOutOfBoundsException异常会更适合一些
*/
throw new IndexOutOfBoundsException("Index: " + index);
}
}
另一种异常转换的形式是异常链,如果底层的异常对于高层调试有很大帮助时,使用异常链就非常合适,这样在高层我们可以通过相应的方法来获取底层抛出的异常:
// 异常链
try {
... // 调用底层方法
} catch (LowerLevelException cause) {
// 构造异常链后重新抛出
throw new HigherLevelException(cause);
}
尽管异常转换与不加选择地将捕获到的底层异常传播到高层中去相比有所改进,但是它不能滥用。处理来自底层异常的首选做法是根本就让底层抛出异常,在调用底层方法前确保它会成功,从而来避免抛出异常,另外,我们有时也可以在调用底层方法前,在高层检查一下参数的有效性,从而也可以避免异常的发生,当然这种做法(不要抛出底层异常的做法)只是对底层抛出的是运行时异常时才可行。如果确实无法避免(如低层抛出的是受检异常或是运行时异常但根本无法阻止)低层异常时,次选方案是让高层绕开这些异常,并将异常使用日志记录器记录下来供事后调试。
总之,处理底层异常最好的方法首选是阻止底层异常的发生,如果不能阻止或者处理底层异常时,一般的做法是使用异常转换(包括异常链转换),除非底层方法碰巧可以保证抛出的异常对高层也合适才可以将底层异常直接从底层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常的同时,又能捕获底层的原因进行失败分析。
62、 每个方法抛出的异常都要有文档描述
如果一个方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这此异常类的某个超类。永远不要声明一个方法“throws Exception”,或者更糟的是声明“throws Throwable”,这是极端的例子,因为它掩盖了该方法可能抛出的其他异常。
对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。每个方法的文档应该描述它的前提条件,这是很重要的,在文档中描述出未受检的异常是满中前提条件的最佳做法。
对于掊中的方法,在文档中描述出它可能抛出的未受检异常显得尤其重要。这份文档成了该接口的通用约定的一部分,它指定了该接口的多个实现必须遵循的公共行为。
未受检异常也要在@throws标签中进行描述。
应该注意的是,为每个方法可能抛出的所有未受检异常建立文档是一种很理想的好的想法,但在实践中并非总能做到这一点。比如在后面的版本中如果修改代码,有可能抛出另外一个未受检异常,这不算违反源代码或者二进制兼容性。
如果某类所有方法抛出同一个异常,那么这个异常的文档可以描述在类文档中。
总之,要为你编写的每个方法所能摆好出的每个异常建立文档,对于未受检和受检异常,以及对于抽象的和具体的方法也都一样。
63、 异常信息中要包含足够详细的异常细节消息
异常的细节消息对异常捕获者非常有用,对异常的诊断是非常有帮助的。
为了捕获失败,异常的细节消息应该包含所有“对该异常有作用”的参数和域值。例如IndexOutOfBoundsException异常的细节消息应该包含下界、上界以及没有落在界内的下标值,因为这三个值都有可能引起这个异常。
异常的细节消息不应该与“用户层次的错误消息”混为一谈,后都对于最终用户而言必须是可理解的。与用户层次的错误消息不同,异常的详细消息主要是让程序员用来分析失败原因的。因此,异常细节消息的内容比可理解性重要得多。
为了确保在异常的细节消息中包含足够的能捕获失败的信息,一种办法是在异常的构造器而不是字符串细节消息中引入这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。例如,IndexOutOfBoundsException本应该这样设计的:
/**
* Construct an IndexOutOfBoundsException.
*
* @param lowerBound the lowest legal index value.
* @param upperBound the highest legal index value plus one.
* @param index the actual index value.
*/
public IndexOutOfBoundsException(int lowerBound, int upperBound,int index) {
// 构建详细的捕获消息
super("Lower bound: " + lowerBound +
", Upper bound: " + upperBound +
", Index: " + index);
// 存储失败的细节消息供程序访问
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
但遗憾的是,Java平台类库并没有使用这种做法,但是,这种做法仍然值得大力推荐。
正如第58条中所建议的,为异常的“失败捕获”信息提供一些访问方法是合适的(如在上述例子中的访问lowerBound、upperBound和index域的方法,注上面已省),提供这样的访问方法对于受检的异常,比对于未受检的异常更为重要,因为失败——捕获信息对于从失败中恢复是非常有用的。程序员访问未受检查异常的细节是很少见,然而,即使对于未受检的异常,提供这些访问方法也是明智的。
64、 努力使失败保持原子性
当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤其重要,因为调用者希望能从这种异常中进行恢复(即继续调用发生异常对象的方法)。一般而言,失败的方法调用应该使对象保持在它被调用之前的状态。具有这种属性的方法被称为具有失败原子性。
有几种途径可以实现这种效果。最简单的办法莫过于设计一个不可变的对象。如果对象是不可变的,失败原子性就是显然的。如果一个操作失败了,它可能会阻止创建新的对象,但是永远也不会使用已有的对象保持在不一致的状态中,因为当每个对象被创建之后它就处于一致的状态中,以后不会再发生变化。
对于在可变对象上执行操作的方法,获得失败原子性最常见的办法是,在执行操作之前检查参数的有效性,这可以使得在对象的状态被修改前,先抛出适当的异常。例如,考虑第6条中的Stack.pop方法:
public Object pop() {
if (size == 0)//先对状态进行有效性检测
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
如果取消对初始大小(size)的检查,当这个方法企图从一个空栈中弹出元素时,它仍然会抛出异常。然而,这会导致size域保持在不一致的状态(负数)之中,从而导致将来对该对象的任何方法调用都会失败。
一种类似的获得失败原子性的办法是,调整计算处理的过程的顺序,使得任何可能失败的计算部分都在对象状态被修改之前执行。如果对参数的检查只有在执行了部分计算之后(这些提前执行的计算与后面需检查的参数要无关者可以啊!)才能进行,这种办法实际上就是上一种办法的自然扩展。例如放入TreeMap中的元素都要具有比较能力,在放入元素时,put方法是先进行了比较方法compare()的调用(如果放的元素不具有比较能力时会抛出ClassCastException),然后才是修改对象内部状态如size的自增与红黑树的结构调整。
第三种获得失败原子性的办法远远没有那么常用,做法是编写一段恢复代码,由它来拦截操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。
最后一种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内部。如果数据保存在临时的数据结构中,计算过程会更加快的话,使用这种办法就更加可行了。例如,Collections.sort在执行排序之前,首先把它的输入列表转换到一个数组中,以便降低在排序的循环内访问元素所需要的开销,这是出于性能考虑,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。
虽然一般情况下都希望实现失败原子性,但并非总是可以做到的。例如,如果两个线程企图在没有同步机制下,并发地修改了同一个对象,这个对象就有可能处于不一致的状态中。因此,在捕获了ConcurrentModificationException异常之后再假设对象仍然是可用的,这就不正确了,此时不需要努力保持失败原子性了。
即使在可以实现失败原子性场合,也并不是人们所希望的,因为某些操作,会增加开销或者复杂性,实现失败原子性应该往往是轻松自如的。
65、 不要忽略异常
请不要这样做:
// 忽略异常块,强烈反对
try {
...
} catch (SomeException e) {
}
空的catch块会使异常达不到应用的目的。忽略异常就如同火警信号器关掉了。至少,catch块也应该包含一条说明,解释为什么可以忽略这个异常。
有一种情况可以忽略异常,即关闭FileInputStream的时候,因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息了,因此不必终止正在进行的操作,即使在这种情况下,也得要把异常记录下来。
本条目中的建议同样适用于受检异常和未受检异常。