小马的世界

读书笔记-软件设计的哲学【7】不同层,不同抽象

2026-05-26 · 16 min read

软件系统是由多层组成的,高层使用低层提供的能力。

在一个设计良好的系统中,每一层都会提供一种不同于其上下层的抽象;如果你跟踪一个操作在层之间通过方法调用不断向上、向下流动的过程,你会发现抽象会随着每一次方法调用而发生变化。

例如:

  • 在文件系统中,最上层实现的是“文件”这一抽象。文件由一个可变长度的字节数组构成,可以通过读取和写入不同长度的字节区间来更新。文件系统中的下一层实现的是内存中的固定大小磁盘块缓存;调用者可以假设,频繁使用的 block 会保留在内存中,从而能够被快速访问。最底层则由设备驱动组成,它们负责在二级存储设备与内存之间搬运 block。

  • 在 TCP 这样的网络传输协议中,最上层提供的抽象是:一种能够可靠地把字节流从一台机器传送到另一台机器的机制。这一层建立在更低层之上,而低层负责在机器之间以“尽力而为(best-effort)”的方式传输有大小限制的数据包:大多数 packet 都能够成功送达,但有些 packet 可能会丢失,或者乱序到达。

如果系统中相邻层拥有相似的抽象,那么这是一个危险信号,说明类的拆分方式可能存在问题。

本章将讨论:

  • 这种情况是如何出现的
  • 它会带来哪些问题
  • 如何通过重构来消除这些问题

7.1 Pass-through 方法

当相邻层具有相似抽象时,这个问题通常会表现为“pass-through method”的形式。

所谓 pass-through method,是指一个方法除了调用另一个方法之外几乎什么都不做,而被调用方法的签名与当前方法相同或几乎相同。

例如,在一个实现 GUI 文本编辑器的学生项目中,有一个类几乎完全由 pass-through method 组成。

下面是其中一部分代码:

public class TextDocument ... {
    private TextArea textArea;
    private TextDocumentListener listener;
    ...

    public Character getLastTypedCharacter() {
        return textArea.getLastTypedCharacter();
    }

    public int getCursorOffset() {
        return textArea.getCursorOffset();
    }

    public void insertString(String textToInsert,
            int offset) {
        textArea.insertString(textToInsert, offset);
    }

    public void willInsertString(String stringToInsert,
            int offset) {
        if (listener != null) {
            listener.willInsertString(this,
                    stringToInsert, offset);
        }
    }
    ...
}

危险信号:Pass-Through Method

pass-through method 指的是一个方法除了把参数传递给另一个方法之外,不做任何事情;而且通常它拥有与被调用方法相同的 API。

这通常意味着这些类之间并不存在清晰的职责划分。

15 个 public 方法中,有 13 个都是 pass-through method。pass-through method 会让类变得更浅(shallower):它们增加了类接口的复杂度,从而增加系统复杂度,但却没有增加系统整体功能。在前面展示的四个方法中,只有最后一个真正做了一点事情,而即便如此,它的功能也非常简单:只是检查了一个变量是否有效。

pass-through method 还会在类之间制造依赖关系:如果 TextAreainsertString 方法的签名发生变化,那么 TextDocument 中的 insertString 方法也必须随之修改。

pass-through method 表明:类之间的职责划分存在混乱。

在前面的例子中,TextDocument 类提供了 insertString 方法,但真正负责文本插入功能的实现却完全位于 TextArea 中。

这通常是一个糟糕的设计:某项功能的接口,应当与实现该功能的代码位于同一个类中。

当你看到一个类把方法 pass-through 给另一个类时,可以思考下面这个问题:

“这两个类分别到底负责哪些功能与抽象?”

你很可能会发现。这些类之间的职责出现了重叠。解决办法是重构这些类,使每个类都拥有清晰且一致的职责集合。

其中一种是,直接把底层类暴露给高层类的调用者,从而让高层类彻底不再负责该功能。

另一种方式是,重新分配类之间的功能。

最后,如果这些类实在无法解耦,那么最好的方案是,把它们合并。

在前面的例子中,共有三个职责相互缠绕的类:

  • TextDocument
  • TextArea
  • TextDocumentListener

学生最终通过:

  • 在类之间移动方法
  • 并把三个类压缩为两个类

来消除了这些 pass-through method。而新的两个类,其职责划分也更加清晰。

7.2 什么时候接口重复是可以接受的?

拥有相同签名的方法,并不一定是坏事。关键在于:每个新增的方法,都必须提供重要的新功能。
pass-through method 之所以糟糕,是因为它们没有提供任何新功能。

有一种情况中:方法调用另一个拥有相同签名的方法,反而是有意义的。这种方法称为 dispatcher(分发器)。

dispatcher 是一种方法:它利用自己的参数,从多个方法中选择一个进行调用;随后,它会把大部分甚至全部参数传递给被选中的方法。dispatcher 的签名通常与它所调用的方法签名相同。即便如此,dispatcher 仍然提供了有价值的功能:

它负责决定:

哪一个方法应该执行当前任务。

例如:当 Web 服务器收到来自浏览器的 HTTP 请求时,它会调用一个 dispatcher。这个 dispatcher 会检查请求中的 URL,并选择某个特定方法来处理请求。有些 URL 可能会通过读取磁盘文件来处理;另一些 URL 则可能通过执行 PHP 或 JavaScript 程序来处理。

dispatch 过程本身可能非常复杂。

通常,它由一组规则驱动,而这些规则会与传入 URL 进行匹配。只要每个方法都提供了有价值且不同的功能,那么多个方法拥有相同签名就是合理的。dispatcher 所调用的方法就具备这一特点。

另一个例子是:

操作系统中具有多种实现的接口,例如磁盘驱动。每个 driver 都为不同类型的磁盘提供支持,但它们拥有相同接口。当多个方法提供同一接口的不同实现时,它能够降低认知负担。一旦你已经学会使用其中一个方法,那么使用其他方法也会更容易,因为你不需要重新学习新的接口。这类方法通常位于同一层,并且它们提供的是相同抽象的不同实现。

7.3 Decorator

Decorator 设计模式(也被称为 “wrapper”)是一种会鼓励跨层 API 重复的模式。Decorator 对象接收一个已有对象,并在其基础上扩展功能;它提供与底层对象相同或相似的 API,而它自身的方法则会调用底层对象的方法。

在第 4 章 Java I/O 的例子中,BufferedInputStream 类就是一个 decorator:给定一个 InputStream 对象后,它提供了带有 buffering 能力的同一套 API。

例如:当调用它的 read 方法读取单个字符时,它实际上会调用底层 InputStreamread 方法来读取一个更大的 block,并把额外读取到的字符保存下来,以满足后续的 read 调用。

另一个例子出现在窗口系统中:一个 Window 类实现的是一种不能滚动的简单窗口;而 ScrollableWindow 类则通过增加水平和垂直滚动条来 decorate Window 类。

Decorator 的动机,是把某个类中的专用扩展,从更加通用的核心中分离出来。然而,decorator 类通常都很浅(shallow):它们会引入大量样板代码(boilerplate),但新增功能却很少。Decorator 类中通常包含大量 pass-through method。

Decorator 模式很容易被过度使用:每增加一个小功能,就创建一个新类。这样会导致大量 shallow class 的爆炸式增长,就像 Java I/O 的例子那样。在创建 decorator 类之前,可以考虑下面这些替代方案:

  • 是否可以直接把新功能加入到底层类中,而不是创建 decorator 类?

如果这个新功能本身相对通用,或者它在逻辑上与底层类密切相关,又或者大多数使用底层类的场景都会使用这个新功能,那么这样做是合理的。例如:几乎所有创建 Java InputStream 的人,都会再创建一个 BufferedInputStream;而 buffering 本来就是 I/O 的天然组成部分,因此这些类本应被合并。

  • 如果这个新功能是针对某个特定 use case 的,那么是否可以把它直接合并进 use case 中,而不是单独创建一个类?
  • 是否可以把新功能并入一个已有 decorator,而不是创建新的 decorator?

这样会得到一个更深(deeper)的 decorator 类,而不是多个浅层 decorator。

  • 最后,问问自己:
  • 新功能是否真的需要“包装”已有功能?
  • 是否可以把它实现为一个独立类,而不依赖于 base class?
  • 在窗口系统的例子中,scrollbar 很可能就可以独立于主窗口实现,而无需包装原有全部功能。

有时候 wrapper 的确是合理的。一种情况是:系统使用了一个外部类,而该类的 interface 无法修改,但它在当前应用中又必须符合另一套 interface。在这种情况下,可以使用 wrapper class 在两个 interface 之间进行转换。不过,这种情况比较少见;通常都会存在比 wrapper 更好的方案。

7.4 Interface 与 Implementation

“不同层,不同抽象(different layer, different abstraction)”这一原则的另一个应用是:一个类的 interface 通常应当与其 implementation 不同。也就是说:内部实现所使用的表示方式,应当不同于 interface 中出现的抽象。如果两者拥有相似抽象,那么这个类很可能不够深(deep)。

例如:在第 6 章讨论的文本编辑器项目中,大多数团队都把文本模块实现为“按行存储文本”:每一行文本被单独存储。一些团队还围绕“行”来设计文本类 API,例如:

  • getLine
  • putLine

然而,这会让文本类变得浅且难用。在高层用户界面代码中,经常需要:

  • 在一行中间插入文本(例如用户输入时)
  • 删除跨越多行的文本区间

如果文本类采用“按行”的 API,那么调用者就不得不:

  • 拆分行
  • 合并行

来实现用户界面操作。这些代码不仅复杂,而且会分散并重复出现在用户界面实现中。当文本类提供“按字符”的 interface 时,会容易使用得多。例如:

  • 一个 insert 方法,可以在文本中的任意位置插入任意字符串(其中可能包含换行)
  • 一个 delete 方法,可以删除两个任意位置之间的文本

