《Effective Java》笔记57-65:异常

第57条:只针对异常的情况才使用异常

也许你在将来会碰到下面这样的代码,它是基本异常模式的循环:

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

实例上,在现代的JVM实例上,基本异常的模式比标准模式要慢得多。

异常应该只用于异常的情况下,它们永远不应该用于正常的控制流。

第58条:对可恢复的情况使用受检异常,对编程错误使用运用时异常

Java程序设计语言提供了三种异常:受检的异常(checked exception)、运行时异常(run-time exception)和错误(error)。关于什么时候适合使用哪种异常,虽然没有明确的规定,但还是有些一般性的原则的。

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

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

第59条:避免不必要地使用受检的异常

受检异常是 Java 提供的一个很好的特征。与返回值不同,它们强迫程序员必须处理异常的条件,从而大大增强了程序的可靠性。也就是说,过分使用受检的异常,调用该方法的代码就必须在 catch 中处理这些异常,或者抛出。

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

如果方法只抛出单个受检异常,也会导致该方法不得在 try 块中,在这种情况下,应该问自己,是否有别的途径来避免 API 调用者使用受检的异常。这里提供这样的参考,我们可以把抛出的单个异常的方法分成两个方法,其中一个方法返回一个 boolean,表明是否该抛出异常。这种 API 重构:

try { // 调用时检查异常
    obj.action(args); // 调用检查异常方法
} catch (TheCheckedExcption e) {
    // 处理异常条件
    ...
}

//重构为:
if (obj.actionPermitted(args)) { // 使用状态测试方法消除catch
    obj.action(args);
} else {
    // 处理异常条件
    ...
}

这种重构并不总是合适的,但在合适的地方,它会使用 API 用起来更加舒服。虽然没有前者漂亮,但更加灵活——如果程序员知道调用肯定会成功,或不介意由调用失败而导致的线程终止,则下面为理为简单的调用形式:

obj.action(args);

第60条:优先使用标准的异常

异常 使用场景
IllegalArgumentException 非 null 的参数值不正确。如:收款金额传递负数
IllegalStateException 对于方法调用而言,对象状态不合适。如:正在初始化中,就被调用了
NullPointerException 在禁止使用 null 的情况下参数值为 null。
IndexOutOfBoundsException 下标参数值越界
ConcurrentModificationException 在禁止并发修改的情况下,检测到对象的并发修改。
UnsupportedOperationException 对象不支持用户请求的方法。如:只读 List 进行删除操作

第61条:抛出与抽象相对应的异常

更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法称为异常转译 (exception translation),如下代码所示:

// 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() );
    } catch (NoSuchElementException e) {
        throw new IndexOutOfBoundsException("Index: " + index);
    }
}

一种特殊的异常转译形式称为异常链(exception chaining),如果低层的异常对于调试导致高层异常的问题非常有帮助,使用异常链就很合适。低层的异常(原因)被传到高层的异常,高层的异常提供访问方法 (Throwable 的 getCause 方法)来获得低层的异常:

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

高层异常的构造器将原因传到支持链(chaining-aware)的超级构造器,因此它最终将被传给 Throwable 的其中一个运行异常链的构造器,例如 Throwable(Throwable) :

/* Exception with chaining-aware constructor */
class HigherLevelException extends Exception {
    HigherLevelException( Throwable cause ) {
        super(cause);
    }
}

大多数标准的异常都有支持链的构造器。对于没有支持链的异常,可以利用 Throwable 的 initCause 方法设置原因。异常链不仅让你可以通过程序(用 getCause)访问原因,还可以将原因的堆战轨迹集成到更高层的异常中。

尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是也不能滥用它。

总而言之,如果不能阻止或者处理来自更低层的异常,一般的做法是使用异常转译,只有在低层方法的规范碰巧可以保证“它所抛出的所有异常对于更高层也是合适的”情况下,才可以将异常从低层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常,同时又能捕获低层的原因进行失败分析。

第62条:每个方法抛出的异常都要有文档

如果一个方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这此异常类的某个超类。永远不要声明一个方法throws Exception,或者更糟的是声明 throws Throwable ,这是极端的例子,因为它掩盖了该方法可能抛出的其他异常。

对于方法可能抛出的运行时异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件

对于接口的方法,在文档中描述出它可能抛出的运行时异常显得尤其重要。这份文档成了该接口的通用约定的一部分,它指定了该接口的多个实现必须遵循的公共行为。

运行时异常也要在 @throws 标签中进行描述。

应该注意的是,为每个方法可能抛出的所有运行时异常建立文档是一种很理想的好的想法,但在实践中并非总能做到这一点。比如在后面的版本中如果修改代码,有可能抛出另外一个运行时异常,这不算违反源代码或者二进制兼容性。

如果某类所有方法抛出同一个异常,那么这个异常的文档可以描述在类文档中。

