《Effective Java》学习笔记八——异常

《Effective Java》学习笔记

只针对异常的情况才使用异常

企图利用java的错误判断机制来提高性能是错误的:

  1. 因为异常机制的设计初衷是用于不正常的情形,所以很少会有JVM实现试图对它们进行优化,使得与显式的测试一样快速。

  2. 把代码放在try-catch块中反而阻止了现在JVM实现本来可能要执行的某些特定优化。

  3. 对数组进行遍历的标准模式并不会导致冗余的检查。有些现在的JVM实现会将它们优化掉。

异常应该只用于异常的情况下;它们永远不应该用于正常的流程控制。
设计良好的API不应该强迫它的客户端为了正常的控制流而使用异常。
“状态测试方法”和“可识别的返回值”:
如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,使用可被识别的返回值可能是很有必要的,因为在调用“测试状态”方法和调用对应的“状态相关”方法的时间间隔之中,对象的状态有可能发生变化。如果单独的“状态测试”方法必须重复“状态相关”方法的工作,从性能的角度考虑,就应该使用可被识别的返回值。如果所有其他方面都是等同的,那么“状态测试”方法则略犹豫可被识别的返回值。
总而言之,异常是为了在异常情况下使用而设计的。不要将它们用于普通的控制流,也不要编写迫使他们这么做的API。

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

如果期望调用者能够适当地恢复,对于这种情况就应该使用受检的异常。通过抛出受检的异常,强迫调用者在一个catch子句中处理该异常,或者将它传播出去。

用运行时异常来表明编程错误。你实现的所含有未受检的抛出结构都应该是RuntimeException的子类。

避免不必要地使用受检的异常

受检的异常是Java设计语言的一项很好的特性。与返回代码不同,他们强迫程序员处理异常的条件,大大增强了可靠性。也就是说,过分使用受检的异常会使API使用起来非常不方便。如果方法抛出一个或者多个受检的异常,或者他必须声明他抛出这些异常,并让他们传播出去。无论哪种方法,都给程序员增添了不可忽视的负担。

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

在实践中,catch块几乎总是具有断言失败的特征。异常受检的本质并没有为程序员提供任何好处,他反而需要付出努力,还使程序更为复杂。

被一个方法单独抛出的受检异常,会给程序员带来非常高的额外负担。如果这个方法还有其他的受检异常,他被调用的时候一定已经出现在一个try块中,所以这个异常只需要另外一个catch块。如果方法只抛出单个受检的异常,仅仅一个异常就会导致该方法不得不外于try块中,在这些情况下,应该问自己,是否有别的途径来避免使用受检的异常。

“把受检的异常编程未受检的异常”的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个boolean,表明是否应该抛出异常。

优先使用标准的异常

专家级程序员与缺乏经验的程序员一个最主要的区别在于,专家追求并且通常也能够实现高度的代码重用。代码重用是值得提倡的,这是一条通用的规则,异常也不例外。Java平台类库提供了一组基本的未受检的异常,他们满足了绝大多数API的异常抛出需要。

重用现有的异常有多方面的好处。其中最主要的好处是,他使你的API更加易于学习和使用,因为他与程序员已经熟悉的习惯用法是一致的。第二个好处是,对于用到这些API的程序而言,他们的可读性会更好,因为他们不会出现很多程序员不熟悉的异常。最后(也是最不重要的)一点是,异常类越少,意味着内存印迹就越小,装载这些类的时间开销也越少。

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

抛出与抽象相对应的异常

如果方法抛出的异常与他所执行的任务没有明显的联系,这种情形将会使人不知所措。当方法传递由底层抽象抛出的异常时,往往会发生这种情况。除了使人感到困惑之外,这也让实现细节污染了更高层的API。如果高层的实现在后续的发行版本中发生了变化,他所抛出的异常也可能会跟着发生变化,从而潜在的破坏现有的客户端程序。

为了避免这个问题,更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法被称为异常转译。

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

高层异常的构造器将原因传到支持连的超级构造器,因此他最终将被传给Throwable的其中一个运行异常链的构造器。

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

尽管异常转移与不加选择的从低层传递异常的做法相比有所改进,但是他也不能被滥用。如有可能,处理来自低层异常的最好做法是,在调用低层方法之前确保他们会成功执行,从而避免他们抛出异常。有时候,可以在给低层传递参数之前,检查更高层方法的参数的有效性,从而避免低层方法抛出异常。

如果无法避免低层异常,次选方案是,让更高层来悄悄地绕开这些异常,从而将高层方法的调用者与低层的问题隔离开来。在这种情况下,可以用某种适当的记录机制(如java.util.logging)将异常记录下来。这样有助于管理员调查问题,同时又将客户端代码和最终用户与问题隔离开来。

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

每个方法抛出的异常都要有文档

