引言
异常机制我们再熟悉不过,在开发环境中,它非常有用。它能帮我们的程序从错锁中恢复,也能在程序奔溃的时候方便我们定位异常原因。但大家在生产环境中会不会有这种感觉:这个异常究竟应该catch处理掉呢,还是应该抛给调用方?底层抛出来的异常,究竟在哪一层处理比较好?应该抛出什么异常? 尽管我们会想,属于用户责任就应该抛给用户,但这里的边界,实际开发中很难界定。本文来回答这个问题,并且会给出异常机制相关的最佳实践。 回忆一下异常的知识。
其中:
- Error是严重的错误,例如内存耗尽,栈溢出等等,它属于非受检异常
- RuntimeException是运行时异常,它也是非受检异常。它的子类非常常用,也非常丰富,例如非法参数异常,空指针异常等等
- 其他的就属于受检异常了,例如常见的IO,文件异常等
69. 异常只用于异常情况
异常是为了异常情况而设计的,不用将它们用于控制流,尽量不要强迫API用户为了正常控制流程而使用异常。
为什么呢?主要是性能问题:
- 异常就是为少数异常情况设计的,所以JVM并不会有太多优化
- try-catch的性能比较差,而且会阻止优化
- 试图用异常来减少流程控制的检查开销是没有必要的,因为后者加持优化后,不一定需要大量检查,总之性能要好的多
异常不要用于流程控制
来看一个案例,代码试图使用异常机制来减少每次循环的检查,但这种完全是南辕北辙。
try {
int i = 0;
while(true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
}
for (Mountain m : range)
m.climb();
避免非必要受检异常的方法
本建议的后半句,“不要强迫API用户为了正常控制流程而使用异常”,需要一个正面的案例来说明:
for (Iterator<Foo> i = collection.iterator(); i.hasNext(); ) {
Foo foo = i.next();
...
}
try {
Iterator<Foo> i = collection.iterator();
while(true) {
Foo foo = i.next();
... }
} catch (NoSuchElementException e) {
}
Iterator中会抛出NoSuchElementException(未受检异常,这里的用法可以把它视为受检异常),但是它提供了状态测试方法,来避免上述代码中后者的使用方式——强迫API用户通过异常来控制流程。 这也引出了转化避免受检异常的方法。
状态测试和optional
要么提供状态测试的接口,要么optional返回可识别结果(例如空optional)。
- 状态测试的缺点是并发情况下,可能引起测试和执行不一致的问题,因为测试和执行不是原子的,但它提供了稍微更好但可读性
- optional的缺点是如果忘记处理某些情况,可能引起隐秘的bug。
至于受检异常是用于什么情况?为什么要尽量转化掉受检异常?什么时候应该使用受检异常?后续内容进行详细说明。
70. 可恢复错误使用受检异常,编程错误使用运行时异常
对于可恢复的情况,要抛出受检异常;对于程序错误,要抛出运行时异常。不确定是否可恢复,则抛出未受检异常。 不要定义任何非受检也非不受检的异常。 受检异常上可以提供方法、传递信息,帮助恢复。
尽管话是这么说,实际可恢复的异常少之又少,大部分情况下,我们都会使用非受检异常,也会尽可能转换掉受检异常。
受检异常
受检异常应该在期望可恢复的情况,而且它需要携带重要的信息,才需要使用。它潜在地指出,该异常是该调用的一种可能的结果,必须由API用户恢复。 举个例子,IOException,以及它的子类。操作IO流的过程中,会产生多种错误,需要由API用户来确定处理方法。 至于为何要避免受检异常,后文会继续展开。
运行时异常
**运行时异常是非受检异常的一种,它指出API使用过程中的编程错误,或者说违背了API的规范。**典型的例子例如ArrayIndexOutOfBoundsException,IllegalArgumentException,NullPointerException等。 运行时异常非常常用,最佳实践后文会详细展开。
不要自定义抛出类型
Java并没有规定抛出的结构和类型,什么时候应该自定义抛出结构呢?答案是,永远不要。准照编程惯例对于程序的可读性,可维护性都会有帮助。
71. 避免没必要的使用受检异常
受检异常可以提升可读性,但是不必要的使用会给API用户造成负担 如果调用者无法恢复失败,或者受检异常可以转化,那就不要使用受检异常
为什么不建议使用受检异常呢? 原因有两个,一方面是对于API的用户造成负担——为了这个受检异常,客户端必须try-catch。而如果调用者无法恢复异常,那么抛出受检异常就是没有必要的。
} catch (TheCheckedException e) {
throw new AssertionError();
}
} catch (TheCheckedException e) {
e.printStackTrace();
System.exit(1);
}
另一方面,受检异常有着严格的用法:
- 受检异常意味着这种异常是方法调用的一种结果,必须由API调用者负责
- API调用者可以恢复
- 需要特定的异常信息
本条建议还推荐利用状态信息和optional替换异常。
72. 优先使用标准的异常
遵照Java的异常机制。 不要直接充用Exception,RuntimeException,Throwable或Error。
对于前者,有三个原因:
- 代码重用,通用规则
- 易于学习和使用
- 易读性
- 异常更少,内存占用更少(footprint,可能是JVM某种对于异常的某种内存开销)
对于后者,原因在于他不能携带足够的信息——本来是可以携带的,你无法可靠地测试这些异常。 Java本身提供的异常大部分情况下可以覆盖所有场景,因为事实上,所有的错误方法调用都可以被归结为非法参数或非法状态。例如,常见的空指针异常,实际上也可说是传入参数不能为空;例如索引越界异常,可以说是索引参数错误。 尽管如此,在特定的场景还是尽可能使用特定的异常类(以下都是非受检异常)。
**Exception ** | **Occasion for Use ** |
---|
IllegalArgumentException | 非法参数异常,正如上面所说,非常常用 | IllegalStateException | 状态异常,指对象本身状态异常,例如对象未初始化完全 | NullPointerException | 空指针异常,常用 | IndexOutOfBoundsException | 数组越界异常 | ConcurrentModificationException | 在禁止并发修改的情况下,检测到对象的并发修改。但这个异常是一种提示,不可能可靠地检测到并发的修改 | UnsupportedOperationException | 非法操作异常 |
73. 抛出与抽象对应的异常
如果有可能,面对底层的异常最佳实践是,在调用之前确保它们会执行成功,避免底层抛出异常 如果无法避免,高层实现应该捕获底层异常,同时进行异常转译(异常链),抛出复合高层抽象的异常
为什么? 如果抛出的异常和它高层执行的任务没有明显联系,这种情形会让人非常困惑。同时也会“污染”高层的API,如果之前的底层异常又没有捕获,高层API发生变化(对应需要处理底层异常,抛出新的异常),那可能潜在地破坏客户端的代码。 考虑在生成环境中出现异常,作为开发者可以利用异常信息进行搜索,但是作为使用者,你完全无法从异常信息看出实际的问题是什么。
避免异常和异常转译
所以,最佳的实现是,在高层API调用底层时,处理异常对应的问题,确保底层调用成功。 如果无法避免底层异常,那么应该在高层捕获,使用异常转译,异常链是一种好方式。
try {
...
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
它允许抛出适当的高层异常,有可能捕获底层异常的堆栈信息。 如果异常没有提供异常链的构造方法,那么可以使用超类Throwable中自带的initCause(Throwable cause) 方法设置原因。
74. 为每个方法抛出的所有异常建立文档
始终单独地声明受检异常(指方法签名),不要使用快捷方式:“Exception”,“Throwable”。 为受检异常建立文档。
原因是快捷方式的声明不仅没有为“方法会抛出什么异常”提供指导信息,而且可能吞掉方法中其他可能出现的异常。
为方法所有的非受检异常建立文档,声明异常抛出条件,但不要在方法声明处抛出
为什么呢? 首先非受检异常代表程序编程上的错误,让程序员了解这些有助于避免错误。对于接口来说更为重要,这些文档构成了该接口的通用约定,指定了它的实现必须遵守的规则。 不要在方法声明处抛出非受检异常的是为了让开发者更好地区分受检和非受检异常,知道哪些是开发者应该恢复的,哪些是应该注意的。
为类中同一种异常建立类文档是可以接受的。
例如空指针异常。
75. 异常的细节信息设计
为了捕获失败的原因,异常的细节信息应该包含“对该异常有贡献”值和参数。 需要的时候,利用异常的构造器为这些值提供方便的接口和清晰的描述 对于异常信息来说,信息的内容比可读性更重要。
我为什么通过异常的细节信息捕获失败的原因非常重要呢? 原因是在生产环境中很多失败都不好重现,甚至不能重现,有的甚至只能在测试环境追溯异常。所以通过异常细节信息,恰当、全面地描述异常就非常关键。例如在IndexOutOfBoundsException的时候,不仅返回当前index,还包括上下届的信息。 一种比较好的实践是利用构造函数,给这些值提供合适的接口,它把异常信息的细节放在异常类里面,而不需要每个用户自己去做细节实现。
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
super(String.format(
"Lower bound: %d, Upper bound: %d, Index: %d",
lowerBound, upperBound, index));
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
另外一点,对于异常的细节信息来说,应该和用户层面的信息区分开,前者主要是面向开发者,它的责任是让开发者获得足够信息分析失败的原因。
76. 努力使失败保持原子性
作为方法规范的一部分,它产生的任何异常都应该让对象保持在调用之前的状态 否则应该尽可能地用API文档说明会处于什么状态,以及为什么
为什么这会是一种方法规范呢? 我的理解是,这个对象在x状态下,调用这个方法出现了异常。保存调用方法之前的状态,那么这个失败就是原子的,不会引起更多连锁的错误,而且也方便开发者排查。
让失败保持原子性
- 对于不可变对象,失败的原子性是必然的。因为它从初始化后就不会再改变,因而异常时还是保持原有的状态。
- 对于可变对象
- 可以尝试在调用方法之前执行参数检查
- 重新调整计算顺序——在改变内部对象之前先判断是否正确。例如,一些有序集合,插入的元素会提前判断能否比较
- 临时拷贝一份,当然这种开销很大
- 回滚,这种通常需要持久化的帮助
我们来看提前判断的案例:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}
这种情况下,如果无法pop,会抛出异常,对象的状态还是保持正常(指针不会出现负数的情况)。 此外,该实现的另一个优点是,对于高层抽象(栈)来说,抛出IndexOutOfBoundsException是不合理的。
77. 不要忽略异常
不要用空catch忽略异常 如果必须忽略,必须在注释中说明为什么可以这么做,异常应该命名为ignored
Future<Integer> f = exec.submit(planarMap::chromaticNumber); int numColors = 4;
try {
numColors = f.get(1L, TimeUnit.SECONDS);
} catch (TimeoutException | ExecutionException ignored) {
}
catch忽略异常,或许你会侥幸逃过一劫,但结果可能是灾难性但。因为当出现异常的时候,可能会被catch悄悄吞掉,你永远无法找到原因。所以每当遇到空的catch块时,就应该让警钟长鸣。
总结
回过头来总结一下受检异常和非受检异常的使用。并且回答引言提出的问题:
- 很少需要抛出受检异常。
- 只有异常属于调用方法的一种结果,上一层有责任可以恢复错误,并且需要携带更多异常信息的时候,才选择将受检异常,抛给上层处理。而且,抛给上层的受检异常,要符合高层的抽象,否则进行异常转译(异常链)。
- 可以考虑利用状态测试方法,或者optional,在本层catch处理掉受检异常
- 多数时候抛出非受检异常。抛出非受检异常,这样就不用考虑在哪一层catch,是否进行异常转译的问题了
- 底层有非受检异常抛出,会导致程序中断,而非受检异常通常又不应该catch,这时候应该在高层就保证底层调用正确,例如进行参数校验
|