软件设计的哲学: 第十章 定义不存在错误
目录
异常处理是软件系统中最糟糕的复杂性来源之一。处理特殊情况的代码天生就比处理正常情况的代码更难编写,而且开发人员经常在定义异常时没有考虑如何处理它们。本章讨论了异常对复杂性的不成比例的贡献,然后展示了如何简化异常处理。本章的主要教训是减少必须处理异常的地方;在许多情况下,可以修改操作的语义,使正常行为可以处理所有情况,并且不需要报告任何异常情况(这就是本章的标题)。
10.1 异常增加复杂性的原因
我使用术语异常来指改变程序中正常控制流的任何不寻常的情况。许多编程语言都包含一个正式的异常机制,该机制允许底层代码抛出异常并通过封装代码捕获异常。但是,即使不使用正式的异常报告机制,也可能发生异常,例如当一个方法返回一个特殊值,表明它没有完成正常行为。所有这些形式的异常都增加了复杂性。
一段特定的代码可能会遇到几种不同的异常:
- 调用者可能提供错误的参数或配置信息。
- 被调用的方法可能无法完成请求的操作。例如,I/O操作可能失败,或者所需的资源可能不可用。
- 在分布式系统中,网络数据包可能丢失或延迟,服务器可能无法及时响应,或者对等节点可能以无法预料的方式通信。
- 代码可能会检测出bug、内部不一致或无法处理的情况。
大型系统必须处理许多异常情况,特别是当它们是分布式的或者需要容错的时候。异常处理占系统中所有代码的很大一部分。
异常处理代码天生就比正常情况下的代码更难写。异常中断了正常的代码流;它通常意味着某事没有像预期的那样工作。当异常发生时,程序员可以用两种方法处理它,每种方法都很复杂。第一种方法是向前推进并完成正在进行的工作,尽管存在例外。例如,如果一个网络数据包丢失,它可以被重发;如果数据损坏了,也许可以从冗余副本中恢复。第二种方法是中止正在进行的操作,向上报告异常。但是,中止可能很复杂,因为异常可能发生在系统状态不一致的地方(数据结构可能已经部分初始化);异常处理代码必须恢复一致性,例如通过撤销发生异常之前所做的任何更改。
此外,异常处理代码为更多的异常创造了机会。考虑重新发送丢失的网络包的情况。也许包裹实际上并没有丢失,只是被耽搁了。在这种情况下,重新发送数据包将导致重复的数据包到达对等点;这引入了一个新的异常条件,对等方必须处理。或者,考虑从冗余副本中恢复丢失的数据的情况:如果冗余副本也丢失了怎么办?在恢复期间发生的次要异常通常比主要异常更微妙和复杂。如果通过中止正在进行的操作来处理异常,则必须将此异常作为另一个异常报告给调用者。为了防止异常的无休止级联,开发人员最终必须找到一种方法来处理异常,而不引入更多的异常。
对异常的语言支持往往冗长而笨拙,这使得异常处理代码难以阅读。例如,考虑以下代码,它使用Java对对象序列化和反序列化的支持从文件中读取tweet集合:
try ( FileInputStream fileStream =new FileInputStream(fileName); BufferedInputStream bufferedStream =new BufferedInputStream(fileStream); ObjectInputStream objectStream =new ObjectInputStream(bufferedStream); ) { for (int i = 0; i < tweetsPerFile; i++) { tweets.add((Tweet) objectStream.readObject()); } } catch (FileNotFoundException e) { ... } catch (ClassNotFoundException e) { ... } catch (EOFException e) { // Not a problem: not all tweet files have full // set of tweets. } catch (IOException e) { ... } catch (ClassCastException e) { ... }
但是,基本的try-catch样板代码比正常情况下的操作代码行数更多,甚至不考虑实际处理异常的代码。很难将异常处理代码与正常情况代码联系起来:例如,在哪里生成每个异常并不明显。另一种方法是把代码分成许多不同的try块;在极端情况下,可以尝试生成异常的每一行代码。这将使异常发生的地方变得清晰,但是try块本身会破坏代码流,使其更难读取;此外,一些异常处理代码可能会在多个try块中重复。
很难确保异常处理代码真正有效。有些异常,比如I/O错误,在测试环境中很难生成,因此很难测试处理它们的代码。异常在运行的系统中不经常发生,所以很少执行异常处理代码。bug可能很长一段时间都无法检测到,当最终需要异常处理代码时,它很可能无法工作(我最喜欢的说法之一是:“未执行的代码无法工作”)。最近的一项研究发现,在分布式数据密集型系统中,超过90%的灾难性故障是由错误处理引起的。当异常处理代码失败时,很难调试问题,因为它发生的频率很低。
10.2 例外情况太多
程序员通过定义不必要的异常而加剧了与异常处理相关的问题。大多数程序员都被告知检测和报告错误很重要;他们通常将其解释为“检测到的错误越多越好”。这导致了一种过度防御的风格,任何看起来有点可疑的东西都会被异常拒绝,这导致了不必要的异常的扩散,增加了系统的复杂性。
在设计Tcl脚本语言时,我自己也犯了这个错误。Tcl包含一个未设置的命令,可用于删除变量。我定义了unset以便在变量不存在时抛出错误。当时我认为,如果有人试图删除一个不存在的变量,那么它一定是一个bug,所以Tcl应该报告它。然而,unset最常见的用途之一是清理以前操作创建的临时状态。通常很难准确地预测创建了什么状态,特别是在操作中途中止的情况下。因此,最简单的方法是删除可能已经创建的所有变量。unset的定义使得这种情况很尴尬:开发人员最终会在catch语句中封装对unset的调用,以捕获并忽略unset抛出的错误。回顾过去,unset命令的定义是我在Tcl设计中犯下的最大错误之一。
使用异常来避免处理困难的情况是很有诱惑力的:与其找出一个干净的方法来处理它,不如抛出一个异常并把问题推给调用者。有些人可能会认为这种方法赋予了调用者权力,因为它允许每个调用者以不同的方式处理异常。然而,如果你在特定情况下不知道该怎么做,很有可能打电话的人也不知道该怎么做。在这种情况下生成异常只会将问题传递给其他人,并增加系统的复杂性。
类抛出的异常是其接口的一部分;具有大量异常的类具有复杂的接口,并且它们比具有较少异常的类要浅。异常是接口中特别复杂的元素。它可以在被捕获之前向上传播几个堆栈级别,因此它不仅影响方法的调用者,还可能影响更高级别的调用者(及其接口)。
抛出异常很容易,处理它们很困难。因此,异常的复杂性来自于异常处理代码。减少异常处理造成的复杂性损害的最佳方法是减少必须处理异常的地方的数量。 本章的其余部分将讨论减少异常处理程序数量的四种技术。
10.3 定义不存在的错误
消除异常处理复杂性的最佳方法是定义api,这样就没有异常需要处理:定义不存在的错误。 这可能看起来有些亵渎,但在实践中却非常有效。考虑前面讨论的Tcl unset命令。当unset被要求删除一个未知变量时,它应该简单地返回,而不是抛出一个错误。我应该稍微修改一下unset的定义:unset应该确保一个变量不再存在,而不是删除一个变量。对于第一个定义,如果变量不存在,unset就无法执行其任务,因此生成异常是有意义的。对于第二个定义,使用不存在的变量的名称来调用unset是非常自然的。在这种情况下,它的工作已经完成,所以它可以简单地返回。不再需要报告错误情况。
10.4 示例:在Windows中删除文件
文件删除提供了另一个如何定义错误的例子。如果文件在进程中打开,Windows操作系统不允许删除该文件。对于开发人员和用户来说,这是一个持续的沮丧之源。为了删除正在使用的文件,用户必须在系统中搜索,找到打开该文件的进程,然后杀死该进程。有时用户会放弃并重新启动他们的系统,只是为了删除一个文件。
Unix操作系统更优雅地定义了文件删除。在Unix中,如果文件在删除时打开,Unix不会立即删除该文件。
它将文件标记为删除,然后删除操作成功返回。该文件名已从其目录中删除,因此其他进程无法打开旧文件,并且可以创建具有相同名称的新文件,但现有的文件数据将持续存在。已经打开文件的进程可以继续正常地读取和写入文件。一旦文件被所有访问进程关闭,它的数据就会被释放。
Unix方法定义了两种不同的错误。首先,删除操作不再返回一个错误,如果文件当前正在使用;删除成功,文件最终将被删除。其次,删除正在使用的文件不会为使用该文件的进程创建异常。解决这个问题的一种可能的方法是立即删除文件,并标记所有打开的文件来禁用它们;其他进程读取或写入删除文件的任何尝试都将失败。但是,这种方法会为那些要处理的进程创建新的错误。相反,Unix允许它们继续正常地访问文件;延迟文件删除定义了不存在的错误。
Unix允许进程继续读写一个命中注定的文件,这似乎有些奇怪,但我从未遇到过这种情况,它会导致严重的问题。对于开发人员和用户来说,Unix下的文件删除定义要比Windows下的定义简单得多。
10.5 示例:Java子字符串方法
最后一个例子是Java String类及其子String方法。给定一个字符串中的两个索引,substring返回从第一个索引给出的字符开始并以第二个索引之前的字符结束的子字符串。但是,如果其中一个索引超出了字符串的范围,则子字符串将抛出IndexOutOfBoundsException。此异常是不必要的,并使此方法的使用复杂化。我经常遇到这样的情况,其中一个或