始终要单独地声明受检的异常,并且利用Javadoc的@throws标记,准确的记录下抛出每个异常的条件。如果一个方法可以抛出多个异常类,则不要使用“快捷方式”声明它会抛出这些异常类的某个超类。永远不要声明一个方法“throws Exception”,或者更糟糕的是声明它“throws Throwable”,因为它实际上掩盖了该方法在同样的执行环境下可能抛出的任何其他异常。
对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件(precondition)。每个方法的文档应该描述它的前提条件,这是很重要的,在文档中记录下未受检的异常就是满足前提条件的最佳做法。
使用Javadoc的@throws便签记录下一个方法可能抛出的每个未受检异常,但是不要使用throws关键字讲未受检的异常包含在方法的声明中。
如果一个类中的许多方法处于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文旦,这是可以接受的,而不是为每个方法单独建立文档。
总而言之,要为你变得每个方法所能抛出的每个异常建立文档。对于未受检和受检的异常,以及对于抽象的和具体的方法也都一样。要为每个受检异常提供单独的throws子句,不要为未受检的异常提供throws子句。如果没有为可以抛出的异常建立文档,其他人就很难或者根本不可能有效地使用你的类和接口。

在细节消息中包含能捕获失败的信息

当程序由于未被捕获的异常而失败的时候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常的字符串表示法(string representation),即它的toString方法的调用结果。它通常包含该异常的类名,紧随其后的是细节消息(detail message)。通常,这只是程序员或者域服务人员(field service personnel,指检查软件失败的人)在调查软件失败原因时必须检查的信息。如果失败的情形不容易重现,要想获得更多的信息会非常困难,甚至是不可能的。因此,异常类型的toString方法应该尽可能多地返回有关失败原因的信息,这一点特别重要。换句话说,异常的细节消息应该捕获住失败,便于以后分析。
为了捕获失败,异常的细节信息应该包含所有“对该异常有贡献”的参数和域的值。例如,IndexOutOfBoundsException异常的细节消息应该包含下界、上界以及没有落在界内的下标值。该细节消息提供了许多关于失败的信息。这三个值中任何一个或者全部都有可能是错的。实标的下标值可能小于下界或等于上界(“越界错误”),或者它可能是个无效值,太小或太大。下界也有可能大于上界(严重违反内部约束条件的一种情况)。每一种情形都代表了不同的问题,如果程序员知道应该去查找哪种错误,就可以极大地加速诊断过程。
虽然在异常的细节消息中包含所有相关的“硬数据(hard data)”是非常重要的,但是包含大量的描述信息往往没有什么意义。堆栈轨迹的用途是与源文件结合起来进行分析,它通常包含抛出该异常的确切文件和行数,以及堆栈中所有其他方法调用所在的文件和行数。关于失败的冗长描述信息通常是不必要的,这些信息可以通过阅读源代码而获得。
异常的细节消息不应该与“用户层次的错误消息”混为一谈,后者对于最终用户而言必须是可理解的。与用户层次的错误消息不同,异常的字符串表示法主要是让程序员或者域服务人员用来分析失败的原因。因此,信息的内容比可理解性要重要得多。
为了确保在异常的细节消息中包含足够的能捕获失败的信息,一种办法是在异常的构造器而不是字符串细节消息中引入这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。

努力使失败保持原子性

这是我们需要遵守的规则:

  1. 失败的方法调用应该使对象保持在被调用之前的状态。
  2. 错误通常是不可恢复的,当方法抛出错误时,不需要保持原子性。
  3. 作为方法规范的一部分,方法产生的任何异常都应该让对象保持在该方法调用之前的状态。如果违反这条规则,API文档就应该清楚地指明对象将会处于什么样的状态。

失败原子性实现方法:

  1. 对象为不可变对象,那么对象创建出来就不能被修改了,也不需要维护。

  2. 在执行操作之前检查参数的有效性。在对象状态被修改之前,先抛出异常。

  3. 调整计算处理的过程,使得任何可能会失败的计算部分都在对象状态被修改之前发生。

  4. 编写恢复代码,由其拦截操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。这样做并不提倡,因为错误代码编写遇到复杂的场景会很繁琐。

  5. 在对象的一份临时拷贝上执行操作,操作完成后,在使用临时拷贝的的结果代替对象的内容。也就是备份操作。

对于以上五种方法,我们更推荐前3种,我们应该先考虑防患于未然,才考虑如何错误恢复。对于大型项目,错误恢复也是可用性重要的战术之一。

不要忽略异常

当API的设计者声明一个方法将抛出某个异常的时候,他们等于正在试图说明某些事情。所以,请不要忽略他!要忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块。
空的catch块会使异常达不到应有的目的,即强迫你处理异常的情况。忽略异常就如同忽略火警信号一样——若把火警信号器关掉了,当真正的火灾发生时,就没有人能看到火警信号了。或许你会侥幸逃过劫难,或许结果将是灾难性的。每当见到空的catch时,应该警钟长鸣。至少,catch块也应该包含一条说明,解释为什么可以忽略这个异常。
有一种情形可以忽略异常,即关闭FileInputStream的时候。因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息,因此不必终止正在进行的操作。即使在这种情况下,把异常记录下来还是明智的做法,因为如果这些异常经常发生,你就可以调查异常的原因。
本文建议同样适用于受检异常和未受检异常。不管异常代表了可预见的异常条件,还是编程错误,用空的catch块忽略他。将会导致程序在遇到错误的情况下悄然的执行下去。然后,有可能在将来的某个点上,当程序不能再容忍与错误源明显相关的问题时,他就会失败。正确的处理异常能够彻底挽回始变。只要将异常传播给外界,至少会导致程序迅速的失败,从而保留了有助于调试该失败条件的信息。