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