软件设计中最根本的问题之一是:给定两项功能,它们应该在同一个地方一起实现,还是应该分别实现?
这个问题适用于系统中的所有层次,例如函数、方法、类以及服务。
例如:
又例如:
本章将讨论做出这些决策时需要考虑的因素。其中一些因素已经在前面的章节中提到过,但为了完整性,本章会再次回顾它们。
当决定将功能合并还是拆分时,目标是: 降低整个系统的复杂度,并提升模块化程度。
乍看之下,实现这一目标的最佳方式似乎是把系统拆分成大量的小组件:
然而,拆分这一行为本身会引入原本不存在的额外复杂性:
复杂性可能仅仅来自组件数量的增加。
组件越多:
拆分通常还会产生更多接口,而每增加一个接口都会增加复杂度。
拆分可能产生额外的管理代码。
例如:在拆分之前,一段代码只需要管理一个对象;而拆分之后,它可能需要同时管理多个对象。
拆分会制造距离感(separation)。
拆分后的组件彼此之间会比拆分前更遥远。例如:原本位于同一个类中的两个方法,拆分之后可能位于不同的类中,甚至不同的文件中。
这种分离使开发者更难同时看到这些组件,甚至可能根本意识不到它们的存在。如果这些组件真正彼此独立,那么这种分离是有益的:它允许开发者专注于一个组件,而不会被其他组件干扰。但如果组件之间存在依赖关系,那么这种分离就是有害的:开发者将不得不频繁地在多个组件之间来回切换。更糟糕的是,他们可能意识不到这些依赖关系,从而引发缺陷。
拆分可能导致重复。
在拆分之前只存在一份的代码,在拆分之后可能需要出现在每一个子组件中。将代码放在一起最有价值的情况是:
这些代码彼此关系紧密。
如果两部分代码毫不相关,那么把它们分开通常会更好。下面是一些表明两段代码彼此相关的迹象:
它们共享信息。
例如:两段代码都依赖于某种特定文档格式的语法规则。
它们总是一起被使用。
使用其中一段代码的人,很可能也会使用另一段代码。只有当这种关系是双向的时候,这种理由才真正具有说服力。
举一个反例:
磁盘块缓存(disk block cache)几乎总会使用哈希表(hash table)。但哈希表还可以用于许多与块缓存无关的场景。因此,这两个模块应当保持分离。
它们在概念上属于同一类别。
也就是说,存在一个更高层次且简单的概念能够同时涵盖两者。
例如:
都属于“字符串处理(string manipulation)”。
又例如:
都属于“网络通信(network communication)”。
如果不查看另一部分代码,就很难理解其中一部分代码。
本章剩余内容将通过更具体的规则和实例说明:
第 5.4 节曾在一个 HTTP 服务器项目的背景下介绍过这一原则。在最初的实现中,项目使用了两个位于不同类中的方法来读取并解析 HTTP 请求。
第一个方法:
第二个方法:
在这种设计下,两个方法最终都掌握了大量关于 HTTP 请求格式的知识:第一个方法虽然只是想读取请求,而不是解析请求,但如果不完成解析工作的大部分内容,它根本无法判断请求在哪里结束。
例如:它必须解析请求头(header)行,才能找到包含整体请求长度信息的那个请求头。由于存在这些共享信息,最好的做法是把读取和解析请求的逻辑放在同一个地方。当这两个类被合并为一个之后,代码变得:
当两个或更多模块被合并成一个模块时,往往有机会为新的模块设计一个 比原有接口更简单、更易使用的接口 。
这种情况通常出现在原本的模块各自只负责解决问题的一部分时。
在上一节 HTTP 服务器的例子中,原来的两个方法之间需要一个接口:第一个方法必须返回 HTTP 请求字符串,然后将其传递给第二个方法。
当这两个方法被合并后,这个接口也就不再需要了。
此外,当两个或多个类的功能被整合到一起时,有些操作甚至可以自动完成,从而让大多数用户根本不需要知道这些功能的存在。
Java 的 I/O 库就是一个很好的例子。
如果 FileInputStream 和 BufferedInputStream 两个类被合并,并且默认自动提供缓冲功能,那么绝大多数用户甚至不需要知道“缓冲(buffering)”这一机制的存在。
一个合并后的 FileInputStream 类仍然可以提供关闭缓冲或替换默认缓冲机制的方法,但大多数用户根本不需要学习这些内容。
如果你发现同一种代码模式反复出现,那么应该考虑重新组织代码结构,以消除这些重复。
一种常见做法是:
将重复的代码提取到单独的方法中,然后用方法调用来替代原来的重复代码段。这种做法在以下情况下最有效:
如果代码片段只有一两行,那么用方法调用替代它所带来的收益可能并不明显。
如果这段代码与周围环境耦合得很紧密(例如访问了大量局部变量),那么提取出来的方法可能不得不接受许多参数(甚至大量引用参数),从而导致接口变得复杂,削弱重构带来的价值。
另一种消除重复的方法是:
重新组织代码,使得这段代码只需要存在于一个地方。
假设你正在编写一个方法,这个方法会在多个位置返回错误,而每次返回之前都必须执行相同的清理操作。
如果编程语言支持 goto,那么可以把清理代码移动到方法的末尾,在每个错误返回点统一跳转到那里执行清理逻辑。
通常来说,goto 被认为是一种糟糕的编程实践。如果滥用,它会导致代码难以理解和维护。但是在这种场景下——用于从深层嵌套代码中快速退出并统一执行收尾逻辑——它仍然是有价值的。
switch (common->opcode) {
case DATA: {
DataHeader* header =
received->getStart<DataHeader>();
if (header == NULL) {
LOG(WARNING,
"%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
}
}
...
case GRANT: {
GrantHeader* header =
received->getStart<GrantHeader>();
if (header == NULL) {
LOG(WARNING,
"%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
}
}
...
case RESEND: {
ResendHeader* header =
received->getStart<ResendHeader>();
if (header == NULL) {
LOG(WARNING,
"%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
}
}
...
}
这段代码用于处理不同类型的网络数据包。
对于每一种数据包类型,如果收到的数据长度不足以容纳对应的数据结构,就会记录一条日志。
在这个版本中,LOG 语句在多个数据包类型中被重复书写。
switch (common->opcode) {
case DATA: {
DataHeader* header =
received->getStart<DataHeader>();
if (header == NULL)
goto packetTooShort;
...
}
case GRANT: {
GrantHeader* header =
received->getStart<GrantHeader>();
if (header == NULL)
goto packetTooShort;
...
}
case RESEND: {
ResendHeader* header =
received->getStart<ResendHeader>();
if (header == NULL)
goto packetTooShort;
...
}
...
}
...
packetTooShort:
LOG(WARNING,
"%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
对第一段代码的一种重构方式。
通过将错误处理逻辑统一放到 packetTooShort 标签处,整个程序只保留了一份 LOG 代码,从而消除了重复。
如果一个模块包含一种能够用于多种不同用途的机制,那么这个模块应当只提供这一种 通用机制(general-purpose mechanism) 。
它不应该:
与某个通用机制相关的专用代码,通常应该被放到另一个模块中(通常是与该特定用途相关的模块)。
第 6 章关于 GUI 编辑器的讨论展示了这一原则。
最佳设计方案中:
这种设计带来了两个好处:
而在早期设计中,这些面向界面的特殊操作被直接实现于文本类内部,因此导致了额外的耦合和接口复杂度。
如果同一段代码(或者几乎相同的代码)一次又一次地出现,
这就是一个危险信号(Red Flag):
说明你还没有找到正确的抽象(abstraction)。
接下来的几个小节将通过两个实例来说明前面讨论的原则。
第一个例子来自第 6 章中的 GUI 编辑器项目。
编辑器会显示一个闪烁的竖线,用于指示用户输入的文本将会出现的位置,这个竖线称为 插入光标(insertion cursor) 。
编辑器还会显示一段高亮区域,用于表示一系列被选中的字符,这部分内容称为 选区(selection) ,可用于复制或删除文本。
插入光标始终可见;
而选区并不一定存在,因为有时用户并没有选中任何文本。当选区存在时,插入光标总是位于选区的一端,而光标和选区往往也是一起被操作的:
因此,看起来似乎很合理:使用一个对象同时管理选区(selection)和光标(cursor)。
有一个项目团队就采用了这种设计。该对象保存:
然而,这种组合对象(combined object)用起来却相当别扭。对于上层代码来说,它没有带来任何好处。因为上层代码仍然必须把选区和光标看作两个不同的实体来处理。
例如,在插入文本时:
也就是说,上层代码仍然是在分别操作选区和光标。实际上,这个组合对象的实现反而比独立对象更复杂。
它没有直接存储光标位置,而是:
因此,当需要获取光标位置时:
换句话说,光标位置是被间接表示的。
在这个例子中,选区和光标之间的关系并没有紧密到值得把它们合并成一个对象。因此后来代码被修改为:
将选区(selection)和光标(cursor)彻底分离。
结果无论是使用方式还是实现方式都变得更加简单。独立对象提供的接口比组合对象更简单,因为不再需要从一个对象中提取选区信息和光标信息。
光标的实现也因此变得更简单:光标位置被直接表示,而不是通过
来间接推导。事实上,在修改后的设计中:
取而代之的是引入了一个新的 Position 类,用于表示文件中的一个位置:
选区由两个 Position 表示,光标由一个 Position 表示。
Position 类后来还在项目中的其他地方得到了复用。这个例子也再次说明了:
底层但更加通用的接口往往更有价值。
这一思想在第 6 章已经讨论过。
当一个通用机制(general-purpose mechanism)中混入了针对特定用途的专用代码时,就会出现这一危险信号。
这种设计会导致:
第二个例子来自一个学生项目中的错误日志系统。某个类中包含了大量类似下面这样的代码:
try {
rpcConn =
connectionPool.getConnection(dest);
} catch (IOException e) {
NetworkErrorLogger.logRpcOpenError(
req, dest, e);
return null;
}
这里并不是在发现错误的地方直接记录日志,而是调用一个专门的日志类中的方法。这个日志类定义在同一个源文件的末尾:
private static class NetworkErrorLogger {
/**
* 输出与 RPC 连接建立失败相关的信息
*
* @param req
* 原本准备通过该连接发送的 RPC 请求
*
* @param dest
* RPC 的目标地址
*
* @param e
* 捕获到的异常
*/
public static void logRpcOpenError(
RpcRequest req,
AddrPortTuple dest,
Exception e) {
logger.log(Level.WARNING,
"Cannot send message: "
+ req
+ ".\nUnable to find or open "
+ "connection to "
+ dest
+ ": "
+ e);
}
...
}
NetworkErrorLogger 类中包含多个类似的方法:
logRpcSendErrorlogRpcReceiveError每个方法负责记录一种不同类型的错误。然而,这种拆分实际上只增加了复杂度,却没有带来任何收益。
这些日志方法通常只有一行真正的代码,但却需要大量文档说明。而且每个方法往往只会在一个地方被调用。
这些日志方法与其调用点高度耦合。阅读调用代码的人:
NetworkErrorLogger.logRpcOpenError(...)
往往需要翻到日志方法的实现中,才能确认到底记录了哪些内容。
反过来,阅读日志方法的人也必须回到调用点,才能理解这个日志出现的具体上下文。
在这个例子中,更好的做法是:直接在发现错误的位置写日志语句。这样:
关于“什么时候应该拆分”的问题,不仅适用于类(class),也适用于方法(method)。
例如:
长方法通常比短方法更难理解。因此很多人认为:
只要方法太长,就应该拆分。
在很多编程课程中甚至会给出硬性规则:
“任何超过 20 行的方法都必须拆分。”
然而,仅仅因为方法很长,往往不足以成为拆分它的理由。
实际上,开发者经常会把方法拆得过度。拆分会引入额外接口,而接口本身就会增加复杂度。同时,原本属于一个整体的代码被强行拆开后,如果这些部分实际上关系密切,反而会让代码更难阅读。
因此: 除非拆分后能够让整个系统变得更简单,否则不要拆分方法。
下面作者会进一步讨论这一点。长方法并不总是坏事。
例如:
假设一个方法中有五个长度约为 20 行的代码块,它们按照顺序依次执行。如果这些代码块之间相对独立,那么读者完全可以:
在这种情况下,把每个代码块拆成单独的方法并不会带来多少收益。
如果这些代码块之间存在复杂交互,那么更应该把它们放在一起。这样读者能够一次性看到所有相关逻辑。
否则,如果每个代码块都被拆到不同的方法中,读者就不得不在多个方法之间来回跳转,才能理解整个流程是如何运作的。
因此:即使一个方法有数百行代码,只要:
它仍然是可以接受的。这样的代码通常具有较高的“深度(deep)”:
而这正是优秀设计的重要特征。当设计方法时,最重要的目标是:
提供清晰、干净的抽象(clean abstractions)。
(a) 原始方法
(b) 提取一个子任务(推荐)
(c) 拆成两个独立方法(很少是好方案)
(d) 产生多个浅层方法(应避免)
一个方法(a)可以通过两种方式拆分:
如果拆分后得到的是一堆浅层(shallow)方法,如(d)所示,那么就不应该进行拆分。
一个方法应该具有简单的接口,这样用户不需要在脑子里记住太多信息就能正确使用它。方法应该是 深的(deep) :也就是说,它的接口应该远比它的实现简单。如果一个方法具备这些特征,那么它是否很长其实并不重要。
只有当拆分能够让整体抽象更加清晰时,拆分方法才有意义。
下面是两种拆分方式。
最好的拆分方式通常是:将某个子任务抽取为独立方法。拆分后:
新父方法的接口与原方法保持一致。这种拆分只有在子任务能够与原方法其它部分清晰分离时才有意义。理想情况下:
阅读子方法的人不需要了解父方法的实现细节。
阅读父方法的人不需要理解子方法的内部实现。通常这意味着:子方法本身是比较通用的。
除了父方法之外,它理论上也可能被其他方法复用。如果你进行了这样的拆分,结果却发现自己必须不断在父方法和子方法之间来回切换,
才能理解它们如何协同工作,那么这就是一个危险信号:
Conjoined Methods(连体方法)
说明这次拆分大概率是错误的。
另一种方式是:把原方法拆成两个独立方法,两个方法都直接暴露给原来的调用者。
这种做法适用于:原方法接口过于复杂,因为它试图完成多个彼此关联不强的任务。如果确实如此,那么可以把原来的功能拆成多个较小的方法,每个方法只负责原来的一部分功能。这种拆分后,每个新方法的接口都应该比原方法更简单。理想情况下:大部分调用者只需要调用其中一个方法即可。
如果调用者必须同时调用两个方法,那就意味着:
一个积极信号是:拆出来的新方法比原方法更具通用性。换句话说,你能够想象它们在其他场景中被单独使用。
因为它会让调用者原本只需要面对一个方法,现在却必须面对多个方法。更糟糕的是,这种拆分很容易产生多个浅层方法。
如果调用者必须:
那么拆分通常就是错误的。因此,判断标准应该是:
它是否让调用者的工作变得更简单?
如果没有,那就不要拆。
有时候,把多个方法合并起来反而能让系统变得更简单。例如:
合并后,两个浅方法可能变成一个更深的方法。
多个方法中重复的逻辑可以被统一。
原本方法之间的依赖、中间数据结构,都可能被删除。
原本分散在多个地方的知识,现在被集中在一个地方。
正如 9.2 节所讨论的那样,方法合并往往能够减少接口数量。
每个方法都应该能够被独立理解。如果:
想理解方法 A,
必须先理解方法 B 的实现,
那么这就是一个危险信号。这种问题不仅会出现在方法上。
只要:
那么就属于同样的问题。
在《Clean Code》一书中,Robert Martin 主张:函数应该仅仅根据长度进行拆分。他认为函数应该极其短小,甚至连 10 行代码都太长。
他写道:
函数的第一条规则:
它应该很小。
第二条规则:
它应该更小。
if、else、while 等语句块中的内容应该只有一行。
而这一行最好是一个函数调用。
这意味着函数不应该大到足以容纳嵌套结构。
因此函数的缩进层级不应超过一层或两层。
当然,这会让函数更容易阅读和理解。
我同意:短函数通常比长函数更容易理解。但是,当一个函数已经缩短到几十行以内时,继续缩短带来的收益通常已经很有限。更重要的问题其实是:
拆分函数是否降低了整个系统的复杂度?
换句话说,究竟是
还是
函数越多,就意味着:
如果函数被拆得太小,它们会失去独立性,从而形成前面提到的:
Conjoined Functions(连体函数)
必须一起阅读和理解。当出现这种情况时,保留那个较大的函数反而更好。因为所有相关逻辑都集中在一个地方。
作者给出的原则是:
深度(Depth)比长度(Length)更重要。
首先让函数变得深,然后再让它足够短,以保证可读性。
不要为了追求短小,而牺牲深度。
决定模块应该拆分还是合并时,判断依据应该是: 复杂度(complexity) 而不是尺寸大小。
请选择能够实现以下目标的结构:
这才是拆分与合并模块时应该遵循的原则。
这一章(Chapter 9: Better Together Or Better Apart?)的核心思想可以浓缩成一句话:
不要迷信“小而美”。
拆分与合并的唯一标准是:是否降低了系统整体复杂度。
深而简单的抽象,比大量短小但互相依赖的抽象更有价值。