EffectiveJava--异常

本章内容:
1. 只针对异常的情况才使用异常
2. 对可恢复的情况使用受检异常,对编程错误使用运行时异常
3. 避免不必要地使用受检的异常
4. 优先使用标准的异常
5. 抛出与抽象相对应的异常
6. 每个方法抛出的异常都要有文档
7. 在细节消息中包含能捕获失败的信息
8. 努力使失败保持原子性
9. 不要忽略异常

1. 只针对异常的情况才使用异常
    某一天,如果你不走运的话,可能会碰到下面这样的代码:
        try { 
            int i = 0; 
            while (true) 
                range[i++].climb(); 
        } catch (ArrayIndexOutOfBoundsException e) { 
        } 
    这段代码的意图不是很明显,其本意是当下标超出range的数组长度时,将会直接抛出ArrayIndexOutOfBoundsException 异常,它的构想是非常拙劣的,对于任何一个Java程序员来说,下面的标准模式一看就会明白:
        for (Mountain m : range) {
            m.climb();
        }
    和之前的写法相比其可读性不言而喻。那么为什么又有人会用第一种写法呢?显然他们是被误导了,他们企图避免for-each 循环中JVM 对每次数组访问都要进行的越界检查。这无疑是多余的,应该避免。这种想法有三个错误:
(1)因为异常机制的设计初衷是用于不正常的情形,所以很少会JVM实现试图对它们进行优化,使得与显式的测试一样快速。
(2)把代码放在try-catch块中反而阻止了JVM的某些特定优化。
(3)对数组的进行遍历的标准模式并不会导致冗余的检查,有些现在的JVM实现会将他们优化掉。
    实际上,在现代的JVM实现是,基于异常的模式比标准模式要慢得多。
    基于异常的循环模式不仅模糊了代码的意图,降低了它的性能,而且它还不能保证正常工作。如果出现了不相关的Bug,这个模式会悄悄地失效,从而掩盖了这个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) {
        }
    这应该非常类似于本条目开始时给出的遍历数组的例子。在实际的设计中,还有另外一种方式,如果状态相关的方法被调用时,该对象处于不适当的状态之中,它就会返回一个可识别的值,比如null。然而该方式并不适合于此例,因为对于next,返回null 可能是合法的。
    对于状态测试方法和可识别的返回值,这两种设计方式在实际应用中如何选择:
(1)如果对象将在缺少同步的情况下被并发访问,或者可被外界改变状态,使用可识别返回值的方法是非常必要的,因为在测试状态(hasNext)和对应的调用(next)之间存在一个时间窗口,在该窗口中,对象可能会发生状态的变化。因此,在该种情况下应选择返回可识别的返回值的方式。
(2)如果状态测试方法(hasNext)必须重复相应的调用方法(next)的工作,出于性能上的考虑,就应该选择返回可识别的返回值的方式。
(3)对于其他情形则应该尽可能考虑使用状态测试方法的设计方式,因为它可以带来更好的可读性,对于使用不当的情形,可能更加易于检测和改正:如果忘了去调用状态测试方法,状态使用不当的情形,可能更加易于检测和改正;如果忘了去检查可识别的返回值,这个Bug就很难会被发现。

2. 对可恢复的情况使用受检异常,对编程错误使用运行时异常
    Java中提供了三种可抛出结构:受检异常、运行时异常和错误。受检异常强制要求程序员对其进行catch。运行时异常我们可以不处理,这样的异常由虚拟机接管,出现运行时异常后,系统会把异常一直往上抛,一直遇到处理代码,如果不对运行时异常进行处理,那么出现运行时异常之后,要么是线程中止,要么是主程序终止。错误如果发生,除了通知用户以及尽量稳妥地终止程序外,几乎什么也不能做。该条目针对这三种类型适用的场景给出了一般性原则:
(1)如果期望调用者能够适当地恢复,对于这种情况就应该使用受检异常,如某人打算网上购物,结果余额不足,此时可以抛出自定义的受检异常。通过抛出受检异常,将强迫调用者在catch 子句中处理该异常,或继续向上传播。因此,在方法中声明受检异常,是对API 用户的一种潜在提示。 
(2)用运行时异常来表明编程错误。大多数的运行时异常都表示"前提违例",即API 的使用者没有遵守API 设计者建立的使用约定。如数组访问越界等问题。 
(3)对于错误而言,通常是被JVM 保留用于表示资源不足、约束失败,或者其他使程序无法继续执行的条件。 
    针对自定义的受检异常,该条目还给出一个非常实用的技巧,当调用者捕获到该异常时,可以通过调用该自定义异常提供的接口方法,获取更为具体的错误信息,如当前余额等信息。

3. 避免不必要地使用受检的异常
    受检异常是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 子句中,没有做出任何用于恢复异常的动作。

4. 优先使用标准的异常
    使用标准异常,不仅可以更好的复用已有的代码,同时也使你设计的API 更加容易学习和使用,因为它和程序员已经熟悉的习惯用法更为一致。另外一个优势是,代码的可读性更好,程序员在阅读时不会出现更多的不熟悉的代码。该条目给出了一些非常常用且容易被复用的异常,见下表:
        异常                                       应用场合
        IllegalArgumentException    非null的参数值不正确;
        IllegalStateException    对于方法调用而言,对象状态不合适;
        NullPointerException    在禁止使用null 的情况下参数值为null;
        IndexOutOfBoundsException    下标参数值越界;
        ConcurrentModificationException    在禁止并发修改的情况下,检测到对象的并发修改;
        UnsupportedOperationException    对象不支持用户请求的方法;

    当然在Java 中还存在很多其他的异常,如ArithmeticException、NumberFormatException 等,这些异常均有各自的应用场合,然而需要说明的是,这些异常的应用场合在有的时候界限不是非常分明,至于该选择哪个比较合适,则更多的需要依赖上下文环境去判断。
    最后需要强调的是,一定要确保抛出异常的条件和该异常文档中描述的条件保持一致。

