第四章 模块应该是深的

管理软件复杂性最重要的技术之一就是设计系统,使开发者在任何时候都只需要面对整体复杂性的一小部分。这种方法被称为模块化设计,本章介绍其基本原则。

模块化设计(Modular design)

在模块化设计中,软件系统被分解为相对独立的模块集合。模块可以采用多种形式,例如类,子系统或服务。在理想的世界中,每个模块都将完全独立于其他模块:开发人员可以在任何模块中工作,而无需了解任何其他模块。在这个世界上,系统的复杂性就是最糟糕的模块的复杂性。

不幸的是,这种理想是无法实现的。模块必须通过调用彼此的函数或方法来协同工作。结果,模块必须相互了解。模块之间将存在依赖关系:如果一个模块发生更改,则可能需要更改其他模块以进行匹配。例如,方法的参数在方法与调用该方法的任何代码之间创建依赖关系。如果必需的参数更改,则必须修改该方法的所有调用以符合新的签名。依赖关系可以采用许多其他形式,并且它们可能非常微妙。模块化设计的目标是最大程度地减少模块之间的依赖性。

为了管理依赖关系,我们将每个模块分为两个部分:接口和实现。接口包含使用其他模块的开发人员必须知道的所有内容,才能使用给定的模块。通常,接口描述模块做什么,而不描述模块如何做。该实现由执行接口所承诺的代码组成。在特定模块中工作的开发人员必须了解该模块的接口和实现,以及由给定模块调用的任何其他模块的接口。除了正在使用的模块以外,开发人员无需了解其他模块的实现。

考虑一个实现平衡树的模块。该模块可能包含复杂的代码,以确保树保持平衡。但是,此复杂性对于模块用户而言是不可见的。用户可以看到一个相对简单的接口,用于调用在树中插入,删除和获取节点的操作。要调用插入操作,调用者只需提供新节点的键和值即可。遍历树和拆分节点的机制在接口中不可见。

就本书而言,模块是具有接口和实现的任何代码单元。面向对象编程语言中的每个类都是一个模块。类中的方法或非面向对象语言中的函数也可以视为模块:每个模块都有一个接口和一个实现,并且可以将模块化设计技术应用于它们。更高级别的子系统和服务也是模块。它们的接口可能采用不同的形式,例如内核调用或 HTTP 请求。本书中有关模块化设计的许多讨论都集中在设计类上,但这些技术和概念也适用于其他类型的模块。

最好的模块是那些其接口比其实现简单得多的模块。这样的模块具有两个优点。首先,一个简单的接口可以将模块强加于系统其余部分的复杂性降至最低。其次,如果以不更改其接口的方式修改了一个模块,则该修改不会影响其他模块。如果一个模块的接口比它的实现简单得多,那么该模块的许多方面就可以被改变而不影响其他模块。

接口中有什么?

模块的接口包含两种信息:正式的和非正式的。正式信息在代码中有明确指定,并且其中一些可以通过编程语言检查其是否正确。例如,一个方法的正式信息是其签名,其中包括其参数的名称和类型,其返回值的类型以及有关该方法引发的异常的信息。大多数编程语言都确保对方法的每次调用都提供正确数量和类型的参数以匹配其签名。一个类的正式信息包括其所有公共方法的签名,以及任何公共变量的名称和类型。

每个接口还包括非正式的元素。这些元素不是以编程语言可以理解或强制执行的方式来指定的。一个接口的非正式部分包括它的高级行为,比如一个函数会删除由它的一个参数命名的文件这一事实。如果对一个类的使用存在限制(也许一个方法必须在另一个方法之前被调用),那么这些约束也是类接口的一部分。一般来说,如果开发人员需要了解特定信息才能使用一个模块,那么这些信息就是该模块的接口的一部分。接口的非正式信息只能用注释来描述,而且编程语言不能保证描述的完整性和准确性。对于大多数接口来说,非正式的信息比正式的信息要大,要复杂。

明确规定接口的好处之一是,它准确地指出了开发者为了使用相关的模块需要知道什么。这有助于消除第 2.2 节中描述的“未知的未知”问题。

抽象

抽象这个词与模块化设计的理念密切相关。抽象是一个实体的简化视图,其中省略了不重要的细节。抽象是有用的,因为它们使我们更容易思考和操纵复杂的事物。

在模块化编程中,每个模块以其接口的形式提供一个抽象概念。接口展示了模块功能的简化视图;从模块抽象的角度来看,实现的细节并不重要,因此在接口中将其省略。

