Effective Java 异常 (57-65)

Posted by JackPeng on July 17, 2016

五十七、只针对异常情况才使用异常

不知道你否则遇见过下面的代码:

try {
     int i = 0;
     while (true)
         range[i++].climb();
 } catch (ArrayIndexOutOfBoundsException e) {
 }

这段代码的意图不是很明显,其本意就是遍历变量数组range中的每一个元素,并执行元素的climb方法,当下标超出range的数组长度时,将会直接抛出ArrayIndexOutOfBoundsException异常,catch代码块将会捕获到该异常,但是未作任何处理,只是将该错误视为正常工作流程的一部分来看待。这样的写法确实给人一种匪夷所思的感觉,让我们再来看一下修改后的写法:

for (Mountain m : range) {
    m.climb();
}

和之前的写法相比其可读性不言而喻。那么为什么又有人会用第一种写法呢?显然他们是被误导了,他们企图避免for-each循环中JVM对每次数组访问都要进行的越界检查。这无疑是多余的,甚至适得其反,因为将代码放在try-catch块中反而阻止了JVM的某些特定优化,至于数组的边界检查,现在很多JVM实现都会将他们优化掉了。在实际的测试中,我们会发现采用异常的方式其运行效率要比正常的方式慢很多。

除了刚刚提到的效率和代码可读性问题,第一种写法还会掩盖一些潜在的Bug,假设数组元素的climb方法中也会访问某一数组,并且在访问的过程中出现了数组越界的问题,基于该错误,JVM将会抛出ArrayIndexOutOfBoundsException异常,不幸的是,该异常将会被climb函数之外catch语句捕获,在未做任何处理之后,就按照正常流程继续执行了,这样Bug也就此被隐藏起来。

这个例子的教训很简单:”异常应该只用于异常的情况下,它们永远不应该用于正常的控制流”。虽然有的时候有人会说这种怪异的写法可以带来性能上的提升,即便如此,随着平台实现的不断改进,这种异常模式的性能优势也不可能一直保持。然而,这种过度聪明的模式带来的微妙的Bug,以及维护的痛苦却依然存在。

根据这条原则,我们在设计API的时候也是会有所启发的。设计良好的API不应该强迫它的客户端为了正常的控制流而使用异常。如Iterator,JDK在设计时充分考虑到这一点,客户端在执行next方法之前,需要先调用hasNext方法已确认是否还有可读的集合元素,见如下代码:

for (Iterator<Foo> i = collection.iterator(); i.hasNext(); ) {
    Foo f = i.next();
}

如果Iterator缺少hasNext方法,客户端则将被迫改为下面的写法:

try {
    Iterator<Foo> i = collection.iterator();
     while (true)
         Foo f = i.next();
 } catch (NoSuchElementException e) {
 }

这应该非常类似于本条目开始时给出的遍历数组的例子。在实际的设计中,还有另外一种方式,即验证可识别的错误返回值,然而该方式并不适合于此例,因为对于next,返回null可能是合法的。那么这两种设计方式(状态测试和错误返回值)在实际应用中有哪些区别呢?

  1. 如果是缺少同步的并发访问,或者可被外界改变状态,使用可识别返回值的方法是非常必要的,因为在测试状态(hasNext)和对应的调用(next)之间存在一个时间窗口,在该窗口中,对象可能会发生状态的变化。因此,在该种情况下应选择返回可识别的错误返回值的方式。

  2. 如果状态测试方法(hasNext)和相应的调用方法(next)使用的是相同的代码,出于性能上的考虑,没有必要重复两次相同的工作,此时应该选择返回可识别的错误返回值的方式。

  3. 对于其他情形则应该尽可能考虑”状态测试”的设计方式,因为它可以带来更好的可读性。

五十八、对可恢复的情况使用受检异常,对编程错误使用运行时异常

Java中提供了三种可抛出结构:受检异常、运行时异常和错误。该条目针对这三种类型适用的场景给出了一般性原则。

  1. 如果期望调用者能够适当地恢复,对于这种情况就应该使用受检异常,如某人打算网上购物,结果余额不足,此时可以抛出自定义的受检异常。通过抛出受检异常,将强迫调用者在catch子句中处理该异常,或继续向上传播。因此,在方法中声明受检异常,是对API用户的一种潜在提示(和业务相关,需要在catch代码块处理)。
  
  2. 用运行时异常来表明编程错误。大多数的运行时异常都表示"前提违例",即API的使用者没有遵守API设计者建立的使用约定。如数组访问越界等问题。(需要在try代码块处理)
  
  3. 对于错误而言,通常是被JVM保留用于表示资源不足、约束失败,或者其他使程序无法继续执行的条件。

针对自定义的受检异常,该条目还给出一个非常实用的技巧,当调用者捕获到该异常时,可以通过调用该自定义异常提供的接口方法,获取更为具体的错误信息,如当前余额等信息。

五十九、避免不必要的使用受检异常

受检异常是Java提供的一个很好的特征。与返回值不同,它们强迫程序员必须处理异常的条件,从而大大增强了程序的可靠性。然而,如果过分使用受检异常则会使API在使用时非常不方便,毕竟我们还是需要用一些额外的代码来处理这些抛出的异常,倘若在一个函数中,它所调用的五个API都会抛出异常,那么编写这样的函数代码将会是一项令人沮丧的工作。

如果正确的使用API不能阻止这种异常条件的产生,并且一旦产生异常,使用API的程序员可以立即采用有用的动作,这种负担就被认为是正当的。除非这两个条件都成立,否则更适合使用未受检异常,见如下测试:

try {
    dosomething();
} catch (TheCheckedException e) {
    throw new AssertionError();
}

try {
    donsomething();
} catch (TheCheckedException e) {
    e.printStackTrace();
    System.exit(1);
}

