软件设计的哲学: 第九章 合并还是分解
目录
软件设计中最基本的问题之一是:给定两部分功能,它们应该在同一个地方一起实现,还是应该分开实现? 这个问题适用于系统中的所有级别,比如函数、方法、类和服务。 例如,缓冲应该包含在提供面向流的文件I/O的类中,还是应该包含在单独的类中?HTTP请求的解析应该完全在一个方法中实现,还是应该在多个方法(甚至多个类)中进行?本章讨论了做出这些决定时需要考虑的因素。这些因素中的一些已经在前几章中讨论过,但是为了完整起见,这里将重新讨论它们。
在决定是合并还是分离时,目标是降低整个系统的复杂性并改进其模块化。实现这一目标的最佳方法似乎是将系统划分为大量的小组件:组件越小,每个单独的组件可能就越简单。 然而,细分的行为产生了额外的复杂性,这在细分之前是不存在的:
- 一些复杂性仅仅来自组件的数量:组件越多,就越难以跟踪它们,也就越难以在大型集合中找到所需的组件。细分通常会导致更多的接口,而且每个新接口都会增加复杂性。
- 细分可能导致管理组件的额外代码。例如,在细分之前使用单个对象的一段代码现在可能必须管理多个对象。
- 细分产生分离:细分后的组件将比细分前更加分离。例如,在细分之前在单个类中的方法可能在细分之后在不同的类中,也可能在不同的文件中。分离使得开发人员很难同时看到组件,甚至很难意识到它们的存在。如果组件是真正独立的,那么分离是好的:它允许开发人员一次只关注一个组件,而不会被其他组件分散注意力。另一方面,如果组件之间存在依赖关系,则分离是不好的:开发人员最终将在组件之间来回切换。更糟糕的是,他们可能没有意识到依赖关系,这可能会导致bug。
- 细分可能导致重复:在细分之前存在于单个实例中的代码可能需要存在于每个细分的组件中。
如果代码片段紧密相关,那么将它们组合在一起是最有益的。如果这些部分是不相关的,那么最好分开。这里有一些迹象表明,两段代码是相关的:
- 他们分享信息;例如,这两段代码可能取决于特定类型文档的语法。
- 它们一起使用:任何使用其中一段代码的人都可能使用另一段代码。这种形式的关系只有在双向的情况下才有吸引力。作为一个反例,磁盘块缓存几乎总是涉及到一个散列表,但是散列表可以在许多不涉及块缓存的情况下使用;因此,这些模块应该是独立的。
- 它们在概念上是重叠的,因为有一个简单的更高级别的类别,其中包括这两段代码。例如,搜索子字符串和大小写转换都属于字符串操作的范畴;流量控制和可靠交付都属于网络通信的范畴。
- 如果不看另一段代码,就很难理解其中一段代码。
本章的其余部分将使用更具体的规则和示例来说明何时将代码片段放在一起是有意义的,以及何时将它们分开是有意义的。
9.1 如果共享信息,则将信息集合在一起
第5.4节在实现HTTP服务器的项目上下文中介绍了这一原则。在第一个实现中,该项目使用不同类中的两个不同方法来读入和解析HTTP请求。第一个方法读取来自网络套接字的传入请求的文本,并将其放在字符串对象中。第二个方法解析字符串以提取请求的各个组件。分解,最终的两个方法都有相当知识的HTTP请求的格式:第一种方法只是想读请求,解析它,但它不能识别的最后请求不做的大部分工作的解析(例如,它解析头线以识别包含整体请求的标题长度)。由于这种共享信息,最好在同一个位置读取和解析请求;当这两个类合并为一个类时,代码变得更短更简单。
9.2 如果可以简化接口,就一起使用
当两个或多个模块组合成一个模块时,可以为新模块定义一个比原来的接口更简单或更容易使用的接口。这种情况经常发生在原始模块实现问题解决方案的一部分时。在前一节的HTTP服务器示例中,原始方法需要一个接口来从第一个方法返回HTTP请求字符串并将其传递给第二个方法。当这些方法组合在一起时,这些接口就被消除了。
此外,当两个或多个类的功能组合在一起时,可能会自动执行某些功能,因此大多数用户不需要知道它们。Java I/O库说明了这一机会。如果将FileInputStream和BufferedInputStream类组合在一起,并且默认提供了缓冲,那么绝大多数用户甚至都不需要知道缓冲的存在。组合的FileInputStream类可能提供禁用或替换默认缓冲机制的方法,但是大多数用户不需要了解这些方法。
9.3 消除重复
如果您发现重复出现相同的代码模式,请尝试重新组织代码以消除重复。一种方法是将重复的代码分解成一个单独的方法,并将重复的代码片段替换为对该方法的调用。 如果重复的代码段很长,并且替换方法有一个简单的签名,那么这种方法是最有效的。如果代码段只有一两行,那么用方法调用替换它可能没有什么好处。如果代码段以复杂的方式与它的环境交互(例如通过访问许多局部变量),那么替换方法可能需要复杂的签名(例如许多引用传递参数),这将降低它的值。
消除重复的另一种方法是重构代码,使有问题的代码片段只需要在一个地方执行。 假设您正在编写一个方法,该方法需要在几个不同的点上返回错误,并且在返回之前需要在这些点上执行相同的清理操作(参见图9.1中的示例)。如果编程语言支持goto,您可以将清理代码移动到方法的末尾,然后转到需要错误返回的每个点,如图9.2所示。Goto语句通常被认为是一个糟糕的想法,如果不加选择地使用它们,可能会导致无法破译的代码,但是在这种情况下它们是有用的,因为它们可以用来逃避嵌套的代码。
9.4 通用代码和专用代码分开
如果一个模块包含一个可以用于多个不同目的的机制,那么它应该只提供一个通用机制。它不应该包含专门用于特定用途的机制的代码,也不应该包含其他通用机制。与通用机制相关联的专用代码通常应该放在不同的模块中(通常是与特定用途相关联的模块)。第6章中的GUI编辑器讨论说明了这一原则:最佳设计是文本类提供通用的文本操作,而用户界面的特定操作(如删除选择)在用户界面模块中实现。这种方法消除了早期设计中出现的信息泄漏和额外的接口,在早期设计中,专门的用户界面操作是在text类中实现的。
危险信号:重复
如果同一段代码(或几乎相同的代码)反复出现,这是一个危险信号,说明您没有找到正确的抽象。
图9.1:此代码处理不同类型的入站网络数据包;对于每种类型,如果信息包太短而不适合该类型,则记录一条消息。在这个版本的代码中,日志语句被复制到几个不同的包类型中。
图9.2:对图9.1中的代码进行重组,使日志语句只有一个副本。
一般来说,系统的低层往往是通用的,而上层则是专用的。例如,应用程序的最顶层由完全特定于该应用程序的特性组成。将专用代码从通用代码中分离出来的方法是将专用代码向上拉到更高的层中,而将较低的层保留为通用代码。
当你遇到一个类,包括通用和专用功能相同的抽象,看看类可以分为两个类,一个包含通用功能,其他之上提供专用功能。
9.5 示例:插入光标和选择
下一节将通过三个示例来说明上面讨论的原则。在两个例子中,最好的方法是分离相关的代码片段;在第三个例子中,最好将它们连接在一起。
第一个例子由第6章的GUI编辑器项目中的插入游标和选择组成。编辑器显示一条闪烁的竖线,指示用户键入的文本将出现在文档中的何处。它还显示了一个高亮显示的字符范围,称为选择,用于复制或删除文本。插入光标总是可见的,但有时可能没有选择文本。如果选择项存在,则插入光标始终定位在选择项的一端。
选择和插入游标在某些方面是相关的。例如,光标总是停留在一个选择,和光标选择往往是一起操作:点击并拖动鼠标设置他们两人,和文本插入第一个删除选中的文本,如果有任何,然后在光标位置插入新的文本。因此,使用单个对象来管理选择和游标似乎是合理的,一个项目团队采用了这种方法。该对象在文件中存储了两个位置,以及布尔值,布尔值指示哪一端是游标,以及选择是否存在。
然而,组合的对象是尴尬的。它没有为高级代码提供任何好处,因为高级代码仍然需要知道选择和游标是不同的实体,并且需要分别操作它们(在文本插入期间,它首先调用组合对象上的一个方法来删除所选的文本;然后,它调用另一个方法来检索光标位置,以便插入新文本)。组合对象实际上比单独的对象更复杂。它避免将游标位置存储为单独的实体,而是必须存储一个布尔值,指示选择的哪一端是游标。为了检索光标位置,组合对象必须首先测试布尔值,然后选择适当的选择结束。
危险信号:特殊和一般的混合物
当通用机制还包含专门用于该机制特定用途的代码时,就会出现此警告。这使得机制更加复杂,并在机制和特定用例之间产生信息泄漏:未来对用例的修改可能也需要对底层机制进行更改。
本例中,选择和游标之间的关系不够紧密,无法将它们组合在一起。当修改代码以将选择和游标分隔开时,使用和实现都变得更简单了。与必须从中提取选择和游标信息的组合对象相比,分离对象提供了更简单的接口。游标实现也变得更简单了,因为游标位置是直接表示的,而不是通过选择和布尔值间接表示的。事实上,在修订版本中,选择和游标都没有使用特殊的类。相反,引入了一个新的Position类来表示文件中的一个位置(行号和行中的字符)。选择用两个位置表示,游标用一个位置表示。这些职位在项目中还有其他用途。这个示例还演示了较低级但更通用的接口的好处,这在第6章中讨论过。
9.6示例:日志记录的单独类
第二个例子涉及到学生项目中的错误日志记录。一个类包含如下代码序列: