Effective-Java学习笔记(九)

第十章 异常

69. 仅在确有异常条件下使用异常

有人认为用 try-catch 和 while(true) 遍历数组比用 for-each 性能更好,因为 for-each 由编译器隐藏了边界检查,而 try-catch 代码中不包含检查

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

这个想法有三个误区:

  1. 因为异常是为特殊情况设计的,所以 JVM 实现几乎不会让它们像显式测试一样快。
  2. 将代码放在 try-catch 块中会抑制 JVM 可能执行的某些优化。
  3. 遍历数组的标准习惯用法不一定会导致冗余检查。许多 JVM 实现对它们进行了优化。

基于异常的循环除了不能提高性能外,还容易被异常隐藏循环中的 bug。因此,异常只适用于确有异常的情况;它们不应该用于一般的控制流程。

一个设计良好的 API 不能迫使其客户端为一般的控制流程使用异常。调用具有「状态依赖」方法的类,通常应该有一个单独的「状态测试」方法,表明是否适合调用「状态依赖」方法。例如,Iterator 接口具有「状态依赖」的 next 方法和对应的「状态测试」方法 hasNext。

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

如果 Iterator 缺少 hasNext 方法,客户端将被迫这样做:

// Do not use this hideous code for iteration over a collection!
try {
    Iterator<Foo> i = collection.iterator();
    while(true) {
        Foo foo = i.next();
        ...
    }
}
catch (NoSuchElementException e) {
}

这与一开始举例的对数组进行迭代的例子非常相似,除了冗长和误导之外,基于异常的循环执行效果可能很差,并且会掩盖系统中不相关部分的 bug。

提供单独的「状态测试」方法的另一种方式,就是让「状态依赖」方法返回一个空的 Optional 对象(Item-55),或者在它不能执行所需的计算时返回一个可识别的值,比如 null。

状态测试方法、Optional、可识别的返回值之间的选择如下:

  1. 如果要在没有外部同步的情况下并发地访问对象,或者受制于外部条件的状态转换,则必须使用 Optional 或可识别的返回值,因为对象的状态可能在调用「状态测试」方法与「状态依赖」方法的间隔中发生变化。
  2. 如果一个单独的「状态测试」方法重复「状态依赖」方法的工作,从性能问题考虑,可能要求使用 Optional 或可识别的返回值
  3. 在所有其他条件相同的情况下,「状态测试」方法略优于可识别的返回值。它提供了较好的可读性,而且不正确的使用可能更容易被检测:如果你忘记调用「状态测试」方法,「状态依赖」方法将抛出异常,使错误显而易见;
  4. 如果你忘记检查一个可识别的返回值,那么这个 bug 可能很难发现。但是这对于返回 Optional 对象的方式来说不是问题。

总之,异常是为确有异常的情况设计的。不要将它们用于一般的控制流程,也不要编写强制其他人这样做的 API。

70. 对可恢复情况使用 checked 异常,对编程错误使用运行时异常

Java 提供了三种可抛出项:checked 异常、运行时异常和错误。决定是使用 checked 异常还是 unchecked 异常的基本规则是:使用 checked 异常的情况是为了合理地期望调用者能够从中恢复。

有两种 unchecked 的可抛出项:运行时异常和错误。它们在行为上是一样的:都是可抛出的,通常不需要也不应该被捕获。如果程序抛出 unchecked 异常或错误,通常情况下是不可能恢复的,如果继续执行,弊大于利。如果程序没有捕获到这样的可抛出项,它将导致当前线程停止,并发出适当的错误消息。

运行时异常

运行时异常用来指示编程错误。大多数运行时异常都表示操作违反了先决条件。违反先决条件是指使用 API 的客户端未能遵守 API 规范所建立的约定。例如,数组访问约定指定数组索引必须大于等于 0 并且小于等于 length-1 (length:数组长度)。ArrayIndexOutOfBoundsException 表示违反了此先决条件

这个建议存在的问题是:并不总能清楚是在处理可恢复的条件还是编程错误。例如,考虑资源耗尽的情况,这可能是由编程错误(如分配一个不合理的大数组)或真正的资源短缺造成的。如果资源枯竭是由于暂时短缺或暂时需求增加造成的,这种情况很可能是可以恢复的。对于 API 设计人员来说,判断给定的资源耗尽实例是否允许恢复是一个问题。如果你认为某个条件可能允许恢复,请使用 checked 异常;如果没有,则使用运行时异常。如果不清楚是否可以恢复,最好使用 unchecked 异常

错误

虽然 Java 语言规范没有要求,但有一个约定俗成的约定,即错误保留给 JVM 使用,以指示:资源不足、不可恢复故障或其他导致无法继续执行的条件。考虑到这种约定被大众认可,所以最好不要实现任何新的 Error 子类。因此,你实现的所有 unchecked 异常都应该继承 RuntimeException(直接或间接)。不仅不应该定义 Error 子类,而且除了 AssertionError 之外,不应该抛出它们。

自定义异常

