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