小马的世界

读书笔记-软件设计的哲学【4】模块应该是“深”的(Modules Should Be Deep)

2026-01-19 · 17 min read

管理软件复杂性最重要的技术之一,是在设计系统时,让开发者在任何给定时刻只需要面对系统整体复杂性的一小部分。这种方法称为模块化设计(modular design),本章将介绍其基本原则。

4.1 模块化设计(Modular design)

在模块化设计中,一个软件系统会被分解为一组相对独立的模块。模块可以有多种形式,例如类、子系统或服务。在理想情况下,每个模块都应当完全独立于其他模块:开发者可以在任何一个模块中工作,而不需要了解其他模块的任何细节。在这样的世界里,系统的复杂度将等于其最复杂模块的复杂度。

不幸的是,这个理想无法完全实现。模块必须通过调用彼此的函数或方法来协同工作,因此模块之间不可避免地需要了解彼此的一些信息。于是就会产生依赖关系:当一个模块发生变化时,其他模块可能也需要随之调整。例如,一个方法的参数列表会在该方法与所有调用它的代码之间形成依赖;如果参数发生变化,所有调用点都必须修改以匹配新的签名。依赖关系还可以以其他更微妙的形式存在,例如:某个方法只有在另一个方法先被调用之后才能正确工作。模块化设计的目标,是最小化模块之间的依赖关系。

为了识别和管理依赖关系,我们通常将每个模块分为两部分:接口(interface) 和 实现(implementation)。
接口包含了其他模块的开发者在使用该模块时必须知道的一切信息。通常, 接口描述的是模块做什么(what),而不是怎么做(how) 。实现则是完成接口所承诺功能的具体代码。

在某个模块中工作的开发者,需要理解该模块的接口和实现,以及它所调用的其他模块的接口;但他或她不应该需要理解那些其他模块的实现细节。

举一个实现了平衡树的模块作为例子。该模块内部可能包含相当复杂的代码,用来确保树始终保持平衡;但这些复杂性对模块的使用者是不可见的。使用者看到的是一个相对简单的接口,用于插入、删除和查找节点。调用插入操作时,调用方只需提供新节点的键和值;遍历树、分裂节点等机制并不会暴露在接口中。

在本书中,模块被定义为:任何同时具有接口和实现的代码单元。在面向对象语言中,每个类都是一个模块;在非面向对象语言中,类中的方法,或独立的函数,也都可以视为模块——它们同样有接口和实现,模块化设计的技术同样适用。更高层次的子系统和服务也是模块,只是它们的接口形式可能不同,例如内核调用或 HTTP 请求。本书中关于模块化设计的大部分讨论以类为例,但这些技术和概念同样适用于其他类型的模块。

最好的模块,是那些接口远比实现简单的模块。

这样的模块有两个主要优势:

  • 简单的接口可以将模块施加给系统其他部分的复杂性降到最低;
  • 如果模块的修改不涉及接口变化,那么系统中的其他模块就不会受到影响。

当模块的接口明显比其实现简单时,就会有很多内部细节可以被修改,而不影响其他模块。

4.2 接口中包含什么?

模块的接口包含两类信息:形式化信息(formal) 和 非形式化信息(informal)。

形式化接口是明确写在代码中的,并且可以由编程语言检查其正确性。例如,一个方法的形式化接口是它的签名,包括参数的名称和类型、返回值的类型,以及可能抛出的异常信息。大多数编程语言都会确保方法调用时,参数的数量和类型与签名匹配。一个类的形式化接口由其所有公共方法的签名,以及所有公共变量的名称和类型组成。

接口中还包含非形式化的元素。这些信息无法被编程语言直接理解或强制执行。非形式化接口包括模块的高层行为,例如:某个函数会删除由其某个参数指定的文件;或者对类的使用存在约束(例如,某个方法必须在另一个方法之前被调用)。只要开发者在使用模块时需要知道某条信息,那它就是该模块接口的一部分。