在抽象的定义中,"不重要 "这个词很关键。从抽象中忽略的不重要的细节越多越好。然而,只有当一个细节不重要时,它才能被从抽象中省略。抽象可能在两个方面出错。首先,它可能包含并非真正重要的细节。当这种情况发生时,它会使抽象变得不必要的复杂,从而增加了使用该抽象的开发人员的认知负担。第二个错误是抽象忽略了真正重要的细节。这就导致了模糊不清:只看抽象的开发者将无法获得正确使用抽象所需的所有信息。省略了重要细节的抽象是一个虚假的抽象:它可能看起来很简单,但实际上并非如此。设计抽象的关键是了解什么是重要的,并寻找能将重要的信息量最小化的设计。

例如,考虑一个文件系统。文件系统提供的抽象省略了许多细节,例如,选择存储设备上的哪些块用于给定文件中的数据的机制。这些细节对文件系统的用户来说并不重要(只要系统提供足够的性能即可)。但是,文件系统实现的一些细节对用户很重要。大多数文件系统将数据缓存在主内存中,并且它们可能会延迟将新数据写入存储设备以提高性能。一些应用程序(例如数据库)需要确切地知道何时将数据写入存储设备,这样它们就可以确保在系统崩溃后数据会被保留下来。因此,将数据刷新到辅助存储的规则必须在文件系统的接口中可见。

我们不仅在编程中依赖抽象来管理复杂性,而且在我们的日常生活中也无处不在。微波炉包含复杂的电子装置,将交流电转换为微波辐射,并将这种辐射分布在整个烹饪腔中。幸运的是,用户看到的是一个简单得多的抽象,包括几个按钮来控制微波的时间和强度。汽车提供了一个简单的抽象概念,使我们能够在不了解电机、电池电源管理、防抱死制动、巡航控制等机制的情况下驾驶它们。

深模块

最好的模块是那些提供强大功能而又有简单界面的模块。我使用 "深度 "一词来描述这种模块。为了直观地了解深度的概念,设想每个模块用一个矩形来表示,如图4.1所示。每个矩形的面积与该模块实现的功能成正比。矩形的顶部边缘代表模块的接口;该边缘的长度表示接口的复杂性。最好的模块是有深度的:它们有很多功能隐藏在简单的界面后面。一个深层的模块是一个很好的抽象,因为只有一小部分的内部复杂性对其用户是可见的。

UFPsKS.png

图 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);

开放系统调用采用分层文件名,例如/ a / b / c,并返回一个整数文件描述符,该描述符用于引用打开的文件。open 命令的其他参数提供可选信息,例如打开文件是为了读取还是写入,如果不存在现有文件则是否应创建新文件,以及如果创建新文件则具有文件的访问权限。读写系统调用在应用程序内存缓冲区和文件之间传输信息。close命令结束对文件的访问。大多数文件是按顺序访问的,因此这是默认设置。但是,可以通过调用 lseek 系统调用来更改当前访问位置来实现随机访问。

Unix I/O 接口的现代实现需要成千上万行代码,这些代码可以解决诸如以下的复杂问题:

  • 如何在磁盘上表示文件以便高效访问?
  • 如何存储目录,以及如何处理分层路径名以查找它们所引用的文件?
  • 如何实施权限,以使一个用户无法修改或删除另一用户的文件?
  • 如何实现文件访问?例如,如何在中断处理程序和后台代码之间划分功能,以及这两个元素如何安全通信?
  • 在同时访问多个文件时使用什么调度策略?
  • 如何将最近访问的文件数据缓存在内存中以减少磁盘访问次数?
  • 如何将各种不同的辅助存储设备(例如磁盘和闪存驱动器)整合到一个文件系统中?

所有这些问题,以及更多的问题,都由 Unix 文件系统的实现来解决。对于调用文件系统的程序员来说,他们是看不见的。多年来,Unix I/O 接口的实现已经发生了根本性的变化,但是五个基本内核调用并没有改变。

深度模块的另一个例子是 Go 或 Java 等语言中的垃圾收集器。这个模块完全没有接口。它在幕后隐秘地工作,回收未使用的内存。在一个系统中添加垃圾收集器实际上缩小了它的整体接口,因为它取消了释放对象的接口(不在需要手动释放对象)。垃圾收集器的实现是相当复杂的,但这种复杂性对使用该语言的程序员是隐藏的。

诸如 Unix I/O 和垃圾收集器之类的深层模块提供了强大的抽象,因为它们易于使用,但隐藏了巨大的实现复杂性。

浅模块

