10.1 为什么异常情况会增加复杂性
我使用术语“异常(exception)”来指代任何改变程序中正常控制流的不常见情况。许多编程语言都包括一个正式的异常机制,允许低级别的代码抛出异常,并由外围的代码捕获。然而,即使不使用正式的异常报告机制,也会发生异常,例如当一个方法返回一个特殊的值,表明它没有完成其正常行为。所有这些形式的异常都会增加复杂性。
一段特定的代码可能会以几种不同的方式遇到异常:
调用者可能提供了错误的参数或配置信息。
被调用的方法可能无法完成请求的操作。例如,I/O操作可能会失败,或者所需的资源可能不可用。
在一个分布式系统中,网络数据包可能会丢失或延迟,服务器可能无法及时响应,或者对端可能会以意想不到的方式进行通信。
代码可能会检测到bug、内部不一致,或者它没有准备好处理的情况。
大型系统必须处理许多异常情况,特别是如果它们是分布式的或需要容错的。异常处理可以占到系统中所有代码的很大一部分。
异常处理代码在本质上比正常情况下的代码更难写。异常扰乱了代码的正常流程;它通常意味着某些东西没有按照预期工作。当异常发生时,程序员可以用两种方法处理它,每一种方法都可能很复杂。第一种方法是不顾异常,继续前进,完成正在进行的工作。例如,如果一个网络数据包丢失了,可以重新发送;如果数据被破坏了,也许可以从一个冗余的拷贝中恢复过来。第二种方法是中止正在进行的操作并向上报告异常。然而,中止操作会很复杂,因为异常可能发生在系统状态不一致的地方(数据结构可能被部分初始化);异常处理代码必须恢复一致性,比如通过回退异常发生前的任何改变。
此外,异常处理代码为更多的异常创造了机会。考虑一下重新发送一个丢失的网络数据包的情况。也许这个数据包实际上并没有丢失,而只是被延迟了。在这种情况下,重新发送数据包会导致重复的数据包到达对端;这就引入了对端必须处理的一个新异常情况。或者,考虑从冗余副本中恢复丢失数据的情况:如果冗余副本也丢失了怎么办?在恢复过程中发生的次要异常往往比主要异常更微妙和复杂。如果一个异常是通过中止正在进行的操作来处理的,那么必须将其作为另一个异常报告给调用者。为了防止无休止的异常级联,开发者最终必须找到一种方法来处理异常而不引入更多的异常。
语言对异常的支持往往是冗长和笨重的,这使得异常处理代码难以阅读。例如,考虑下面的代码,它使用Java对对象序列化和反序列化的支持从文件中读取一个推文集合:
仅仅是基本的try-catch
样板代码所占的代码行数就比正常情况下的操作代码多,甚至没有考虑实际处理异常的代码。很难将处理异常的代码与正常情况下的代码联系起来:例如,每个异常是在哪里产生的并不明显。另一种方法是将代码分解成许多不同的try
块;在极端情况下,每一行可能产生异常的代码都有一个try
。这将使异常发生的地方变得很清楚,但try
块本身会破坏代码的流程,使其更难阅读;此外,一些异常处理代码可能最终会在多个try
块中重复出现。
很难确保异常处理代码真的能奏效。有些异常,例如I/O错误,不容易在测试环境中产生,所以很难测试处理它们的代码。异常在运行的系统中并不经常发生,所以异常处理代码很少执行。漏洞可能在很长时间内不被发现,当终于需要处理异常的代码时,很有可能它不会奏效(我最喜欢的一句话:“没有执行过的代码是不会奏效的”)。最近的一项研究发现,在分布式数据密集型系统中,超过90%的灾难性故障是由不正确的错误处理造成的(见注解)。当异常处理代码失败时,很难调试问题,因为它出现的频率很低。
Last updated