接口的非形式化部分只能通过注释或文档来描述,而编程语言无法保证这些描述是完整或准确的。事实上,在大多数接口中,非形式化部分往往比形式化部分更大、更复杂。

一个接口清晰定义的好处之一,是它能够准确说明开发者在使用该模块时需要知道什么信息,从而帮助消除第 2.2 节中提到的“未知的未知”问题。

4.3 抽象(Abstractions)

抽象(abstraction) 与模块化设计的思想密切相关。抽象是对某个实体的简化视图,它省略了不重要的细节。抽象之所以有价值,是因为它让我们更容易思考和操作复杂的事物。

在模块化编程中,每个模块都通过其接口提供了一种抽象。接口呈现的是模块功能的简化视角;它隐藏了实现中的复杂细节,只暴露使用者真正关心的部分。

脚注说明:
在研究领域中,确实存在一些编程语言,可以使用形式化规范语言来精确描述方法或函数的整体行为,并自动检查规范与实现是否一致。一个有趣的问题是:这种形式化规范是否可以取代接口中的非形式化部分?作者目前的观点是:用自然语言(如英语)描述的接口,对开发者来说,往往比形式化规范语言更直观、更容易理解。

从模块抽象的角度看,实现中的许多细节是 不重要的 ,因此应当被 省略在接口之外

在抽象的定义中,“不重要”这个词至关重要。一个抽象省略的不重要细节越多,抽象就越好。然而,只有当某个细节 确实不重要 时,它才应该被省略。抽象可能在两种情况下出错:
第一种是包含了并不真正重要的细节;这样会让抽象变得不必要地复杂,增加使用该抽象的开发者的 认知负担
第二种错误是省略了 实际上很重要 的细节。这会导致 晦涩性 :只看抽象的开发者无法获得正确使用该抽象所需的全部信息。省略了重要细节的抽象是一种 虚假抽象(false abstraction) :它看起来很简单,但实际上并非如此。设计抽象的关键,在于理解什么才是重要的,并寻找能够 最小化重要信息数量 的设计。

举个例子,考虑一个 文件系统 。文件系统提供的抽象省略了许多细节,例如:为某个文件的数据选择存储设备上哪些块。这些细节对文件系统的使用者并不重要(只要系统能提供足够的性能)。然而,文件系统实现中的某些细节对用户是重要的。大多数文件系统会将数据缓存在主存中,并可能延迟将新数据写入存储设备以提升性能。一些应用(如数据库)需要确切地知道数据 何时 被写入到存储介质,以确保系统崩溃后数据仍然存在。因此,将数据刷新到二级存储的规则, 必须 在文件系统的接口中可见。

我们不仅在编程中依赖抽象来管理复杂性,在日常生活中也是如此。微波炉内部包含复杂的电子元件,用于将交流电转换为微波并在加热腔内分布;而用户看到的却是一个简单得多的抽象:几个按钮,用来控制时间和功率。汽车也通过抽象,让我们无需理解电机控制、电池管理、防抱死制动、巡航控制等机制,就能驾驶它们。

4.4 深模块(Deep modules)

最好的模块,是那些 功能强大但接口简单 的模块。作者用“深(deep)”来描述这种模块。为了形象化“深度”的概念,设想每个模块都由一个矩形表示,如图 4.1 所示。矩形的面积与模块实现的功能量成正比;矩形的上边表示模块的接口,该边的长度表示接口的复杂度。

最好的模块是深的:它们在一个简单的接口背后隐藏了大量功能。深模块之所以是好的抽象,是因为只有很小一部分内部复杂性会暴露给使用者。

模块的“深度”是一种从 成本—收益 角度思考设计的方式。模块带来的收益是它的功能;模块的成本(从系统复杂性的角度)是它的接口。模块的接口代表了它向系统其他部分施加的复杂性:接口越小、越简单,引入的复杂性就越少。最好的模块,是 收益最大、成本最小 的模块。接口是好东西,但 更多或更大的接口并不一定更好

