软件设计的哲学: 第九章 合并还是分解
软件设计中最基本的问题之一是:给定两部分功能,它们应该在同一个地方一起实现,还是应该分开实现? 这个问题适用于系统中的所有级别,比如函数、方法、类和服务。 例如,缓冲应该包含在提供面向流的文件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示例:日志记录的单独类
第二个例子涉及到学生项目中的错误日志记录。一个类包含如下代码序列:
try {
rpcConn = connectionPool.getConnection(dest);
} catch (IOException e) {
NetworkErrorLogger.logRpcOpenError(req, dest, e);
return null;
}
不是在错误被检测到的地方记录错误,而是调用一个特殊的错误日志类中的一个单独的方法。错误日志类是在同一个源文件的末尾定义的:
private static class NetworkErrorLogger {
/**
* Output information relevant to an error that occurs when trying
* to open a connection to send an RPC.
*
* @param req
The RPC request that would have been sent through the connection
* @param dest
* The destination of the RPC
* @param e
* The caught error
*/
public static void logRpcOpenError(RpcRequest req, AddrPortTuple dest, Exception e) {
logger.log(Level.WARNING, "Cannot send message: " + req + ". \n" + "Unable to find or open connection to " + dest + " :" + e);
}
...
}
NetworkErrorLogger类包含几个方法,如logRpcSendError和logRpcReceiveError,每个方法都记录不同类型的错误。
这种分离增加了复杂性,但没有带来任何好处。日志记录方法很简单:大多数都是由一行代码组成的,但是它们需要大量的文档。每个方法只在一个地方调用。日志记录方法高度依赖于它们的调用:读取调用的人很可能会切换到日志记录方法,以确保记录了正确的信息;类似地,阅读日志记录方法的人可能会转到调用站点以了解方法的用途。
在本例中,最好消除日志记录方法,并将日志语句放置在检测到错误的位置。这将使代码更易于阅读,并消除日志方法所需的接口。
9.7示例:编辑器撤销机制
在6.2部分的GUI编辑器项目中,其中一个需求是支持多级撤销/重做,不仅是对文本本身的更改,还包括对选择、插入游标和视图的更改。例如,如果用户选择某个文本,删除它,滚动到文件中的另一个位置,然后调用undo,编辑器必须将其状态恢复到删除之前的状态。这包括恢复被删除的文本,再次选择它,并使选择的文本在窗口中可见。
一些学生项目将整个撤销机制作为text类的一部分实现。text类维护了一个所有可撤销更改的列表。当文本被更改时,它会自动向这个列表添加条目。对于选择、插入游标和视图的更改,用户界面代码调用text类中的其他方法,然后这些方法将这些更改的条目添加到撤消列表中。当用户请求撤消或重做时,用户界面代码调用text类中的一个方法,然后由该方法处理撤消列表中的条目。对于与文本相关的条目,它更新了文本类的内部结构;对于与其他内容(如选择)相关的条目,文本类将调用回用户界面代码以执行撤消或重做。
这种方法导致文本类中出现一组令人尴尬的特性。撤销/重做的核心是一种通用机制,用于管理已执行的操作列表,并在撤消和重做操作期间逐步执行这些操作。核心位于text类中,与特殊用途的处理程序一起,这些处理程序为特定的事情(比如文本和选择)实现撤销和重做。用于选择和游标的特殊用途的撤消处理程序与文本类中的任何其他内容无关;它们导致文本类和用户界面之间的信息泄漏,以及每个模块中来回传递撤消信息的额外方法。如果将来向系统中添加了一种新的可撤消实体,则需要对text类进行更改,包括特定于该实体的新方法。此外,通用撤销核心与类中的通用文本工具几乎没有什么关系。
这些问题可以通过提取撤销/重做机制的通用核心并将其放在一个单独的类中来解决:
public class History {
public interface Action {
public void redo();
public void undo();
}
History() {...}
void addAction(Action action) {...}
void addFence() {...}
void undo() {...}
void redo() {...}
}
在本设计中,History类管理实现接口History. action的对象集合。每一个历史。Action描述单个操作,例如文本插入或光标位置的更改,并提供可以撤消或重做操作的方法。History类不知道操作中存储的信息,也不知道它们如何实现撤销和重做方法。History维护一个历史列表,该列表描述了在应用程序的生命周期中执行的所有操作,它提供了undo和redo方法,这些方法在响应用户请求的undos和redos时来回遍历列表,调用History. actions中的undo和redo方法。
历史。操作是特殊用途的对象:每个操作都理解一种特定的可撤消操作。它们在History类之外的模块中实现,这些模块理解特定类型的可撤销操作。text类可以实现UndoableInsert和UndoableDelete对象来描述文本插入和删除。每当插入文本时,text类都会创建一个新的UndoableInsert对象来描述插入并调用历史记录。addAction将其添加到历史记录列表。编辑器的用户界面代码可能创建UndoableSelection和UndoableCursor对象,它们描述对选择和插入游标的更改。
History类还允许对操作进行分组,例如,来自用户的单个undo请求可以恢复已删除的文本、重新选择已删除的文本和重新定位插入光标。
有很多方法来组织动作;History类使用fence,它是历史列表中的标记,用于分隔相关操作的组。每次遍历历史。redo向后遍历历史记录列表,撤消操作,直到到达下一个围栏。fence的位置由调用History.addFence的高级代码决定。
这种方法将撤销的功能分为三类,分别在不同的地方实现:
- 一种通用的机制,用于管理和分组操作以及调用undo/redo操作(由History类实现)。
- 特定操作的细节(由各种类实现,每个类理解少量的操作类型)。
- 分组操作的策略(由高级用户界面代码实现,以提供正确的整体应用程序行为)。
这些类别中的每一个都可以在不了解其他类别的情况下实现。历史课不知道哪些行为被撤销了;它可以用于各种各样的应用。每个action类只理解一种action,而History类和action类都不需要知道分组action的策略。
关键的设计决策是将撤消机制的通用部分与专用部分分离,并将通用部分单独放在类中。一旦完成了这一步,剩下的设计就自然而然地结束了。
注意: 将通用代码与专用代码分离的建议是指与特定机制相关的代码。例如,特殊用途的撤消代码(例如撤消文本插入的代码)应该与通用用途的撤消代码(例如管理历史记录列表的代码)分开。然而,将一种机制的专用代码与另一种机制的通用代码组合起来通常是有意义的。text类就是这样一个例子:它实现了管理文本的通用机制,但是它包含了与撤销相关的专用代码。撤消代码是专用的,因为它只处理文本修改的撤消操作。将这段代码与History类中通用的undo基础结构结合在一起是没有意义的,但是将它放在text类中是有意义的,因为它与其他文本函数密切相关。
9.8 分解和连接方法
何时细分的问题不分解仅适用于类,也适用于方法:是否存在将现有方法划分为多个较小的方法更好的时机?或者,两个较小的方法应该合并成一个较大的方法吗?长方法往往比短方法更难理解,因此许多人认为,长度本身就是分解方法的一个很好的理由。学生在课堂上经常被给予严格的标准,如“分解任何超过20行的方法!”
但是,长度本身很少是拆分方法的好理由。 一般来说,开发人员倾向于过多地分解方法。拆分方法会引入额外的接口,增加了复杂性。它还分离了原始方法的各个部分,如果这些部分实际上是相关的,就会使代码更难读取。你不应该破坏一个方法,除非它使整个系统更简单;我将在下面讨论这是如何发生的。
长方法并不总是坏事。例如,假设一个方法包含五个按顺序执行的20行代码块。如果这些块是相对独立的,则可以一次读取和理解一个块;将每个块移动到一个单独的方法中没有什么好处。如果代码块具有复杂的交互,那么将它们放在一起更重要,这样读者就可以一次看到所有代码;如果每个块位于一个单独的方法中,读者将不得不在这些展开的方法之间来回切换,以了解它们是如何协同工作的。如果方法具有简单的签名并且易于阅读,那么包含数百行代码的方法就很好。这些方法很深奥(功能很多,接口简单),这很好。
图9.3:一个方法(A)可以通过提取一个子任务(b)或者通过将其功能划分为两个单独的方法(c)来分解。
在设计方法时,最重要的目标是提供简洁而简单的抽象。 每一种方法都应该做一件事,而且要做得彻底。 这个方法应该有一个干净简单的界面,这样用户就不需要在他们的头脑中有太多的信息来正确地使用它。方法应该是深度的:它的接口应该比它的实现简单得多。 如果一个方法具有所有这些属性,那么它是否长可能并不重要。
总的来说,分解方法只有在产生更清晰的抽象时才有意义。有两种方法可以做到这一点,如图9.3所示。最好的方法是将一个子任务分解成单独的方法,如图9.3(b)所示。细分产生包含子任务的子方法和包含原始方法其余部分的父方法;父调用子调用。新父方法的接口与原始方法相同。这种形式的细分有意义如果有干净地分离的子任务的原始方法,这意味着(a)有人阅读孩子的方法不需要知道任何关于父法和(b)有人阅读父法不需要理解孩子的实现方法。通常这意味着子方法是相对通用的:它可以被父方法之外的其他方法使用。如果您对这个表单进行拆分,然后发现自己在父类和子类之间来回切换,以了解它们是如何协同工作的,那么这就是一个危险信号(“联合方法”),表明拆分可能不是一个好主意。
分解一个方法的第二种方法是将它分解成两个单独的方法,每个方法对于原始方法的调用者都是可见的,如图9.3(c)所示。如果原始方法有一个过于复杂的接口,这是有意义的,因为它试图做许多不密切相关的事情。如果是这种情况,可以将方法的功能划分为两个或多个更小的方法,每个方法只具有原始方法的一部分功能。如果像这样分解,每个结果方法的接口应该比原始方法的接口简单。理想情况下,大多数调用者应该只需要调用两个新方法中的一个;如果调用者必须同时调用这两个新方法,那么这就增加了复杂性,从而降低了拆分的可能性。新方法将更专注于它们所做的事情。如果新方法比原来的方法更通用,这是一个好迹象。你可以想象在其他情况下分别使用它们)。
图9.3(c)中所示的表单分解通常没有意义,因为它们导致调用者必须处理多个方法,而不是一个。当您以这种方式进行划分时,您可能会得到几个浅层方法,如图9.3(d)所示。如果调用者必须调用每个单独的方法,在它们之间来回传递状态,那么分解不是一个好主意。如果您正在考虑类似图9.3(c)中的拆分,那么您应该根据它是否简化了调用者的工作来判断它。
在某些情况下,可以通过将方法连接在一起来简化系统。例如,连接方法可以用一个较深的方法代替两个较浅的方法;它可以消除重复的代码;它可以消除原始方法或中间数据结构之间的依赖关系;它可能导致更好的封装,因此以前在多个地方出现的知识现在被隔离在一个地方;或者,它可能导致一个更简单的接口,如9.2节中所讨论的那样。
危险信号:联合方法
应该能够独立地理解每种方法。如果你不能理解一个方法的实现而不理解另一个方法的实现,那就是一个危险信号。此微信型号也可以出现在其他上下文中:如果两段代码在物理上是分开的,但是每段代码只能通过查看另一段代码来理解,这就是危险信号。
9.9 结论
拆分或联接模块的决策应该基于复杂性。选择能够隐藏最佳信息、最少依赖和最深接口的结构。