小马的世界

读书笔记-软件设计的哲学【9】一起更好,还是分开更好?

2026-06-01 · 24 min read

软件设计中最根本的问题之一是:给定两项功能,它们应该在同一个地方一起实现,还是应该分别实现?

这个问题适用于系统中的所有层次,例如函数、方法、类以及服务。

例如:

  • 缓冲(buffering)功能应该包含在提供流式文件 I/O 的类中吗?
  • 还是应该放在一个独立的类里?

又例如:

  • 一个 HTTP 请求的解析过程,应该全部由一个方法完成吗?
  • 还是应该拆分到多个方法(甚至多个类)中?

本章将讨论做出这些决策时需要考虑的因素。其中一些因素已经在前面的章节中提到过,但为了完整性,本章会再次回顾它们。

当决定将功能合并还是拆分时,目标是: 降低整个系统的复杂度,并提升模块化程度。

乍看之下,实现这一目标的最佳方式似乎是把系统拆分成大量的小组件:

  • 组件越小,
  • 每个组件本身往往越简单。

然而,拆分这一行为本身会引入原本不存在的额外复杂性:

  • 复杂性可能仅仅来自组件数量的增加。

    组件越多:

    • 越难跟踪所有组件;
    • 越难在庞大的组件集合中找到想要的组件。

    拆分通常还会产生更多接口,而每增加一个接口都会增加复杂度。

  • 拆分可能产生额外的管理代码。

    例如:在拆分之前,一段代码只需要管理一个对象;而拆分之后,它可能需要同时管理多个对象。

  • 拆分会制造距离感(separation)。

    拆分后的组件彼此之间会比拆分前更遥远。例如:原本位于同一个类中的两个方法,拆分之后可能位于不同的类中,甚至不同的文件中。

    这种分离使开发者更难同时看到这些组件,甚至可能根本意识不到它们的存在。如果这些组件真正彼此独立,那么这种分离是有益的:它允许开发者专注于一个组件,而不会被其他组件干扰。但如果组件之间存在依赖关系,那么这种分离就是有害的:开发者将不得不频繁地在多个组件之间来回切换。更糟糕的是,他们可能意识不到这些依赖关系,从而引发缺陷。

  • 拆分可能导致重复。

    在拆分之前只存在一份的代码,在拆分之后可能需要出现在每一个子组件中。将代码放在一起最有价值的情况是:

这些代码彼此关系紧密。

如果两部分代码毫不相关,那么把它们分开通常会更好。下面是一些表明两段代码彼此相关的迹象:

  • 它们共享信息。

    例如:两段代码都依赖于某种特定文档格式的语法规则。

  • 它们总是一起被使用。

    使用其中一段代码的人,很可能也会使用另一段代码。只有当这种关系是双向的时候,这种理由才真正具有说服力。

    举一个反例:

    磁盘块缓存(disk block cache)几乎总会使用哈希表(hash table)。但哈希表还可以用于许多与块缓存无关的场景。因此,这两个模块应当保持分离。

  • 它们在概念上属于同一类别。

    也就是说,存在一个更高层次且简单的概念能够同时涵盖两者。

    例如:

    • 子串搜索(substring search)
    • 大小写转换(case conversion)

    都属于“字符串处理(string manipulation)”。

    又例如:

    • 流量控制(flow control)
    • 可靠传输(reliable delivery)

    都属于“网络通信(network communication)”。

  • 如果不查看另一部分代码,就很难理解其中一部分代码。

本章剩余内容将通过更具体的规则和实例说明:

  • 什么时候应该把代码放在一起;
  • 什么时候应该把代码拆开。

如果共享信息,就把它们放在一起

第 5.4 节曾在一个 HTTP 服务器项目的背景下介绍过这一原则。在最初的实现中,项目使用了两个位于不同类中的方法来读取并解析 HTTP 请求。

第一个方法:

  • 从网络套接字(socket)中读取请求文本;
  • 将其存放到一个字符串对象中。

