我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。
最后想分享一下我个人目前的看法,内功修炼不像学习一个新的工具那么简单,其主旨在于踏实,深入探索底层原理的过程很缓慢并且是艰辛的,但一旦开悟,修为一定会突破瓶颈,达到更高的境界,这远远不是我通过一两篇博客就能学到的东西。
接下来就针对此书列举一下我的收获与思考。
不过还是要吐槽一下的是翻译版属实让人一言难尽,有些地方会有误导的效果,你比如java语言里extends是继承的关键字,书本中全部翻译成了扩展 就完全不是原来的意思了。所以建议有问题的地方对照英文原版进行语义上的理解。
没有时间读原作的同学可以参考我这篇文章。
69 只针对异常的情况才使用异常
try { int i = 0; while ( true ) range[i++].climb(); } catch ( ArrayIndexOutOfBoundsException e ) { }
当这个循环企图访问数组边界之外的第一个数组元素的时候,使用try-catch并且忽略ArrayIndexOutOfBoundsException异常的手段来达到终止循环的目的。
对于大多数程序员来说,下面的标准模式可读性就很高:
for ( Mountain m : range ) m.climb();
第一串代码企图使用Java的错误判断机制来提高程序性能,因为VM对每次数组访问都要检查越界情况,这种想法有三个错误:
因为异常设计的初衷适用于不正常的情形,所有几乎没有JVM实现试图对他们进行优化
把代码放在try-catch块中反而阻止了现代JVM实现本可能执行的某些特定优化
对数据进行标准的for遍历并不会导致冗余的检查
实际上基于异常的模式比标准模式要慢得多
异常应该只用于异常的情况下;他们永远不应该用于正常的程序控制流程。
设计良好的API不应该强迫它的客户端为了正常的控制流程而使用异常,如果类中具有「状态相关」(state-dependent)的方法,这个类也应该具有一个单独的「状态测试」(state-testing)方法,即表明是否可以调用这个状态相关的方法。比如Iterator接口含有状态相关的next方法,以及相应的状态测试方法hasNext。
for ( Iterator<Foo> i = collection.iterator(); i.hasNext(); ){ Foo foo = i.next(); ... }
如果Iterator缺少hasNext方法,客户端将被迫改用下面的做法:
try { Iterator<Foo> i = collection.iterator(); while ( true ){ Foo foo = i.next(); ... } } catch ( NoSuchElementException e ) {}
另外一种提供单独状态测试的做法是,如果「状态相关」方法无法执行想要的计算,就可以让它返回一个零⻓度的optional值,或者返回一个可识别的null。
如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,就必须使用「optional或者可识别的null」
如果单独的「状态测试」方法必须重复「状态相关」方法的工作,从性能的⻆度考虑,就必须使用可被识别的返回值
其他情况,「状态测试」方法优于可被识别的null
70 对可恢复的情况使用受检异常,对编程错误使用运行时异常
Java 程序设计语言提供了三种 throwable:受检异常(checked exceptions)、运行时异常(runtime exceptions)和错误(errors)。对于什么情况适合使用哪种 throwable,是有一些一般性的原则提出了强有力的指导:
如果期望调用者能够合理的恢复程序运行,对于这种情况就应该使用受检异常。通过抛出受检异常,强迫调用者在一个 catch 子句中处理该异常,或者把它传播出去。因此,方法中声明要抛出的每个受检异常都是对 API 用户的一个潜在提示:与异常相关联的条件是调用这个方法一种可能结果。
运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的 throwable。
用运行时异常来表明编程错误。大多数运行时异常都表示 API 的客户没有遵守 API 规范建立的约定,例如数组越界抛ArrayIndexOutOfBoundsException。
考虑资源枯竭的情形,这可能是由程序错误引起的,比如分配了一块不合理的过大数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大造成的,这种情况可能是可恢复的。API 设计者需要判断这样的资源枯竭是否允许恢复。如果你相信一种情况可能允许回复,就使用受检异常;如果不是,则使用运行时异常。如果不清楚是否有可能恢复,最好使用非受检异常。
好不要实现任何新的 Error 的子类,实现的所有非受检的 throwable 都应该是RuntimeExceptiond子类,也不应该抛出AssertionError异常。
因为受检异常往往指明了可恢复的条件,所以对于这样的异常,提供一些辅助方法尤其重要,通过这种方法调用者可以获得一些有助于程序恢复的信息。例如,假设因为用户资金不足,当他企图购买一张礼品卡时导致失败,于是抛出受检异常。这个异常应该提供一个访问方法,以便允许客户查询所缺的费用金额。
71 避免不必要的使用受检异常
如果调用者无法恢复失败,就应该抛出未受检异常。如果可以恢复,并且想要迫使调用者处理异常的条件,首选应该返回一个optional值。当且仅当万一失败时,这些无法提供足够的信息,才应该抛出受检异常。
受检异常强迫程序员处理异常的条件,大大增强了可靠性。过分使用受检异常会使API使用起来非常不方便。如果方法抛出受检异常,调用该方法代码就必须在catch块中处理,或者抛出异常。
这种负担在Java 8中更重了,因为抛出受检异常的方法不能直接在Stream中使用。
除非下面两种情况同时成立,否则更适合使用未受检异常:
正确地使用API并不能阻止这种异常条件的产生
一旦产生异常,程序员可以立即采取有效动作
作为一个石蕊测试,你可以试着问自己:程序员将如何处理该异常。下面的做法是最好的吗?
} catch ( TheCheckedException e ) { throw new AssertionError(); /* Can't happen! */ }
下面这种做法又如何?
} catch ( TheCheckedException e ) { e.printStackTrace(); /* Oh well, we lose. */ System.exit( 1 ); }
如果觉得上面两种方式都不行的话,还是采用未受检的异常可能更合适。
石蕊测试:简单而具有决定性的测试
如果方法抛出的受检异常是唯一的,它给程序员带来的额外负担就会非常高:该方法必须放置于一个try块中,并且不能在Stream里用。
消除受检异常最容易的方法是,返回所要的结果类型的一个optional,但缺点是方法无法返回任何额外的详细信息
「把受检异常变成未受检异常」的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个boolean值,表明是否应该抛出异常。例如:
try { obj.action( args ); } catch ( TheCheckedException e ) { ... /* Handle exceptional condition */ }
被重构为:
if ( obj.actionPermitted( args ) ) { obj.action( args ); } else { ... /* Handle exceptional condition */ }
如果程序员知道调用将会成功,或者不介意由于调用失败而导致的线程终止,这种重构还允许以下这个更为简单的调用形式:
obj.action(args);
如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,这种重构就是不恰当的,因为在actionPermitted和action这两个调用的时间间隔之中,对象的状态有可能会发生变化。
72 优先使用标准的异常
Java平台类库提供了一组基本的未受检异常,它们满足了绝大多数API的异常抛出需求。
重用标准的异常有多个好处:
使API更易于学习和使用
对于用到这些API程序而言,它们的可读性会更好
异常类越少,意味着内存占用(footprint)就越小,装载这些类的时间开销也越少
以下是四种常用的异常:
IllegalArgumentException:
当调用者传递的参数值不合适的时候,往往就会抛出这个异常。比如,假设某一个参数代表了“某个动作的重复次数”,如果程序员给这个参数传递了一个负数,就会抛出这个异常。
IllegalStateException:
如果因为接收对象的状态而使调用非法,通常就会抛出这个异常。例如,如果在某个对象被正确地初始化之前,调用者就企图使用这个对象,就会抛出这个异常。
ConcurrentModificationException:
如果检测到一个专⻔设计用于单线程的对象,或者与外部同步机制配合使用的对象正在(或已经)被并发地修改,就应该抛出这个异常。
UnsupportedOperationException:
如果对象不支持所请求的操作,就会抛出这个异常。
不要直接重用Exception、RuntimeException、Throwable或者Error,对待这些类要像对待抽象类
一样。
如果希望稍微增加更多的失败一捕获(failure-capture)信息,可以放心地子类化标准异常,但要记住异常是可序列化的
73 抛出与抽象对应的异常
更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法称为异常转译(exception translation),如下代码所示:
try { ... /* Use lower-level abstraction to do our bidding */ } catch (LowerLevelException e) { throw new HigherLevelException(...); }
下面的异常转译例子取自于AbstractSequentialList类,该类是List接口的一个⻣架实现(skeletal implementation)
/** * 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方法)来获得低层的异常:
// Exception Chaining try { ... // Use lower-level abstraction to do our bidding } catch (LowerLevelException cause) { throw new HigherLevelException(cause); }
高层异常的构造器将原因传到支持链(chaining-aware)的超级构造器,因此它最终将被传给Throwable的其中一个运行异常链的构造器,例如Throwable(Throwable t) :
/* Exception with chaining-aware constructor */ class HigherLevelException extends Exception { HigherLevelException( Throwable cause ) { super(cause); } }
对于没有支持链的异常,可以利用Throwable的initCause方法设置原因。异常链不仅让你可以通过程序(用getCause)访问原因,还可以将原因的堆战轨迹集成到更高层的异常中。
尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是也不能滥用它。
处理来自底层的异常通常有两种方法:
(推荐)在调用低层方法之前确保它们会成功执行,可以在给低层传递参数之前,检查更高层方法的参数的有效性
让更高层来悄悄地处理这些异常,从而将高层方法的调用者与低层的问题隔离开来。可以用某种适当的记录机制(如java.util.logging)将异常记录下来。这样有助于管理员调查问题,同时又将客户端代码和最终用户与问题隔离开来。
74 每个方法抛出的异常都需要创建文档
始终要单独地声明受检异常,并且利用Javadoc的@throws标签,准确地记录下抛出每个异常的条件。
如果一个公有方法可能抛出多个异常类,则不要声明它会抛出这些异常类的某个超类。
这条有一个例外:main方法它可以被安全地声明抛出Exception,因为它只通过虚拟机调用。
对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。
对于接口中的方法,在文档中记录下它可能抛出的未受检异常显得尤为重要。这份文档构成了该接口的通用约定(general contract)的一部分,它指定了该接口的多个实现必须遵循的公共行为。
使用Javadoc的@throws标签记录下一个方法可能抛出的每个未受检异常,但是不要使用throws关键字将未受检的异常包含在方法的声明中
如果一个类中的许多方法出于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文档,这是可以接受的。
一个常⻅的例子是NullPointerException。若类的文档注释中有这样的描述:「All methods in this class throw a NullPointerException if a null object reference is passed in any parameter」
75 在细节消息中包含失败 - 捕获信息
当程序由于未被捕获的异常而失败的时候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常的字符串表示法(string representation),即它的toString方法的调用结果。它通常包含该异常的类名,紧随其后的是细节消息(detail message)。
为了捕获失败,异常的细节信息应该包含“与该异常有关”的所有参数和字段的值。例如,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 保持失败的原子性
一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有失败原子性(failure atomic)。
有几种途径可以实现这种效果:
1. 设计一个不可变的对象
因为当每个对象被创建之后它就处于一致的状态之中,以后也不会再发生变化。
2. 对于可变对象,在执行操作之前检查参数的有效性
public Object pop() { if ( size == 0 ) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; /* Eliminate obsolete reference */ return(result); }
3. 调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生
以 TreeMap 的情形为例,它的元素被按照某种特定的顺序做了排序。为了向 TreeMap 中添加元素,该元素的类型就必须是可以利用 TreeMap 的排序准则与其他元素进行比较的。如果企图增加类型不正确的元素,在 tree 以任何方式被修改之前,自然会导致 ClassCastException 异常。
4. 在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内容
5. 编写一段恢复代码(recovery code)
由它来拦截操作过程中发生的失败,以及便对象回滚到操作开始之前的状态上。这种办法主要用于永久性的(基于磁盘的)数据结构。
如果两个线程企图在没有适当的同步机制的情况下,并发地修改同一个对象,这个对象就有可能处在在不一致的状态中。因此,不能在捕获了ConcurrentModificationException异常之后再假设对象仍然是可用的。错误通常是不可恢复的,所以,当方法抛出 AssertionError 时,不需要再去保持失败原子性。
77 不要忽略异常
要忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块:
try { ... } catch ( SomeException e ) { }
空的catch块会使异常达不到应有的目的,每当⻅到空的catch块时,应该警惕。
有些情形可以忽略异常。比如,关闭FileinputStream 的时候。因为你还没有改变文件的状态,因此不必执行任何恢复动作,即使在这种情况下,把异常记录下来还是明智的做法,可以调查异常的原因。如果选择忽略异常,catch块中应该包含一条注释,说明为什么可以这么做,并且变量应该命名为ignored:
Future<Integer> f = exec.submit(planarMap::chromaticNumber); int numColors = 4; // Default: guaranteed sufficient for any map try { numColors = f.get( 1L, TimeUnit.SECONDS ); } catch ( TimeoutException | ExecutionException ignored ) { // Use default: minimal coloring is desirable, not required }