自定义异常继承 Throwable 类,Java 语言规范把它们当做普通 checked 异常(普通 checked 异常是 Exception 的子类,但不是 RuntimeException的子类)。不要使用自定义异常,它会让 API 的用户困惑

异常附加信息

API 设计人员常常忘记异常是成熟对象,可以为其定义任意方法。此类方法的主要用途是提供捕获异常的代码,并提供有关引发异常的附加信息。如果缺乏此类方法,程序员需要自行解析异常的字符串表示以获取更多信息。这是极坏的做法

因为 checked 异常通常表示可恢复的条件,所以这类异常来说,设计能够提供信息的方法来帮助调用者从异常条件中恢复尤为重要。例如,假设当使用礼品卡购物由于资金不足而失败时,抛出一个 checked 异常。该异常应提供一个访问器方法来查询差额。这将使调用者能够将金额传递给购物者。

总而言之,为可恢复条件抛出 checked 异常,为编程错误抛出 unchecked 异常。当有疑问时,抛出 unchecked 异常。不要定义任何既不是 checked 异常也不是运行时异常的自定义异常。应该为 checked 异常设计相关的方法,如提供异常信息,以帮助恢复。

71. 避免不必要地使用 checked 异常

合理抛出 checked 异常可以提高程序可靠性。过度使用会使得调用它的方法多次 try-catch 或抛出,给 API 用户带来负担,尤其是 Java8 中,抛出checked 异常的方法不能直接在流中使用。

只有在正确使用 API 也无法避免异常且使用 API 的程序员在遇到异常时可以采取一些有用的操作才能使用 checked 异常,否则抛出 unchecked 异常

如果 checked 异常是方法抛出的唯一 checked 异常,那么 checked 异常给程序员带来的额外负担就会大得多。如果还有其他 checked 异常,则该方法一定已经在 try 块中了,因此该异常最多需要另一个 catch 块而已。如果一个方法抛出单个 checked 异常,那么这个异常就是该方法必须出现在 try 块中而不能直接在流中使用的唯一原因。在这种情况下,有必要问问自己是否有办法避免 checked 异常。

消除 checked 异常的最简单方法是返回所需结果类型的 Optional 对象(Item-55)。该方法只返回一个空的 Optional 对象,而不是抛出一个 checked 异常。这种技术的缺点是,该方法不能返回任何详细说明其无法执行所需计算的附加信息。相反,异常具有描述性类型,并且可以导出方法来提供附加信息(Item-70)

总之,如果谨慎使用,checked 异常可以提高程序的可靠性;当过度使用时,它们会使 API 难以使用。如果调用者不应从失败中恢复,则抛出 unchecked 异常。如果恢复是可能的,并且你希望强制调用者处理异常情况,那么首先考虑返回一个 Optional 对象。只有当在失败的情况下,提供的信息不充分时,你才应该抛出一个 checked 异常。

72. 鼓励复用标准异常

Java 库提供了一组异常,涵盖了大多数 API 的大多数异常抛出需求。

复用标准异常有几个好处:

  1. 使你的 API 更容易学习和使用,因为它符合程序员已经熟悉的既定约定
  2. 使用你的 API 的程序更容易阅读,因为它们不会因为不熟悉的异常而混乱
  3. 更少的异常类意味着更小的内存占用和更少的加载类的时间

常见的被复用的异常:

  1. IllegalArgumentException。通常是调用者传入不合适的参数时抛出的异常

  2. IllegalStateException。如果因为接收对象的状态导致调用非法,则通常会抛出此异常。例如,调用者试图在对象被正确初始化之前使用它

    可以说,每个错误的方法调用都归结为参数非法或状态非法,但是有一些异常通常用于某些特定的参数非法和状态非法。如果调用者在禁止空值的参数中传递 null,那么按照惯例,抛出 NullPointerException 而不是 IllegalArgumentException。类似地,如果调用者将表示索引的参数中的超出范围的值传递给序列,则应该抛出 IndexOutOfBoundsException,而不是 IllegalArgumentException

  3. ConcurrentModificationException。如果一个对象被设计为由单个线程使用(或与外部同步),并且检测到它正在被并发地修改,则应该抛出该异常。因为不可能可靠地检测并发修改,所以该异常充其量只是一个提示。

  4. UnsupportedOperationException。如果对象不支持尝试的操作,则抛出此异常。它很少使用,因为大多数对象都支持它们的所有方法。此异常用于一个类没有实现由其实现的接口定义的一个或多个可选操作。例如,对于只支持追加操作的 List 实现,试图从中删除元素时就会抛出这个异常

不要直接复用 Exception、RuntimeException、Throwable 或 Error

此表总结了最常见的可复用异常:

Exception Occasion for Use
IllegalArgumentException Non-null parameter value is inappropriate(非空参数值不合适)
IllegalStateException Object state is inappropriate for method invocation(对象状态不适用于方法调用)
NullPointerException Parameter value is null where prohibited(禁止参数为空时仍传入 null)
IndexOutOfBoundsException Index parameter value is out of range(索引参数值超出范围)
ConcurrentModificationException Concurrent modification of an object has been detected where it is prohibited(在禁止并发修改对象的地方检测到该动作)
UnsupportedOperationException Object does not support method(对象不支持该方法调用)