第二个方法:

  • 对该字符串进行解析;
  • 提取请求中的各个组成部分。

在这种设计下,两个方法最终都掌握了大量关于 HTTP 请求格式的知识:第一个方法虽然只是想读取请求,而不是解析请求,但如果不完成解析工作的大部分内容,它根本无法判断请求在哪里结束。

例如:它必须解析请求头(header)行,才能找到包含整体请求长度信息的那个请求头。由于存在这些共享信息,最好的做法是把读取和解析请求的逻辑放在同一个地方。当这两个类被合并为一个之后,代码变得:

  • 更短;
  • 更简单。

如果合并能够简化接口,那就把它们放在一起

当两个或更多模块被合并成一个模块时,往往有机会为新的模块设计一个 比原有接口更简单、更易使用的接口

这种情况通常出现在原本的模块各自只负责解决问题的一部分时。

在上一节 HTTP 服务器的例子中,原来的两个方法之间需要一个接口:第一个方法必须返回 HTTP 请求字符串,然后将其传递给第二个方法。

当这两个方法被合并后,这个接口也就不再需要了。

此外,当两个或多个类的功能被整合到一起时,有些操作甚至可以自动完成,从而让大多数用户根本不需要知道这些功能的存在。

Java 的 I/O 库就是一个很好的例子。

如果 FileInputStreamBufferedInputStream 两个类被合并,并且默认自动提供缓冲功能,那么绝大多数用户甚至不需要知道“缓冲(buffering)”这一机制的存在。

一个合并后的 FileInputStream 类仍然可以提供关闭缓冲或替换默认缓冲机制的方法,但大多数用户根本不需要学习这些内容。

为了消除重复而进行整合

如果你发现同一种代码模式反复出现,那么应该考虑重新组织代码结构,以消除这些重复。

一种常见做法是:

将重复的代码提取到单独的方法中,然后用方法调用来替代原来的重复代码段。这种做法在以下情况下最有效:

  • 被提取的代码片段比较长;
  • 替代方法的接口(signature)比较简单。

如果代码片段只有一两行,那么用方法调用替代它所带来的收益可能并不明显。

如果这段代码与周围环境耦合得很紧密(例如访问了大量局部变量),那么提取出来的方法可能不得不接受许多参数(甚至大量引用参数),从而导致接口变得复杂,削弱重构带来的价值。

另一种消除重复的方法是:

重新组织代码,使得这段代码只需要存在于一个地方。

假设你正在编写一个方法,这个方法会在多个位置返回错误,而每次返回之前都必须执行相同的清理操作。

如果编程语言支持 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 编辑器的讨论展示了这一原则。

最佳设计方案中:

  • 文本类(Text Class)只负责提供通用的文本操作能力;
  • 与用户界面相关的操作(例如删除选中内容)则由 UI 模块实现。

这种设计带来了两个好处:

  1. 消除了信息泄漏(information leakage);
  2. 消除了额外接口。

而在早期设计中,这些面向界面的特殊操作被直接实现于文本类内部,因此导致了额外的耦合和接口复杂度。

Repetition(重复)

如果同一段代码(或者几乎相同的代码)一次又一次地出现,

这就是一个危险信号(Red Flag):

说明你还没有找到正确的抽象(abstraction)。

例:插入光标与文本选区

接下来的几个小节将通过两个实例来说明前面讨论的原则。

  • 在第一个例子中,最佳方案是将相关代码拆分开;
  • 在第二个例子中,最佳方案则是将相关代码合并在一起。

第一个例子来自第 6 章中的 GUI 编辑器项目。

编辑器会显示一个闪烁的竖线,用于指示用户输入的文本将会出现的位置,这个竖线称为 插入光标(insertion cursor)

编辑器还会显示一段高亮区域,用于表示一系列被选中的字符,这部分内容称为 选区(selection) ,可用于复制或删除文本。

插入光标始终可见;