Unix 操作系统及其后代(如 Linux)所提供的文件 I/O 机制,是一个深接口的经典例子。用于 I/O 的基础系统调用只有五个,且签名都很简单:

int     open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t   lseek(int fd, off_t offset, int referencePosition);
int     close(int fd);

open 系统调用接受一个分层的文件名(如 /a/b/c),并返回一个整数形式的 文件描述符 ,用于引用已打开的文件。其他参数用于指定可选信息,例如:以读或写方式打开、在文件不存在时是否创建新文件、以及新文件的访问权限。
readwrite 系统调用在应用内存中的缓冲区与文件之间传输数据;close 结束对文件的访问。大多数文件以顺序方式访问,这是默认行为;但通过调用 lseek 改变当前访问位置,也可以实现随机访问。

Unix I/O 接口的现代实现需要 数十万行代码 ,用来处理诸多复杂问题,例如:

  • 如何在磁盘上表示文件以支持高效访问?
  • 目录如何存储?层级路径名如何被解析以定位文件?
  • 如何强制权限控制,防止一个用户修改或删除另一个用户的文件?
  • 文件访问是如何实现的?例如,中断处理程序与后台代码如何分工并安全通信?
  • 当多个文件被并发访问时,采用哪些调度策略?
  • 如何将最近访问的文件数据缓存在内存中,以减少磁盘访问次数?
  • 如何将多种不同的二级存储设备(如磁盘、闪存)整合进一个统一的文件系统?

这些问题,以及更多问题,都由 Unix 文件系统的实现来处理; 对调用系统调用的程序员来说,它们是不可见的 。多年来,Unix I/O 接口的实现发生了巨大变化,但这五个基本的内核调用却几乎没有改变。

另一个深模块的例子,是 Go 或 Java 等语言中的 垃圾回收器 。这个模块甚至 没有接口 :它在后台悄然工作,回收未使用的内存。将垃圾回收加入系统,实际上还 缩小 了整体接口,因为它消除了“释放对象”的接口。实现本身极其复杂,但对使用者完全隐藏。

4.5 浅模块(Shallow modules)

相对而言, 浅模块 指的是: 与其所提供的功能相比,其接口显得相对复杂的模块
例如,实现链表(linked list)的一个类就是浅的。操作链表并不需要太多代码(插入或删除一个元素通常只要几行),因此链表这种抽象并没有隐藏很多细节。链表接口的复杂度几乎和其实现的复杂度一样高。

像链表这样的浅类有时是不可避免的,也可能仍然有用,但它们在对抗复杂性方面提供的“杠杆”很有限。

下面是一个极端的浅方法示例,来自一门软件设计课程中的项目:

private void addNullValueForAttribute(String attribute) {
    data.put(attribute, null);
}

从管理复杂性的角度来看,这个方法 让事情变得更糟,而不是更好
这个方法没有提供任何抽象,因为它的全部功能都直接暴露在接口中。比如,调用者很可能需要知道 attribute 会被存放在 data 这个变量里。思考这个接口,并不比直接思考完整实现更简单。

如果这个方法被正确地写了文档,那么 文档本身可能比方法代码还要长 。甚至调用这个方法所需要敲的键,比调用者直接操作 data 变量还要多。这个方法引入了复杂性(以一个新的接口供开发者学习的形式),却没有提供任何相应的收益。


⚠️ 红旗:浅模块(Red Flag: Shallow Module)

浅模块的定义是: 其接口相对于它所提供的功能而言过于复杂
浅模块在与复杂性作斗争中几乎没有帮助,因为它们带来的好处(不必了解其内部实现)被学习和使用其接口的成本所抵消了。 小模块往往是浅模块


4.6 类分裂症(Classitis)

不幸的是, 深类(deep classes)的价值在当今并未得到广泛认可
编程领域的传统智慧是:类应该“小”,而不是“深”。