其它异常如果有合适的复用场景也可以复用,例如,如果你正在实现诸如复数或有理数之类的算术对象,那么复用 ArithmeticException 和 NumberFormatException 是合适的

73. 抛出适合底层抽象异常的高层异常

当方法抛出一个与它所执行的任务没有明显关联的异常时,这是令人不安的。这种情况经常发生在由方法传播自低层抽象抛出的异常。它不仅令人不安,而且让实现细节污染了上层的 API。

为了避免这个问题,高层应该捕获低层异常,并确保抛出的异常可以用高层抽象解释。 这个习惯用法称为异常转换:

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

下面是来自 AbstractSequentialList 类的异常转换示例,该类是 List 接口的一个框架实现(Item-20)。在本例中,异常转换是由 List<E> 接口中的 get 方法规范强制执行的:

/**
* 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);
    }
}

如果低层异常可能有助于调试高层异常的问题,则需要一种称为链式异常的特殊异常转换形式。低层异常(作为原因)传递给高层异常,高层异常提供一个访问器方法(Throwable 的 getCause 方法)来检索低层异常:

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

高层异常的构造函数将原因传递给能够接收链式异常的父类构造函数,因此它最终被传递给 Throwable 的一个接收链式异常的构造函数,比如 Throwable(Throwable):

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

大多数标准异常都有接收链式异常的构造函数。对于不支持链式异常的异常,可以使用 Throwable 的 initCause 方法设置原因。异常链不仅允许你以编程方式访问原因(使用 getCause),而且还将原因的堆栈跟踪集成到更高层异常的堆栈跟踪中。

虽然异常转换优于底层异常的盲目传播,但它不应该被过度使用。在可能的情况下,处理底层异常的最佳方法是确保底层方法避免异常。有时,你可以在将高层方法的参数传递到底层之前检查它们的有效性。

如果不可能从底层防止异常,那么下一个最好的方法就是让高层静默处理这些异常,使较高层方法的调用者免受底层问题的影响。在这种情况下,可以使用一些适当的日志工具(如 java.util.logging)来记录异常。这允许程序员研究问题,同时将客户端代码和用户与之隔离。

总之,如果无法防止或处理来自底层的异常,则使用异常转换,但要保证底层方法的所有异常都适用于较高层。链式异常提供了兼顾两方面的最佳服务:允许抛出适当的高层异常,同时捕获并分析失败的潜在原因

74. 为每个方法记录会抛出的所有异常

  1. 始终单独声明 checked 异常,并使用 Javadoc 的 @throw 标记精确记录每次抛出异常的条件。如果一个方法抛出多个异常,不要使用快捷方式声明这些异常的父类。作为一个极端的例子,即不要在公共方法声明 throws Exception,除了只被 JVM 调用的 main方法
  2. unchecked 异常不要声明 throws,但应该像 checked 异常一样用 Javadoc 记录他们。特别是接口中的方法要记录可能抛出的 unchecked 异常。

如果一个类中的许多方法都因为相同的原因抛出异常,你可以在类的文档注释中记录异常, 而不是为每个方法单独记录异常。一个常见的例子是 NullPointerException。类的文档注释可以这样描述:「如果在任何参数中传递了 null 对象引用,该类中的所有方法都会抛出 NullPointerException」

总之,记录你所编写的每个方法可能引发的每个异常。对于 unchecked 异常、checked 异常、抽象方法、实例方法都是如此。应该在文档注释中采用 @throw 标记的形式。在方法的 throws 子句中分别声明每个 checked 异常,但不要声明 unchecked 异常。如果你不记录方法可能抛出的异常,其他人将很难或不可能有效地使用你的类和接口。

75. 异常详细消息中应包含捕获失败的信息

程序因为未捕获异常而失败时,系统会自动调用异常的 toString 方法打印堆栈信息,堆栈信息包含异常的类名及详细信息。异常的详细消息应该包含导致异常的所有参数和字段的值。例如,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;
}

76. 尽力保证故障原子性

失败的方法调用应该使对象处于调用之前的状态。 具有此属性的方法称为具备故障原子性。

有几种实现故障原子性的方式:

关于不可变对象

  1. 不可变对象在创建后永远处于一致状态

关于可变对象:

  1. 在修改状态前,先执行可能抛出异常的操作,例如检查状态,不合法就抛出异常
  2. 在临时副本上操作,成功后替换原来的对象,不成功不影响原来的对象。例如,一些排序函数会将入参 list 复制到数组中,对数组进行排序,再转换成 list
  3. 编写回滚代码,主要用于持久化的数据

有些情况是不能保证故障原子性的,例如,多线程不同步修改对象,对象可能处于不一致状态,当捕获到 ConcurrentModificationException 后对象不可恢复

总之,作为方法规范的一部分,生成的任何异常都应该使对象保持在方法调用之前的状态。如果违反了这条规则,API 文档应该清楚地指出对象将处于什么状态。

77. 不要忽略异常

异常要么 try-catch 要么抛出,不要写空的 catch 块,如果这样做,写上注释