而选区并不一定存在,因为有时用户并没有选中任何文本。当选区存在时,插入光标总是位于选区的一端,而光标和选区往往也是一起被操作的:

  • 鼠标点击并拖拽会同时设置它们两个;
  • 文本插入操作会先删除选中的文本(如果存在选区),然后再在光标位置插入新的文本。

因此,看起来似乎很合理:使用一个对象同时管理选区(selection)和光标(cursor)。

有一个项目团队就采用了这种设计。该对象保存:

  • 文件中的两个位置(position);
  • 一个布尔值,用于表示哪一端是光标位置;
  • 另一个布尔值,用于表示当前是否存在选区。

然而,这种组合对象(combined object)用起来却相当别扭。对于上层代码来说,它没有带来任何好处。因为上层代码仍然必须把选区和光标看作两个不同的实体来处理。

例如,在插入文本时:

  1. 首先调用组合对象的方法删除选中的文本;
  2. 然后再调用另一个方法获取光标位置;
  3. 最后在该位置插入新的文本。

也就是说,上层代码仍然是在分别操作选区和光标。实际上,这个组合对象的实现反而比独立对象更复杂。

它没有直接存储光标位置,而是:

  • 保存一个选区;
  • 再保存一个布尔值,用于表示选区的哪一端是光标。

因此,当需要获取光标位置时:

  1. 先检查这个布尔值;
  2. 再根据结果选择选区的起点或终点。

换句话说,光标位置是被间接表示的。

在这个例子中,选区和光标之间的关系并没有紧密到值得把它们合并成一个对象。因此后来代码被修改为:

将选区(selection)和光标(cursor)彻底分离。

结果无论是使用方式还是实现方式都变得更加简单。独立对象提供的接口比组合对象更简单,因为不再需要从一个对象中提取选区信息和光标信息。

光标的实现也因此变得更简单:光标位置被直接表示,而不是通过

  • 一个选区
  • 加上一个布尔值

来间接推导。事实上,在修改后的设计中:

  • 既没有专门的 Selection 类;
  • 也没有专门的 Cursor 类。

取而代之的是引入了一个新的 Position 类,用于表示文件中的一个位置:

  • 行号(line number)
  • 行内字符位置(character offset)

选区由两个 Position 表示,光标由一个 Position 表示。

Position 类后来还在项目中的其他地方得到了复用。这个例子也再次说明了:

底层但更加通用的接口往往更有价值。

这一思想在第 6 章已经讨论过。

Special-General Mixture(专用与通用混杂)

当一个通用机制(general-purpose mechanism)中混入了针对特定用途的专用代码时,就会出现这一危险信号。

这种设计会导致:

  1. 通用机制变得更加复杂;
  2. 通用机制与具体应用场景之间产生信息泄漏(information leakage);
  3. 当未来修改特定业务逻辑时,很可能不得不同时修改底层通用机制。

为日志单独创建类(Example: separate class for logging)

第二个例子来自一个学生项目中的错误日志系统。某个类中包含了大量类似下面这样的代码:

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 类中包含多个类似的方法:

  • logRpcSendError
  • logRpcReceiveError
  • 等等

每个方法负责记录一种不同类型的错误。然而,这种拆分实际上只增加了复杂度,却没有带来任何收益。

这些日志方法通常只有一行真正的代码,但却需要大量文档说明。而且每个方法往往只会在一个地方被调用。

这些日志方法与其调用点高度耦合。阅读调用代码的人:

NetworkErrorLogger.logRpcOpenError(...)

往往需要翻到日志方法的实现中,才能确认到底记录了哪些内容。

反过来,阅读日志方法的人也必须回到调用点,才能理解这个日志出现的具体上下文。

在这个例子中,更好的做法是:直接在发现错误的位置写日志语句。这样:

  • 代码更容易阅读;
  • 不再需要日志方法接口;
  • 减少了不必要的抽象层。

拆分方法与合并方法(Splitting and Joining Methods)

