第 4 章 模块应该是深的
管理软件复杂性最重要的技术之一,是在设计系统时,让开发人员在任何时候都只需面对整体复杂性的一小部分。这种方法称为 模块化设计,本章介绍其基本原则。
本章概览什么样的模块算好模块?
4.1 模块化设计
在模块化设计中,软件系统被分解为一组相对独立的 模块。模块可以采用多种形式,例如类、子系统或服务。在理想情况下,每个模块都完全独立于其他模块:开发人员可以开发任意一个模块,而无需了解其他模块。这时,系统的复杂性就取决于其中最复杂的模块。
不幸的是,这种理想无法实现。模块必须通过调用彼此的函数或方法来协同工作,因此必须相互了解。模块之间会存在 依赖关系:如果一个模块发生更改,则可能需要相应地更改其他模块。例如,方法的参数在方法本身与调用该方法的代码之间建立了依赖关系。如果更改了方法要求的参数,就必须修改该方法的所有调用,以符合新的签名。依赖关系还有许多其他形式,而且可能非常微妙。比如,除非先调用一个方法,否则另一个方法就无法正常工作。模块化设计的目标是最大程度地减少模块之间的依赖性。
为了识别和管理依赖关系,我们将每个模块分为两个部分:接口 和 实现。接口包含了开发人员在使用这个模块时必须知道的所有内容。通常,接口描述模块做什么,而不描述模块如何做;实现则是完成这些功能的代码。开发某个模块的人员必须了解该模块的接口和实现,以及它所调用的其他模块的接口,但应该无需了解其他模块的实现。
考虑一个实现平衡树的模块。该模块可能包含复杂的代码,以确保树保持平衡。但是,此复杂性对于模块用户而言是不可见的。用户可以看到一个相对简单的接口,用于调用在树中插入、删除和获取节点的操作。要调用插入操作,调用者只需提供新节点的键和值即可,而遍历树和拆分节点的机制在接口中不可见。
就本书而言,模块是具有接口和实现的任何代码单元。面向对象编程语言中的每个类都是一个模块。类中的方法或非面向对象语言中的函数也可以视为模块:每个模块都有一个接口和一个实现,并且可以将模块化设计技术应用于它们。更高层级的子系统和服务也是模块。它们的接口可能采用不同的形式,例如内核调用或 HTTP 请求。本书中有关模块化设计的许多讨论都集中在类的设计上,但是这些技术和概念也适用于其他种类的模块。
最好的模块通常具有比实现简单得多的接口。这样的模块有两个优点。首先,简单的接口可以将模块给系统其余部分带来的复杂性降至最低。其次,只要修改模块时不改变其接口,就不会影响其他模块。如果模块的接口比实现简单得多,就能在不影响其他模块的情况下更改模块的许多方面。
4.2 接口中有什么?
模块的接口包含两种信息:形式化信息和非形式化信息。接口的形式化部分在代码中明确指定,并且其中一些可以通过编程语言检查其正确性。例如,方法的形式化接口是其签名,其中包括参数的名称和类型、返回值的类型以及有关该方法引发的异常的信息。大多数编程语言都确保对方法的每次调用都提供了正确数量和类型的参数以匹配其签名。类的形式化接口包括其所有公有方法的签名以及任何公有变量的名称和类型。
每个接口还包括非形式化的元素。这些元素并未以编程语言能够理解或强制约束的形式加以规定。接口的非形式化部分包括其高层级行为,例如,函数会删除由某个参数指定的文件。如果类的使用存在限制(也许必须先调用一个方法才能调用另一个),这些约束也是类接口的一部分。一般来说,开发人员使用模块时必须了解的信息,都属于模块接口的一部分。接口的非形式化部分只能使用注释来描述,而编程语言并不能确保描述完整或准确 [1]。对于大多数接口,非形式化部分比形式化部分包含的内容更多,也更复杂。
明确规定接口的好处之一是,它能准确告诉开发人员,使用相应模块需要知道哪些内容。这有助于消除第 2.2 节中描述的“未知的未知”问题。
4.3 抽象
抽象 这个术语与模块化设计的思想紧密相关。抽象是实体的简化视图,其中省略了不重要的细节。 抽象是有用的,因为它们使我们更容易思考和操纵复杂的事物。
在模块化编程中,每个模块以其接口的形式提供抽象。该接口提供了模块功能的简化视图;从模块抽象的角度来看,实现的细节并不重要,因此在接口中将其省略。
在抽象的定义中,“不重要”一词至关重要。抽象省略的不重要细节越多越好,但只有确实不重要的细节才能省略。抽象可能出现两类错误。第一类是包含了并非真正重要的细节,使抽象变得不必要地复杂,增加使用者的认知负荷。第二类是忽略了真正重要的细节。这会导致模糊性:仅查看抽象的开发人员无法获得正确使用抽象所需的全部信息。忽略重要细节的抽象是 错误的抽象:它可能看起来很简单,但实际上并非如此。设计抽象的关键是理解什么信息重要,并寻找能让使用者需要关注的重要信息尽可能少的设计。
例如,考虑一个文件系统。文件系统提供的抽象省略了许多细节,例如,如何选择存储设备上的块来存放给定文件的数据。这些细节对文件系统的用户并不重要(只要系统提供足够的性能即可)。但是,文件系统实现中的一些细节对用户很重要。大多数文件系统会将数据缓存在主内存中,并可能延迟将新数据写入存储设备,以提高性能。一些应用程序(例如数据库)需要确切地知道数据何时写入存储设备,才能确保系统崩溃后数据仍然保留。因此,文件系统的接口必须明确说明将数据刷新到辅助存储的规则。
译者图解这个细节能从接口中省掉吗?
我们不仅依靠抽象来管理编程中的复杂性,抽象在日常生活中也无处不在。微波炉包含复杂的电子设备,可将交流电转换为微波辐射并将该辐射分布到整个烹饪腔中。幸运的是,用户看到的是一个简单得多的抽象,它由几个按钮控制微波的定时和强度。汽车提供了一种简单的抽象概念,使我们可以在不了解电动机、电池电源管理、防抱死制动、巡航控制等机制的情况下驾驶它们。
4.4 深模块
最好的模块是那些功能强大、接口却很简单的模块。我用“深”一词来描述这样的模块。为了直观地表示深度的概念,假设每个模块都由一个矩形表示,如图 4.1 所示。每个矩形的面积与模块实现的功能成比例。矩形的顶部边缘代表模块的接口;边缘的长度表示接口的复杂性。最好的模块很深:它们在简单的接口后隐藏了许多功能。深模块提供了很好的抽象,因为用户只能看到其内部复杂性中很小的一部分。
图 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,并返回一个整型的 文件描述符,该描述符用于引用打开的文件。open 的其他参数提供可选信息,例如打开文件是为了读取还是写入、如果文件不存在是否应创建新文件,以及新建文件的访问权限。read 和 write 系统调用在应用程序内存中的缓冲区与文件之间传输信息。close 结束对文件的访问。大多数文件是按顺序访问的,因此这是默认设置。但是,可以通过 lseek 系统调用来更改当前访问位置以实现随机访问。
Unix I/O 接口的现代实现需要数十万行代码,这些代码可以解决诸如以下的复杂问题:
- 如何在磁盘上表示文件以支持高效率的访问?
- 如何存储目录,以及如何处理层次化的路径名以查找它们所引用的文件?
- 如何进行权限管控,以使一个用户无法修改或删除另一用户的文件?
- 如何实现文件访问?例如,如何在中断处理程序和后台代码之间划分功能,以及这两部分如何安全通信?
- 在同时访问多个文件时使用什么调度策略?
- 如何将最近访问的文件数据缓存在内存中以减少磁盘访问次数?
- 如何将各种不同的辅助存储设备(例如磁盘和闪存驱动器)合并到单个文件系统中?
Unix 文件系统的实现解决了所有这些问题,以及其他许多问题。使用这些系统调用的程序员无需看到这些细节。多年来,Unix I/O 接口的实现发生了根本性的变化,但五个基本的内核调用并没有改变。
深模块的另一个示例是 Go 或 Java 等语言中的垃圾收集器。这个模块根本没有接口。它在后台悄然运行,回收未使用的内存。由于垃圾收集消除了用于释放对象的接口,因此向系统中添加垃圾回收实际上会缩小其总体接口。垃圾收集器的实现非常复杂,但这种复杂性对程序员是隐藏的。
诸如 Unix I/O 和垃圾收集器之类的深模块提供了强大的抽象,因为它们易于使用,隐藏了巨大的实现复杂性。
4.5 浅模块
另一方面,浅模块是其接口与其提供的功能相比相对复杂的模块。例如,实现链表的类很浅。操作链表不需要太多代码(插入或删除元素仅需几行代码),因此链表抽象不会隐藏很多细节。链表接口的复杂性几乎与其实现的复杂性一样高。类似于链表的浅类有时是不可避免的,它们也是有用的,但是它们在管理复杂性方面没有提供太多帮助。
下面是一个来自软件设计课程项目的浅方法,是一个极端的例子:
private void addNullValueForAttribute(String attribute) {
data.put(attribute, null);
}
从管理复杂性的角度来看,此方法会使情况变得更糟,而不是更好。该方法不提供任何抽象,因为其所有功能都可以从接口中看出来。例如,调用者可能需要知道该属性将存储在 data 变量中。理解接口并不比理解完整实现简单。如果为这个方法编写完整的文档,文档会比方法的代码还长。与调用方直接操作数据变量相比,调用该方法甚至需要敲更多的键。该方法引入了一个需要开发人员学习的新接口,增加了复杂性,却没有提供相应的收益。
危险信号:浅模块
浅模块是一个接口相对于其提供的功能而言较为复杂的模块。浅模块在对抗复杂性方面帮助不大,因为它们提供的好处(不必了解它们在内部如何工作)被学习和使用其接口的成本所抵消。小模块往往很浅。
4.6 多类症
不幸的是,深类的价值在今天并未得到广泛认可。编程中的传统观点是,类应该 小 而不是深。学生们经常被教导说,类的设计中最重要的事情是将较大的类分成较小的类。对于方法,通常会给出相同的建议:“任何长于 N 行的方法都应拆分为多个方法”(N 可以低至 10)。这种做法导致了大量的浅类和浅方法,增加了系统的整体复杂性。
“类应该小”这一观念的极端形式,是我称之为 多类症 的综合征。它源于一种错误认识:“类是好的,所以类越多越好。”在有多类症的系统中,开发人员被鼓励尽量减少每个新类的功能:想要更多功能,就引入更多类。这样,每个类本身可能很简单,整个系统却更复杂了。小类提供的功能不多,因此需要大量小类,而每个类又都有自己的接口。这些接口累积起来,会给系统带来巨大的复杂性。每个类还需要样板代码,因此小类也容易使代码冗长。
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,也不能读取或写入序列化的对象。BufferedInputStream 对象将缓冲功能添加到 FileInputStream,而 ObjectInputStream 添加了读取和写入序列化对象的功能。一旦文件被打开,上面代码中的前两个对象 fileStream 和 bufferedStream 将永远不会被使用,以后的所有操作都使用 objectStream。
译者注(勘误):
ObjectInputStream用于读取并反序列化对象,不提供写入序列化对象的功能;原文此处的“写入”不准确。正文照录,参见 Java API 文档。
特别令人烦恼(并且容易出错)的是,必须通过创建一个单独的 BufferedInputStream 对象来显式请求缓冲功能。如果开发人员忘记创建该对象,就不会有缓冲,I/O 也会变慢。也许 Java 开发人员会争辩说,并不是每个人都希望对文件 I/O 使用缓冲,因此不应将其内置到基本机制中。他们也可能认为,最好单独提供缓冲功能,以便人们可以选择是否使用它。提供选择是好的,但是 设计接口时应该使常见情况尽可能简单 (请参阅第 2.1 节的公式)。几乎每个文件 I/O 用户都希望使用缓冲,因此默认情况下应提供缓冲。对于不需要缓冲的少数情况,该库可以提供一种禁用它的机制。禁用缓冲的机制应该在接口中明确地单独提供(例如,通过提供不同的 FileInputStream 构造函数,或者通过提供禁用或替换缓冲机制的方法),这样大多数开发人员甚至不需要知道其存在。
相反,Unix 系统调用的设计者使常见情况变得简单。例如,他们认识到顺序 I/O 是最常见的,因此他们将其作为默认行为。通过使用 lseek 系统调用,随机访问仍然相对容易实现,但是仅执行顺序访问的开发人员无需了解该机制。如果一个接口具有许多功能,但是大多数开发人员只需要了解其中的一些功能,那么该接口的有效复杂性就是常用功能的复杂性。
译者图解有选择,不等于每次都要选择
4.8 结论
通过将模块的接口与实现分开,我们可以向系统的其余部分隐藏实现的复杂性。模块的用户只需要了解接口提供的抽象。设计类和其他模块时,最重要的是使它们足够深:既能为常见用例提供简单的接口,又能提供丰富的功能。这样就能最大程度地隐藏复杂性。
[1] 有些编程语言(主要见于研究领域)允许用规范语言形式化地描述方法或函数的整体行为,还可以自动检查规范,确保它与实现相匹配。一个有趣的问题是,这样的形式化规范能否代替接口的非形式化部分。我目前认为,对开发人员来说,用英语描述的接口很可能比用形式化规范语言编写的接口更直观、更易理解。 ↩