另一方面,浅层模块是其接口与其提供的功能相比相对复杂的模块。例如,一个实现链表的类是浅层的。操作链表不需要太多代码(插入或删除元素仅需几行),因此链表抽象并没有隐藏很多细节。链表接口的复杂度几乎与其实现的复杂度一样高。浅层类有时是不可避免的,但是它们在管理复杂性方面没有提供太多帮助。

这是一个浅层方法的极端示例,该浅层方法来自软件设计类的项目:

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

从管理复杂性的角度来看,此方法会使情况变得更糟。该方法没有提供任何抽象,因为它的所有功能都可以通过其接口看到。例如,调用者可能需要知道该属性将被存储在 data 变量中。考虑接口并不比考虑完整实现简单。如果正确记录了该方法,文档将比方法的代码更长。与调用方直接操作数据变量相比,调用该方法所花费的时间甚至更多。该方法增加了复杂性(以供开发人员学习的新接口的形式),但没有提供任何补偿。

浅层模块是一个接口相对于其提供的功能而言比较复杂的模块。浅层模块在对抗复杂性方面没有什么帮助,因为它们提供的好处(不必学习它们的内部工作原理)被学习和使用其接口的成本所抵消。小模块往往是浅层的。

经典主义

不幸的是,深层类的价值在今天并未得到广泛重视。编程中的传统观点是,类应该小而不是深。学生们经常被教导说,类设计中最重要的事情是将较大的类分成较小的类。对于方法,人们也经常给出同样的建议:“任何长于N行的方法都应该被分成多个方法”(N 可以低至 10)。这种方法导致了大量的浅层类和方法,增加了整个系统的复杂性。

“类应该小”的极端做法是我称之为“类炎”的综合症,它源于一种错误的观点:“类是好的,所以类越多越好”。在患有类炎的系统中,开发人员被鼓励尽量减少每个新类的功能量:如果您想要更多的功能,请引入更多的类。类炎可能会导致单独的类的很简单,但它会增加整个系统的复杂性。小的类并没有贡献多少功能,所以必须要有很多的类,每个类都有自己的接口。这些接口的累积会在系统级别产生巨大的复杂性。小的类也会导致冗长的编程风格,因为每个类都需要模板。

Java and Unix I/O 示例:Java 和 Unix I/O

如今,最明显的类炎的例子之一是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。

特别令人烦恼(并且容易出错)的是,必须通过创建一个单独的 BufferedInputStream 对象来显式请求缓冲。如果开发人员忘记创建该对象,就不会有缓冲,I/O 就会很慢。也许 Java 开发人员会争辩说,并不是每个人都希望对文件 I/O 使用缓冲,所以它不应该被内置到基本机制中。他们可能会争辩说,最好把缓冲分开,这样人们就可以选择是否使用它。提供选择是好的,但是接口的设计应该使普通的情况尽可能的简单(见第6页的公式)。几乎每一个使用文件I/O的用户都会想要缓冲,所以应该默认提供缓冲功能。对于那些不希望有缓冲的少数情况,库可以提供一种机制来禁用它。任何禁用缓冲的机制都应该在接口中干净地分开(例如,为FileInputStream 提供一个不同的构造函数,或者通过一个禁用或替换缓冲机制的方法),这样大多数开发者甚至不需要意识到它的存在。

相反,Unix 系统调用的设计者使常见情况变得简单。例如,他们认识到顺序 I/O 是最常见的,因此他们将其作为默认行为。使用 lseek 系统调用,随机访问仍然相对容易实现,但是仅执行顺序访问的开发人员无需了解该机制。如果一个接口具有许多功能,但是大多数开发人员只需要了解其中的一些功能,那么该接口的有效复杂性就是常用功能的复杂性。

结论

通过将一个模块的接口与它的实现分开,我们可以将实现的复杂性隐藏在系统的其他部分。一个模块的用户只需要理解其接口所提供的抽象性。在设计类和其他模块时,最重要的问题是让它们变得深入,这样它们就有简单的接口来满足常见的用例,但仍然提供重要的功能。这样可以最大限度地掩盖复杂性。

存在一些语言,主要是在研究界,一个方法或函数的整体行为可以用一种规范语言来正式描述。该规范可以被自动检查,以确保它与实现相匹配。一个有趣的问题是,这样的正式规范是否可以代替接口的非正式部分。我目前的观点是,用英语描述的接口比使用正式规范语言编写的接口对开发人员来说更直观和易于理解。


本文转载自:
https://go7hic.github.io/A-Philosophy-of-Software-Design/#/./ch4
https://www.bookstack.cn/read/A-Philosophy-of-Software-Design-zh/docs-ch4.md

标签: none

评论已关闭