关于“什么时候应该拆分”的问题,不仅适用于类(class),也适用于方法(method)。

例如:

  • 是否应该把一个方法拆成多个小方法?
  • 或者是否应该把两个小方法合并成一个大方法?

长方法通常比短方法更难理解。因此很多人认为:

只要方法太长,就应该拆分。

在很多编程课程中甚至会给出硬性规则:

“任何超过 20 行的方法都必须拆分。”

然而,仅仅因为方法很长,往往不足以成为拆分它的理由。

实际上,开发者经常会把方法拆得过度。拆分会引入额外接口,而接口本身就会增加复杂度。同时,原本属于一个整体的代码被强行拆开后,如果这些部分实际上关系密切,反而会让代码更难阅读。

因此: 除非拆分后能够让整个系统变得更简单,否则不要拆分方法。

下面作者会进一步讨论这一点。长方法并不总是坏事。

例如:

假设一个方法中有五个长度约为 20 行的代码块,它们按照顺序依次执行。如果这些代码块之间相对独立,那么读者完全可以:

  • 看完第一个代码块;
  • 理解它;
  • 再继续阅读下一个代码块。

在这种情况下,把每个代码块拆成单独的方法并不会带来多少收益。

如果这些代码块之间存在复杂交互,那么更应该把它们放在一起。这样读者能够一次性看到所有相关逻辑。

否则,如果每个代码块都被拆到不同的方法中,读者就不得不在多个方法之间来回跳转,才能理解整个流程是如何运作的。

因此:即使一个方法有数百行代码,只要:

  • 接口简单(simple signature);
  • 容易阅读;

它仍然是可以接受的。这样的代码通常具有较高的“深度(deep)”:

  • 功能丰富;
  • 接口简单;

而这正是优秀设计的重要特征。当设计方法时,最重要的目标是:

提供清晰、干净的抽象(clean abstractions)。

  • Interface:接口
  • Implementation:实现
  • Callers:调用者

(a) 原始方法

(b) 提取一个子任务(推荐)

(c) 拆成两个独立方法(很少是好方案)

(d) 产生多个浅层方法(应避免)

一个方法(a)可以通过两种方式拆分:

  • 提取一个子任务成为独立方法(b)
  • 将原有功能拆分为两个独立方法(c)

如果拆分后得到的是一堆浅层(shallow)方法,如(d)所示,那么就不应该进行拆分。

每个方法都应该只做一件事,并且把它做好

一个方法应该具有简单的接口,这样用户不需要在脑子里记住太多信息就能正确使用它。方法应该是 深的(deep) :也就是说,它的接口应该远比它的实现简单。如果一个方法具备这些特征,那么它是否很长其实并不重要。

只有当拆分能够让整体抽象更加清晰时,拆分方法才有意义。

下面是两种拆分方式。

第一种:提取子任务

最好的拆分方式通常是:将某个子任务抽取为独立方法。拆分后:

  • 子方法(child method)负责该子任务;
  • 父方法(parent method)保留剩余逻辑;
  • 父方法调用子方法。

新父方法的接口与原方法保持一致。这种拆分只有在子任务能够与原方法其它部分清晰分离时才有意义。理想情况下:

条件一

阅读子方法的人不需要了解父方法的实现细节。

条件二

阅读父方法的人不需要理解子方法的内部实现。通常这意味着:子方法本身是比较通用的。

除了父方法之外,它理论上也可能被其他方法复用。如果你进行了这样的拆分,结果却发现自己必须不断在父方法和子方法之间来回切换,

才能理解它们如何协同工作,那么这就是一个危险信号:

Conjoined Methods(连体方法)

说明这次拆分大概率是错误的。

第二种:拆成两个独立方法

另一种方式是:把原方法拆成两个独立方法,两个方法都直接暴露给原来的调用者。