在内部,文本仍然是按行表示的。但“按字符”的 interface 把:

  • 行拆分
  • 行合并

这些复杂性封装在了文本类内部。这样会让文本类变得更深,并简化使用该类的高层代码。在这种做法中:文本 API 与内部“按行存储”的机制有着明显区别;而这种区别,正体现了该类所提供的价值。

7.5 Pass-through 变量

另一种跨层 API 重复的形式,是 pass-through variable。所谓 pass-through variable,是指:一个变量被沿着很长的方法调用链不断向下传递。

下面是一个来自数据中心服务(datacenter service)的例子。某个命令行参数描述了用于安全通信的证书(certificate)。这些信息实际上只会被底层方法 m3 使用;m3 会调用一个库方法来打开 socket。然而,这个变量却被一路传递经过了从 mainm3 之间的所有方法。cert 变量出现在所有中间方法的签名中。

pass-through variable 会增加复杂度,因为:它迫使所有中间方法都必须知道这个变量的存在,即便这些方法根本不会使用它。此外,如果后来系统新增了一个变量(例如系统最初不支持 certificate,但后来决定增加支持),那么你可能不得不修改大量 interface 和方法,以便把该变量沿着所有相关路径继续向下传递。

消除 pass-through variable 往往并不容易。一种做法是:

检查最上层方法与最底层方法之间,是否已经共享某个对象。在 datacenter service 示例中,也许已经存在一个对象,其中保存了其他网络通信信息,并且 mainm3 都能够访问它。如果是这样,那么 main 可以把 certificate 信息保存在这个对象中,这样它就不需要再通过所有中间方法传递给 m3

不过,如果存在这样一个共享对象,那么它本身也可能成为 pass-through variable。(否则 m3 是如何访问到它的呢?)另一种做法是把信息保存在 global variable 中。这样就不需要在方法之间逐层传递信息。但是 global variable 几乎总会带来其他问题。

例如:global variable 会让你无法在同一个进程中创建两个相互独立的系统实例,因为它们对 global variable 的访问会互相冲突。

在生产环境中,你可能觉得自己不太可能需要多个实例;但在测试中,多实例往往非常有用。我最常使用的解决方案,引入一个 context object。context 用于保存应用程序中的所有“全局状态”:也就是那些原本会成为 pass-through variable 或 global variable 的内容。

大多数应用程序都会在全局状态中包含多个变量,例如:

  • 配置选项(configuration options)
  • 共享子系统
  • 性能计数器(performance counters)

系统的每个实例拥有一个 context object。这样,就能够让同一个进程中同时存在多个系统实例,并且每个实例拥有自己的 context。

遗憾的是,context 很可能会在很多地方被使用,因此它本身也可能变成 pass-through variable。为了减少需要知道 context 存在的方法数量,可以把对 context 的引用保存在系统中大多数主要对象内部。

当创建新对象时,创建方法会从自己的对象中取得 context 引用,并把它传递给新对象的构造函数。

采用这种方式后,context 在系统中几乎随处可用,但它只会作为显式参数出现在 constructor 中。context object 能够统一处理所有系统级全局信息,并消除 pass-through variable 的需求。如果需要新增一个变量,只需把它加入 context object;除了 context 的 constructor 和 destructor 之外,现有代码都不需要修改。

context 还使系统全局状态更容易识别与管理,因为所有内容都集中保存在一个地方。它对于测试也非常方便:测试代码可以通过修改 context 中的字段,来改变应用程序的全局配置。如果系统使用的是 pass-through variable,那么实现这样的修改会困难得多。

不过,context 远不是理想方案。保存在 context 中的变量,会继承 global variable 的大部分缺点。

例如某个变量为什么存在、在哪里被使用,可能并不明显。如果缺乏约束,context 很容易变成一个巨大的“数据垃圾袋(grab-bag)”,从而在系统各处制造隐式依赖关系。context 还可能带来线程安全问题。避免问题的最好方式,是让 context 中的变量保持 immutable(不可变)。遗憾的是到目前为止,我还没有找到比 context 更好的方案。

7.6 结论

系统中新增的每一项设计基础设施——例如:

  • interface
  • argument
  • function
  • class
  • definition

都会增加复杂度,因为开发者必须学习这些元素。因此,一个设计元素若想在复杂度上带来“净收益”,它必须消除一些“如果没有这个设计元素,本来会存在的复杂度”。否则,还不如根本不要引入这个设计元素。

例如一个 class 可以通过封装功能来降低复杂度,使 class 的使用者无需了解这些功能内部实现。“different layer, different abstraction(不同层,不同抽象)”这一原则,本质上就是上述思想的一个应用。如果不同层拥有相同抽象,例如:

  • pass-through method
  • decorator

那么很可能意味着这些额外基础设施,并没有提供足够收益来抵消它们自身带来的复杂度。

pass-through argument 会迫使多个方法都必须知道它们的存在(从而增加复杂度),但却没有提供额外功能。