总之,要为你编写的每个方法所能摆好出的每个异常建立文档,对于未受检和受检异常,以及对于抽象的和具体的方法也都一样。

第63条:在细节消息中包含能捕获失败的信息

为了捕获失败,异常的细节信息应该包含“对该异常有贡献”的所有参数和字段的值。 例如, IndexOutOfBoundsException 异常的细节消息应该包含下界、上界以及没有落在界内的下标值。该细节消息提供了许多关于失败的信息。这三个值中任何一个或者全部都有可能是错的。实际的下标值可能小于下界或等于上界(「越界错误」),或者它可能是个无效值,太小或太大。下界也有可能大于上界(严重违反内部约束条件的一种情况) 。每一种情形都代表了不同的问题,如果程序员知道应该去查找哪种错误,就可以极大地加速诊断过程。

为了确保在异常的细节消息中包含足够的失败信息,一种办法是在异常的构造器而不是字符串细节消息中引人这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。例如 IndexOutOfBoundsException 使用如下构造器代替 String 构造器:

/**
 * Constructs 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 ) {
    // Generate a detail message that captures the failure
    super(String.format(
              "Lower bound: %d, Upper bound: %d, Index: %d",
              lowerBound, upperBound, index ) );
    // Save failure information for programmatic access
    this.lowerBound = lowerBound;
    this.upperBound = upperBound;
    this.index = index;
}

从 Java 9 开始, IndexOutOfBoundsException 终于获得了一个构造器,它可以带一个类型为 int 的 index 参数值。更通俗地说, Java 平台类库并没有广泛地使用这种做法,但是,这种做法仍然值得大力推荐。

第64条:努力使失败保持原子性

一般而言,失败的方法调用应该使对象保持在被调用之前的状态。 具有这种属性的方法被称为具有失败原子性。
有以下几种途径可以保持这种原子性。

  1. 最简单的方法是设计不可变对象。因为失败的操作只会导致新对象的创建失败,而不会影响已有的对象。对于可变对象,一般方法是在操作该对象之前先进行参数的有效性验证,这可以使对象在被修改之前,抛出更为有意义的异常,如:

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

    如果取消对初始大小(size)的检查,当这个方法企图从一个空栈中弹出元素时,它仍然会抛出异常。然而,这将会导致 size 字段保持在不一致的状态(负数)之中,从而导致将来对该对象的任何方法调用都会失败。此外,那时, pop 方法抛出的 ArrayIndexOutOfBoundsException 异常对于该抽象来说也是不恰当的。

  2. 一种类似的获得失败原子性的办法是,调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生。如果对参数的检查只有在执行了部分计算之后才能进行,这种办法实际上就是上一种办法的自然扩展。比如,以 TreeMap 的情形为例,它的元素被按照某种特定的顺序做了排序。为了向 TreeMap 中添加元素,该元素的类型就必须是可以利用 TreeMap 的排序准则与其他元素进行比较的。如果企图增加类型不正确的元素,在 tree 以任何方式被修改之前,自然会导致 ClassCastException 异常。

  3. 第三种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内容。如果数据保存在临时的数据结构中,计算过程会更加迅速,使用这种办法就是件很自然的事。例如,有些排序函数会在执行排序之前,先把它的输入列表备份到一个数组中,以便降低在排序的内循环中访问元素所需要的开销。这是出于性能考虑的做法,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。

  4. 最后一种获得失败原子性的办法远远没有那么常用,做法是编写一段恢复代码 (recovery code),由它来拦截操作过程中发生的失败,以及便对象回滚到操作开始之前的状态上。这种办法主要用于永久性的(基于磁盘的) 数据结构。

虽然一般情况下都希望实现失败原子性,但并非总是可以做到。举个例子,如果两个线程企图在没有适当的同步机制的情况下,并发地修改同一个对象,这个对象就有可能被留在不一致的状态之中。因此,在捕获了 ConcurrentModificationException 异常之后再假设对象仍然是可用的,这就是不正确的。错误通常是不可恢复的,因此,当方法抛出 AssertionError 时,不需要努力去保持失败原子性。

第65条:不要忽略异常

空的 catch 块会使异常达不到应用的目的。忽略异常就如同火警信号器关掉了。至少,catch 块也应该包含一条说明,解释为什么可以忽略这个异常。

有一种情况可以忽略异常,即关闭 FileInputStream 的时候,因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息了,因此不必终止正在进行的操作,即使在这种情况下,也得要把异常记录下来。

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


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Effective Java》笔记57-65:异常

文章字数:3.6k

本文作者:Bin

发布时间:2019-06-20, 22:06:00

最后更新:2019-08-06, 00:43:30

原始链接:http://coolview.github.io/2019/06/20/Effective-Java/%E3%80%8AEffective%20Java%E3%80%8B%E7%AC%94%E8%AE%B057-65%EF%BC%9A%E5%BC%82%E5%B8%B8/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录