这种做法适用于:原方法接口过于复杂,因为它试图完成多个彼此关联不强的任务。如果确实如此,那么可以把原来的功能拆成多个较小的方法,每个方法只负责原来的一部分功能。这种拆分后,每个新方法的接口都应该比原方法更简单。理想情况下:大部分调用者只需要调用其中一个方法即可。

如果调用者必须同时调用两个方法,那就意味着:

  • 调用复杂度增加了;
  • 说明拆分未必合理。

一个积极信号是:拆出来的新方法比原方法更具通用性。换句话说,你能够想象它们在其他场景中被单独使用。

第二种类型的拆分通常并不常见

因为它会让调用者原本只需要面对一个方法,现在却必须面对多个方法。更糟糕的是,这种拆分很容易产生多个浅层方法。

如果调用者必须:

  • 依次调用这些拆分出来的方法;
  • 在方法之间传递状态;

那么拆分通常就是错误的。因此,判断标准应该是:

它是否让调用者的工作变得更简单?

如果没有,那就不要拆。

方法合并(Joining Methods)

有时候,把多个方法合并起来反而能让系统变得更简单。例如:

1. 用一个深方法取代多个浅方法

合并后,两个浅方法可能变成一个更深的方法。

2. 消除重复代码

多个方法中重复的逻辑可以被统一。

3. 消除依赖关系

原本方法之间的依赖、中间数据结构,都可能被删除。

4. 提高封装性

原本分散在多个地方的知识,现在被集中在一个地方。

5. 简化接口

正如 9.2 节所讨论的那样,方法合并往往能够减少接口数量。

Conjoined Methods(连体方法)

每个方法都应该能够被独立理解。如果:

想理解方法 A,

必须先理解方法 B 的实现,

那么这就是一个危险信号。这种问题不仅会出现在方法上。

只要:

  • 两段代码物理上是分开的;
  • 但理解其中一段必须同时查看另一段;

那么就属于同样的问题。

不同的观点:《Clean Code》

在《Clean Code》一书中,Robert Martin 主张:函数应该仅仅根据长度进行拆分。他认为函数应该极其短小,甚至连 10 行代码都太长。

他写道:

函数的第一条规则:

它应该很小。

第二条规则:

它应该更小。

if、else、while 等语句块中的内容应该只有一行。

而这一行最好是一个函数调用。

这意味着函数不应该大到足以容纳嵌套结构。

因此函数的缩进层级不应超过一层或两层。

当然,这会让函数更容易阅读和理解。

我同意:短函数通常比长函数更容易理解。但是,当一个函数已经缩短到几十行以内时,继续缩短带来的收益通常已经很有限。更重要的问题其实是:

拆分函数是否降低了整个系统的复杂度?

换句话说,究竟是

  • 阅读一个较大的函数更容易;

还是

  • 阅读多个小函数并理解它们之间的关系更容易?

函数越多,就意味着:

  • 需要学习更多接口;
  • 需要维护更多抽象。

如果函数被拆得太小,它们会失去独立性,从而形成前面提到的:

Conjoined Functions(连体函数)

必须一起阅读和理解。当出现这种情况时,保留那个较大的函数反而更好。因为所有相关逻辑都集中在一个地方。

作者给出的原则是:

深度(Depth)比长度(Length)更重要。

首先让函数变得深,然后再让它足够短,以保证可读性。

不要为了追求短小,而牺牲深度。

总结(Conclusion)

决定模块应该拆分还是合并时,判断依据应该是: 复杂度(complexity) 而不是尺寸大小。

请选择能够实现以下目标的结构:

  • 最好的信息隐藏(Information Hiding)
  • 最少的依赖关系(Dependencies)
  • 最深的接口(Deep Interfaces)

这才是拆分与合并模块时应该遵循的原则。

这一章(Chapter 9: Better Together Or Better Apart?)的核心思想可以浓缩成一句话:

不要迷信“小而美”。

拆分与合并的唯一标准是:是否降低了系统整体复杂度。

深而简单的抽象,比大量短小但互相依赖的抽象更有价值。