学生常被教导,类设计中最重要的事情是把大的类拆成小的类;对方法也是类似的建议:“任何超过 N 行的方法都应该拆成多个方法”(N 甚至可以小到 10)。
这种做法会导致大量的浅类和浅方法,从而增加系统的整体复杂性。

“类应该小”的极端表现是一种我称之为 类分裂症(classitis) 的综合症,它源于一种错误的观念:“类是好东西,所以类越多越好”。
在患有类分裂症的系统中,开发者被鼓励在每个新类中尽量减少功能;如果需要更多功能,就再引入更多的类。

类分裂症可能会产生 单个看起来都很简单的类 ,但它会增加整个系统的复杂性。小类本身贡献的功能有限,因此必须有很多个,每一个都有自己的接口。这些接口不断累积,会在系统层面造成巨大的复杂性。

小类还会导致代码风格变得冗长,因为每个类都需要大量样板代码(boilerplate)。

4.7 示例:Java 与 Unix I/O

当今最显眼的类分裂症例子之一是 Java 类库
Java 语言本身并不要求大量小类,但在 Java 编程社区中却形成了一种类分裂症文化。

例如,多年来,Java 开发者为了打开一个文件并读取序列化对象,需要创建三个不同的对象:

FileInputStream fileStream =
    new FileInputStream(fileName);
BufferedInputStream bufferedStream =
    new BufferedInputStream(fileStream);
ObjectInputStream objectStream =
    new ObjectInputStream(bufferedStream);

FileInputStream 只提供非常基础的 I/O:它不能进行缓冲 I/O,也不能读写序列化对象。
BufferedInputStreamFileInputStream 之上增加了缓冲能力,
ObjectInputStream 又在其之上增加了读写序列化对象的能力。

在上述代码中,一旦文件被打开,fileStreambufferedStream 就再也不会被使用,后续所有操作都通过 objectStream 进行。

特别令人恼火(而且容易出错)的是: 缓冲必须通过显式创建 BufferedInputStream 来请求 ;如果开发者忘记创建这个对象,就不会有缓冲,I/O 性能会很差。

Java 开发者可能会辩解说,并不是所有人都希望对文件 I/O 使用缓冲,因此不应该把缓冲机制内建进基础机制中。他们可能认为,把缓冲机制单独拆出来,让用户自行选择是否使用,是更好的设计。

提供选择当然是好事,但接口应该被设计成让“常见情况”尽可能简单。
几乎所有文件 I/O 的用户都会希望有缓冲,因此缓冲应该是默认提供的。对于少数不需要缓冲的情况,库可以提供关闭缓冲的机制。

任何用于关闭缓冲的机制,都应该在接口中被清晰地隔离出来(例如,通过为 FileInputStream 提供不同的构造函数,或者通过一个禁用/替换缓冲机制的方法),从而让大多数开发者甚至都不需要意识到缓冲机制的存在。

相比之下,Unix 系统调用的设计者就让常见情况变得非常简单。
例如,他们认识到顺序 I/O 是最常见的使用方式,于是将其作为默认行为。随机访问仍然可以通过 lseek 系统调用轻松实现,但只进行顺序访问的开发者完全不需要知道这个机制的存在。

如果一个接口有很多功能,但大多数开发者只需要了解其中的一小部分,那么这个接口的 有效复杂度 就只是那些常用功能的复杂度。

4.8 结论

通过将模块的接口与其实现分离,我们可以把实现的复杂性隐藏在系统的其他部分之外。
模块的使用者只需要理解接口所提供的抽象。

在设计类和其他模块时, 最重要的问题是让它们变得“深”
也就是说,让它们拥有 简单的接口,却隐藏大量的复杂实现 ,从而为常见使用场景提供简洁的接口,同时仍然提供强大的功能。

这样做,才能最大化被隐藏起来的复杂性。