当我们使用受检异常时,如果在catch子句中对异常的处理方式仅仅如以上两个示例,或者还不如它们的话,那么建议你考虑使用未受检异常。原因很简单,它们在catch子句中,没有做出任何用于恢复异常的动作。

六十、优先使用标准异常

使用标准异常,不仅可以更好的复用已有的代码,同时也使你设计的API更加容易学习和使用,因为它和程序员已经熟悉的习惯用法更为一致。另外一个优势是,代码的可读性更好,程序员在阅读时不会出现更多的不熟悉的代码。该条目给出了一些非常常用且容易被复用的异常,见下表:

  异常                                               应用场合
  IllegalArgumentException              非null的参数值不正确。
  IllegalStateException                     对于方法调用而言,对象状态不合适。
  NullPointerException                     在禁止使用null的情况下参数值为null。
  IndexOutOfBoundsException         下标参数值越界
  ConcurrentModificationException   在禁止并发修改的情况下,检测到对象的并发修改。
  UnsupportedOperationException    对象不支持用户请求的方法。

当然在Java中还存在很多其他的异常,如ArithmeticException、NumberFormatException等,这些异常均有各自的应用场合,然而需要说明的是,这些异常的应用场合在有的时候界限不是非常分明,至于该选择哪个比较合适,则更多的需要依赖上下文环境去判断。

最后需要强调的是,一定要确保抛出异常的条件和该异常文档中描述的条件保持一致。

六十一、抛出与抽象相对应的异常

如果方法抛出的异常与它所执行的任务没有明显的关系,这种情形将会使人不知所措。特别是当异常从底层开始抛出时,如果在中间层没有做任何处理,这样底层的实现细节将会直接污染高层的API接口。为了解决这样的问题,我们通常会做出如下处理:

try {
    doLowerLeverThings();
 } catch (LowerLevelException e) {
     throw new HigherLevelException(...);
 } 这种处理方式被称为异常转译。事实上,在Java中还提供了一种更为方便的转译形式--异常链。试想一下上面的示例代码,在调试阶段,如果高层应用逻辑可以获悉到底层实际产生异常的原因,那么对找到问题的根源将会是非常有帮助的,见如下代码:

try {
     doLowerLevelThings();
 } catch (LowerLevelException cause) {
     throw new HigherLevelException(cause);
 }

底层异常作为参数传递给了高层异常,对于大多数标准异常都支持异常链的构造器,如果没有,可以利用Throwable的initCause方法设置原因。异常链不仅让你可以通过接口函数getCause访问原因,它还可以将原因的堆栈轨迹集成到更高层的异常中。 通过这种异常链的方式,可以非常有效的将底层实现细节与高层应用逻辑彻底分离出来。

六十三、在细节中包含能捕获失败的信息

当程序由于未被捕获的异常而失败的时候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常的字符串表示法,即toString方法的返回结果。如果我们在此时为该异常提供了详细的出错信息,那么对于错误定位和追根溯源都是极其有意义的。比如,我们将抛出异常的函数的输入参数和函数所在类的域字段值等信息格式化后,再打包传递给待抛出的异常对象。假设我们的高层应用捕捉到IndexOutOfBoundsException异常,如果此时该异常对象能够携带数组的下界和上界,以及当前越界的下标值等信息,在看到这些信息后,我们就能很快做出正确的判断并修订该Bug。

特别是对于受检异常,如果抛出的异常类型还能提供一些额外的接口方法用于获取导致错误的数据或信息,这对于捕获异常的调用函数进行错误恢复是非常重要的。

六十四、努力使失败保持原子性

这是一个非常重要的建议,因为在实际开发中当你是接口的开发者时,经常会忽视他,认为不保证的话估计也没有问题。相反,如果你是接口的使用者,也同样会忽略他,会认为这个是接口实现者理所应当完成的事情。

当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤为重要,因为调用者希望能从这种异常中进行恢复。一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有”失败原子性”。

有以下几种途径可以保持这种原子性。

  1. 最简单的方法是设计不可变对象。因为失败的操作只会导致新对象的创建失败,而不会影响已有的对象。

  2. 对于可变对象,一般方法是在操作该对象之前先进行参数的有效性验证,这可以使对象在被修改之前,抛出更为有意义的异常,如:

    public Object pop() { if (size == 0) throw new EmptyStackException(); Object result = elements[–size]; elements[size] = null; return result; }

如果没有在操作之前验证size,elements的数组也会抛出异常,但是由于size的值已经发生了变化,之后再继续使用该对象时将永远无法恢复到正常状态了。

  1. 预先写好恢复性代码,在出现错误时执行带段代码,由于此方法在代码编写和代码维护的过程中,均会带来很大的维护开销,再加之效率相对较低,因此很少会使用该方法。

  2. 为该对象创建一个临时的copy,一旦操作过程中出现异常,就用该复制对象重新初始化当前的对象的状态。

虽然在一般情况下都希望实现失败原子性,然而在有些情况下却是难以做到的,如两个线程同时修改一个可变对象,在没有很好同步的情况下,一旦抛出ConcurrentModificationException异常之后,就很难在恢复到原有状态了。

六十五、不要忽略异常

要忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块。 这是一个显而易见的常识,但是经常会被违反,因此该条目重新提出了它,如:

try {
    dosomething();
 } catch (SomeException e) {
 
 }

可预见的、可以使用忽略异常的情形是在关闭FileInputStream的时候,因为此时数据已经读取完毕。即便如此,如果在捕获到该异常时输出一条提示信息,这对于挖出一些潜在的问题也是非常有帮助的。否则一些潜在的问题将会一直隐藏下去,直到某一时刻突然爆发,以致造成难以弥补的后果。

该条目中的建议同样适用于受检异常和未受检的异常。