5. 抛出与抽象相对应的异常
    如果方法抛出的异常与它所执行的任务没有明显的关系,这种情形将会使人不知所措。特别是当异常从底层开始抛出时,如果在中间层没有做任何处理,这样底层的实现细节将会直接污染高层的API 接口。如果高层的实现在后续的发行版本中发生了变化,它所抛出的异常也可能会跟着发生变化,从而潜在的破坏现有的客户端程序。
    为了解决这个问题,更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法被称为异常转译。如下:
        try {
            doLowerLeverThings();
        } catch (LowerLevelException e) {
            throw new HigherLevelException(...);
        }
    在Java 中还提供了一种更为方便的转译形式--异常链。试想一下上面的示例代码,在调试阶段,如果高层应用逻辑可以获悉到底层实际产生异常的原因,那么对找到问题的根源将会是非常有帮助的,见如下代码:
        try {
            doLowerLevelThings();
        } catch (LowerLevelException cause) {
            throw new HigherLevelException(cause);
        }
    底层异常作为参数传递给了高层异常,对于大多数标准异常都支持异常链的构造器,如果没有,可以利用Throwable 的initCause 方法设置原因。异常链不仅让你可以通过接口函数getCause 访问原因,它还可以将原因的堆栈轨迹集成到更高层的异常中。
    尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是它也不能被滥用。如有可能,处理来自低层异常的最好做法是,在调用低层方法之前确保它们会成功执行,从而避免它们抛出异常。

6. 每个方法抛出的异常都要有文档
    始终要单独地声明受检的异常,并且利用Javadoc的@throws标记,准确地记录下抛出每个异常的条件。永远不要声明一个方法“throws Exception”,或者更糟糕的是声明它“throws Throwable”,这样的声明不仅没有为程序员提供关于“这个方法能够抛出哪些异常”的任何指导信息,而且大大地妨碍了该方法的使用,因为它实际上掩盖了该方法在同样的执行环境下可能抛出的任何其他异常。
    未受检的异常通常代表编程上的错误,让程序员了解这些错误都有助于帮助他们避免犯这样的错误,对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。每个方法的文档应该描述它的前提条件,这是很重要的,在文档中记录下未受检的异常是满足前提条件的最佳做法。
    使用Javadoc的@throws标签记录下一个方法可能抛出的每个未受检异常,但是不要使用throws关键字将未受检的异常包含在方法的声明中。
    如果一个类中的许多方法出于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文档,这是可以接受的,而不是为每个方法单独建立文档。

7. 在细节消息中包含能捕获失败的信息
    当程序由于未被捕获的异常而失败的时候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常的字符串表示法,即toString 方法的返回结果。它通常包含该异常的类名,紧随其后的是细节消息。如果我们在此时为该异常提供了详细的出错信息,那么对于错误定位和追根溯源都是极其有意义的。比如,我们将抛出异常的函数的输入参数和函数所在类的域字段值等信息格式化后,再打包传递给待抛出的异常对象。假设我们的高层应用捕捉到
IndexOutOfBoundsException 异常,如果此时该异常对象能够携带数组的下界和上界,以及当前越界的下标值等信息,在看到这些信息后,我们就能很快做出正确的判断并修订该Bug。
    虽然在异常的细节消息中包含所有相关的硬数据的非常重要的,但是包含大量的描述信息往往没有什么意义。
    为了确保在异常的细节消息中包含足够的能捕获失败的信息,一种办法是在异常的构造器而不是字符串细节消息中引入这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。如下,IndexOutOfBoundsException并不是有个String构造器,而是有个这样的构造器:
        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平台类库并没有广泛地使用这种方法,但是,这种做法仍然值得大力推荐。

8. 努力使失败保持原子性
    当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤为重要,因为调用者希望能从这种异常中进行恢复。一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有"失败原子性"。
    有以下几种途径可以保持这种原子性:
(1)最简单的方法是设计不可变对象。因为失败的操作只会导致新对象的创建失败,而不会影响已有的对象。
(2)对于可变对象,一般方法是在操作该对象之前先进行参数的有效性验证,这可以使对象在被修改之前,抛出更为有意义的异常,如:
        public Object pop() {
            if (size == 0)
                throw new EmptyStackException();
            Object result = elements[--size];
            elements[size] = null;
            return result;
        }
    如果没有在操作之前验证size,elements 的数组也会抛出异常,但是由于size 的值已经发生了变化,之后再继续使用该对象时将永远无法恢复到正常状态了。
(3)预先写好恢复性代码,在出现错误时执行带段代码,由于此方法在代码编写和代码维护的过程中,均会带来很大的维护开销,再加之效率相对较低,因此很少会使用该方法。
(4)为该对象创建一个临时的copy,一旦操作过程中出现异常,就用该复制对象重新初始化当前的对象的状态。

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

9. 不要忽略异常
    要忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块。这是一个显而易见的常识,但是经常会被违反:
        try {
            dosomething();
        } catch (SomeException e) {
        }
    可预见的、可以使用忽略异常的情形是在关闭FileInputStream 的时候,因为此时数据已经读取完毕。即便如此,如果在捕获到该异常时输出一条提示信息,这对于挖出一些潜在的问题也是非常有帮助的。否则一些潜在的问题将会一直隐藏下去,直到某一时刻突然爆发,以致造成难以弥补的后果。

你可能感兴趣的:(EffectiveJava笔记)