Walt You - 行是知之始

《Effective Java》学习日志(九)69:只在异常情况下使用异常

2019-01-06

在最常见的情况下,异常可以提高程序的可读性,可靠性和可维护性。 如果使用不当,可能会产生相反的效果。


学习资料主要参考: 《Effective Java Third Edition》,作者:Joshua Bloch



总有一天,如果你运气不好,你可能偶然发现一段看起来像这样的代码:

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

这段代码有什么作用? 从检查的角度来看,这一点并不明显。这里也有族的沟的理由不去使用它(第67项)。 事实证明,这是一个非常错误的习惯用语,用于循环遍历数组的元素。 无限循环在尝试访问数组边界外的第一个数组元素时抛出,捕获并忽略ArrayIndexOutOfBoundsException,从而终止。 它应该等同于循环数组的标准习惯用法,任何Java程序员都可以立即识别:

for (Mountain m : range)
	m.climb();

那么为什么有人会使用基于异常的循环而不是尝试和真实? 基于错误推理来提高性能是一种错误的尝试,因为VM检查所有数组访问的边界,正常的循环终止测试(由编译器隐藏但仍然存在于for-each循环中)是多余的,应该是避免。 这个推理有三个问题:

  • 因为异常是针对特殊情况而设计的,所以JVM实现者很少有动力使它们像显式测试一样快。
  • 将代码放在try-catch块中会禁止JVM实现可能执行的某些优化。
  • 循环数组的标准习惯用法不一定会导致冗余检查。 许多JVM实现会优化它们。

实际上,基于异常的习语远比标准习惯慢。在我的机器上,基于异常的习惯用法的速度大约是100个元素数组的标准速度的两倍。

基于异常的循环不仅会混淆代码的目的并降低其性能,而且不能保证它能够正常工作。如果循环中存在错误,则使用流控制异常可以掩盖错误,从而使调试过程变得非常复杂。假设循环体中的计算调用一个方法,该方法对某些不相关的数组执行越界访问。如果使用了合理的循环习惯用法,则该错误将生成未捕获的异常,从而导致使用完整堆栈跟踪立即终止线程。如果使用了误导的基于异常的循环,则会捕获与错误相关的异常并将其误解为正常的循环终止。

这个故事的寓意很简单:顾名思义,例外仅用于特殊情况;它们永远不应该用于普通的控制流程。更一般地说,使用标准的,易于识别的习语,而不是过于聪明的技术,旨在提供更好的性能。即使性能优势是真实的,它也可能不会继续面对稳定改进的平台实现。然而,过于聪明的技术带来的微妙错误和维护难题肯定会存在。

该原则也对API设计有影响。 精心设计的API不得强制其客户端使用普通控制流的异常。 具有“状态依赖”方法的类只能在某些不可预测的条件下调用,通常应该有一个单独的“状态测试”方法,指示调用依赖于状态的方法是否合适。

例如,Iterator接口接下来是状态依赖方法,相应的状态测试方法hasNext。 这使得标准习惯用于使用传统的for循环遍历集合(以及for-each循环,其中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) {
}

在开始此项的数组迭代示例之后,这看起来应该非常熟悉。除了冗长和误导之外,基于异常的循环可能表现不佳并且可以掩盖系统中不相关部分的错误。

总之,例外是针对特殊情况而设计的。不要将它们用于普通的控制流程,也不要编写强制其他人这样做的API。


Content