论把系统分解为模块时应采用的准则

D. L. Parnas

Department of Computer Science, Carnegie-Mellon University, Pittsburgh, PA 15213.

摘要

本文讨论模块化(modularization)作为一种机制,如何在缩短系统开发时间的同时,提高系统的灵活性和可理解性(comprehensibility)。“模块化”的有效性取决于把系统划分为模块时所采用的准则。文中给出一个系统设计问题,并描述一种常规分解和一种非常规分解。文章表明,非常规分解对于所列目标具有明显优势。文中还讨论了形成这些分解时所采用的准则。如果在实现非常规分解时仍采用一个模块由一个或多个子例程(subroutine)组成这一常规假设,那么在大多数情况下它会较低效。文末勾勒了一种不会产生这种影响的替代实现方法。

引言

关于模块化程序设计(modular programming)思想,Gauthier 和 Pont 于 1970 年出版的系统程序设计教材中有一段清晰表述 [1], p. 10.23,摘录如下:

对项目工作的明确划分保证了系统的模块性。每项任务形成一个独立、明确的程序模块。在实现时,每个模块及其输入和输出都得到明确定义,与其他系统模块的预期接口不存在混淆。在检验时,可以独立测试模块的完整性;在若干任务完成之后才能开始检验这一点上,几乎没有同步排期问题。最后,系统以模块化方式维护;系统错误和缺陷可以追溯到特定系统模块,从而限制详细错误搜索的范围。

通常,人们并不会说明应采用什么准则来把系统划分为模块。本文将讨论这一问题,并通过示例提出一些可用于把系统分解为模块的准则。

简要现状报告

模块化程序设计领域的主要进展,是编码技术和汇编器的发展;它们(1)允许一个模块在几乎不了解另一个模块代码的情况下编写,(2)允许重新汇编并替换模块,而不必重新汇编整个系统。对于生产大型代码来说,这种能力极有价值;但最常被用作问题系统示例的系统,已经是高度模块化的程序,并且使用了上述技术。

† 经 Prentice-Hall, Englewood Cliffs, N.J. 许可转载。

模块化程序设计的预期收益

人们期望模块化程序设计带来如下收益:(1)管理方面,开发时间应缩短,因为不同小组可以分别处理各模块,彼此很少需要沟通;(2)产品灵活性,应当可以对一个模块作大幅修改而无需修改其他模块;(3)可理解性,应当可以一次只研究系统的一个模块。因此,整个系统可以被更好地设计,因为它更容易被理解。

什么是模块化?

下面给出若干个被称为模块化方案的局部系统描述。在这里,“模块”被视为一种职责分配(responsibility assignment),而不是一个子程序。这些模块化方案包括在独立模块开始工作之前必须作出的设计决策。每种替代方案包含的决策很不相同,但在所有情况下,目的都是描述所有“系统级”决策(system-level decision,即影响多个模块的决策)。

示例系统 1:KWIC 索引生成系统

下面对 KWIC 索引(KWIC index)的描述足以满足本文需要。KWIC 索引系统接受一个有序的行集合,每一行是一个有序的词集合,每个词是一个有序的字符集合。任意一行都可以通过反复移去第一个词并把它追加到行尾来作“循环移位”(circular shift)。KWIC 索引系统输出所有行的所有循环移位,并按字母顺序排列。

这是一个小系统。除非在极端情况下(巨大的数据库、没有支撑软件),这样一个系统可以由优秀程序员在一两周内完成。因此,促使人们采用模块化程序设计的困难,对于这个系统并不重要。由于彻底处理一个大系统并不实际,我们必须把这个问题当作一个大型项目来完成这一练习。我们给出一种代表当前方法的模块化方案,以及另一种已经在本科课堂项目中成功使用的模块化方案。

模块化方案 1

我们看到如下模块:

模块 1:输入。该模块从输入介质读取数据行,并把它们存入主存(core),以供其余模块处理。字符按每字四个打包,并使用一个原本未使用的字符表示一个词的结束。系统维护一个索引,用于指示每一行的起始地址。

模块 2:循环移位。在输入模块完成工作后调用该模块。它准备一个索引,给出每个循环移位的第一个字符地址,以及由模块 1 构造的数组中原行的索引。它把输出留在主存中,按成对的字组织(原行号,起始地址)。

模块 3:字母排序。该模块以模块 1 和模块 2 产生的数组为输入。它产生一个数组,其格式与模块 2 产生的数组相同。不过在这里,循环移位按另一种顺序列出(字母顺序)。

模块 4:输出。该模块使用模块 3 和模块 1 产生的数组,生成一个格式良好的输出,列出所有循环移位。在一个复杂系统中,每一行的实际起点会被标出,可以插入指向更多信息的指针,而且循环移位的起点实际上未必是该行中的第一个词,等等。

模块 5:主控。该模块所做的事情基本只是控制其他四个模块之间的顺序。它也可能处理错误消息、空间分配等。

应当清楚,上述内容并不构成最终文档。在工作能够开始之前,还必须提供更多信息。定义文档将包括若干图示,展示主存格式、指针约定、调用约定(calling convention)等。四个模块之间的所有接口都必须在工作开始之前加以规定。

这就是所有模块化程序设计倡导者所说意义上的模块化方案。系统被划分为若干具有良好定义接口的模块;每个模块都足够小、足够简单,因而能够被彻底理解并良好地编程。小规模实验表明,对于指定任务,大多数程序员大致会提出这种分解。

模块化方案 2

我们看到如下模块:

模块 1:行存储(line storage)。该模块由若干函数或子例程组成,为模块用户提供调用该模块的手段。函数调用 CHAR(r,w,c) 的值是一个整数,表示第 r 行第 w 个词中的第 c 个字符。诸如 SETCHAR(r,w,c,d) 的调用会使第 r 行第 w 个词中的第 c 个字符成为 d 所表示的字符(即 CHAR(r,w,c) = d)。

WORDS(r) 返回第 r 行中的词数。这些例程的调用方式有若干限制;如果违反这些限制,这些例程会“陷入”到一个由例程用户提供的错误处理子例程(error-handling subroutine)。还提供其他例程,使调用者能够得知任意行的词数、当前存储的行数,以及任意词中的字符数。函数 DELINE 和 DELWRD 用于删除已经存储的行的一部分。类似模块的精确定义已在 [3][8] 中给出,这里不再重复。

模块 2:输入。该模块读取输入介质中的原始行,并调用行存储模块把它们存入内部。

模块 3:循环移位器(circular shifter)。该模块提供的主要函数类似于模块 1 中提供的函数。该模块造成这样一种印象:我们创建了一个行容器(line holder),其中包含的不是所有行,而是所有行的所有循环移位。因此,函数调用 CSCHAR(l,w,c) 提供一个值,表示第 l 个循环移位中第 w 个词的第 c 个字符。其规定为:(1)如果 i < j,则第 i 行的移位先于第 j 行的移位;(2)对于每一行,第一个移位是原行,第二个移位由对第一个移位移动一个词得到,依此类推。函数 CSSETUP 必须先于其他函数调用,否则其他函数没有规定值。关于这类模块更精确的规格说明见 [8]

模块 4:字母排序器。该模块主要由两个函数组成。一个是 ALPH,必须先调用它,另一个函数才有定义值。第二个函数 ITH 用作索引。ITH(i) 给出按字母顺序排在第 i 位的循环移位的索引。这些函数的形式化定义见 [8]

模块 5:输出。该模块负责按要求打印一组行或循环移位。

模块 6:主控。功能类似于上述模块化方案中的主控模块。

两种模块化方案的比较

一般而言,两种方案都能工作。第一种相当常规;第二种已在一个课堂项目 [7] 中成功使用。二者都会把编程工作简化为若干小型、可管理程序的相对独立编写。

首先注意,两种分解可以共享所有数据表示和访问方法。我们的讨论是关于切分同一个对象的两种不同方式。按照分解 1 构建的系统,在汇编之后可以想象为与按照分解 2 构建的系统完全相同。两种替代方案的差别在于它们如何划分工作分配,以及模块之间的接口。两种情况下使用的算法可能完全相同。即使在可运行表示(runnable representation)上完全相同,这两个系统仍然有实质差异。这是可能的,因为可运行表示只需用于运行;其他表示用于修改、文档化、理解等。这两个系统在那些其他表示中并不会相同。

可变性。有若干设计决策存在疑问,并且在许多情况下很可能发生变化。下面是一份不完整清单。

  1. 输入格式。
  2. 把所有行都存储在主存中的决策。对于大型作业,在任一时刻把所有行都保存在主存中可能不方便,也可能不现实。
  3. 把字符按每字四个打包的决策。在处理少量数据时,打包字符可能并不可取;采用每字一个字符的布局会节省时间。在其他情况下,我们可能仍然打包,但采用不同格式。
  4. 为循环移位建立索引,而不是实际把它们按循环移位形式存储起来的决策。这里同样,对于小索引或大主存,直接写出它们可能是更好的做法。另一种选择是,我们可以在 CSSETUP 期间什么也不准备。所有计算都可以在调用其他函数(如 CSCHAR)时完成。
  5. 对列表只作一次字母排序的决策,而不是(a)在需要每一项时搜索它,或(b)像 Hoare 的 FIND [2] 中那样部分地进行字母排序。在若干情况下,把字母排序所涉及的计算分布到生成索引所需的时间中会更有利。

通过考察这些变化,我们可以看到两种模块化方案之间的差异。第一个变化在两种分解中都局限于一个模块。对于第一种分解,第二个变化会导致每个模块都发生变化!第三个变化也是如此。在第一种分解中,主存中的行存储格式必须被所有程序使用。在第二种分解中,情况完全不同。关于行究竟如何存储的知识,除模块 1 外,对所有模块都是完全隐藏的。存储方式的任何变化都可以局限在该模块中。

在该系统的某些版本中,分解中还有一个附加模块。行存储模块内部使用了一个符号表(symbol table)模块(如 [3] 中所规定)。这一事实对系统其余部分完全不可见。

第四个变化在第二种分解中局限于循环移位模块,但在第一种分解中,字母排序器和输出例程也会知道这一变化。

第五个变化在第一种分解中也会很困难。输出模块会期望索引在它开始之前已经完成。第二种分解中的字母排序器模块被设计为,使用户无法察觉字母排序究竟是在什么时候完成的。无需修改其他模块。

独立开发。在第一种模块化方案中,模块之间的接口是上面描述的相当复杂的格式和表组织。这些代表着不能草率作出的设计决策。表结构和组织对于各模块的效率至关重要,必须仔细设计。开发这些格式将是模块开发的一个主要部分,而这部分必须由若干开发小组共同完成。在第二种模块化方案中,接口更抽象;它们主要由函数名以及参数的数量和类型构成。这些是相对简单的决策,模块的独立开发应能更早开始。

可理解性。要理解第一种模块化方案中的输出模块,就必须了解字母排序器、循环移位器和输入模块的一些情况。输出使用的表中会有一些方面,只有因为其他模块的工作方式才有意义。由于其他模块中使用的算法,表结构还会受到若干约束。这个系统只能作为一个整体来理解。我的主观判断是,第二种模块化方案并非如此。

准则

现在,许多读者会看出每种分解中使用了什么准则。在第一种分解中,所采用的准则是把处理过程中的每个主要步骤作为一个模块。可以说,要得到第一种分解,人们先画一张流程图。这是分解或模块化的最常见方法。它源于所有程序员训练中的一种做法:我们应从粗略流程图开始,再走向详细实现。对于大约 5,000 到 10,000 条指令规模的系统,流程图曾是一种有用抽象;但当我们超过这个规模时,它似乎就不够了,还需要别的东西。

第二种分解采用“信息隐藏”(information hiding)[4] 作为准则。模块不再对应于处理步骤。例如,行存储模块几乎在系统的每个动作中都会被使用。根据所用方法,字母排序可能对应于处理中的一个阶段,也可能不对应。类似地,在某些情况下,循环移位可能根本不构造任何表,而是在需要时计算每个字符。第二种分解中的每个模块,都以其知道并向所有其他模块隐藏某项设计决策为特征。其接口或定义被选择为尽可能少地暴露其内部工作。

对循环移位模块的改进

为了说明这种准则的影响,让我们更仔细地考察第二种分解中的循环移位模块设计。事后看来,这一定义暴露的信息多于必要信息。虽然我们小心地隐藏了存储或计算循环移位列表的方法,但我们规定了该列表的顺序。如果我们只规定:(1)循环移位当前定义中所指示的行都将存在于表中;(2)其中没有任何一个会被包含两次;(3)存在一个附加函数,允许我们根据移位识别其原始行,那么程序也可以被有效编写。通过规定移位的顺序,我们给出了多于必要的信息,因此不必要地限制了无需修改定义即可构建的系统类别。例如,我们没有允许这样一个系统:循环移位按字母顺序产生,ALPH 为空,ITH 只是返回其参数作为值。我们在按第二种分解构造系统时未能做到这一点,显然必须归类为设计错误。

除了每个模块都向系统其余部分隐藏某项设计决策这一一般准则外,我们还可以提出一些看起来可取的具体分解示例。

  1. 一个数据结构、其内部链接、访问过程和修改过程属于同一个模块。它们不应像常规做法那样由许多模块共享。这一观念或许只是 Balzer [9] 和 Mealy [10] 两篇论文背后假设的进一步阐述。BLISS [11] 的设计显然体现了这一思想。
  2. 调用某个给定例程所需的指令序列,以及该例程本身,属于同一个模块。在用于实验的 Fortran 系统中,这条规则并不相关;但对于用汇编语言构造的系统,它变得至关重要。真实机器不存在完美的通用调用序列(calling sequence),因此随着我们继续寻找理想序列,它们往往会变化。通过把生成调用的职责分配给负责该例程的人,我们使这类改进更容易,也使在同一软件结构中拥有若干不同序列更可行。
  3. 操作系统和类似程序中队列所使用的控制块(control block)格式,必须隐藏在一个“控制块模块”内部。常规做法是把这类格式作为各种模块之间的接口。由于设计演化会迫使控制块格式频繁变化,这样的决策常常代价极高。
  4. 字符编码、字母顺序以及类似数据应隐藏在一个模块中,以获得最大的灵活性。
  5. 某些项目被处理的顺序应当(在实际可行范围内)隐藏在单个模块中。从设备增加到某些资源不可用,操作系统中的各种变化都会使顺序安排极易变化。

效率与实现

如果不小心,第二种分解会比第一种分解低效得多。如果每个函数实际上都实现为带有复杂调用序列的过程,那么由于模块之间反复切换,会产生大量这样的调用。第一种分解不会遭遇这个问题,因为模块之间的控制转移相对不频繁。

为了节省过程调用开销(procedure call overhead),同时获得前面看到的优势,我们必须以一种不同寻常的方式实现这些模块。在许多情况下,例程最好由汇编器插入到代码中;在其他情况下,则会插入高度专门化且高效的转移。要成功且高效地使用第二类分解,需要一种工具,使程序可以像函数是子例程那样编写,但按适当的实现方式汇编。如果使用这种技术,模块之间的分离在最终代码中可能并不清晰。因此,额外的程序修改功能也会有用。换言之,前面提到的程序的若干表示必须与在它们之间执行映射的程序一起保存在机器中。

编译器和同一语言解释器共用的一种分解

在一次较早尝试把这些分解规则应用于设计项目时,我们构造了一个翻译器,用于 [6] 中描述的记法表达的 Markov 算法。虽然我们的本意并不是研究一种语言的编译型翻译器(compiling translator)和解释型翻译器(interpretive translator)之间的关系,但我们发现,我们的分解适用于纯编译器以及该语言的若干种解释器。尽管每类编译器的最终运行表示会有深刻而实质的差异,我们发现,早期分解中隐含的决策对所有情况都成立。

如果我们按照编译器或解释器的经典划分来分配职责(例如语法识别器、代码生成器、编译器的运行时例程),这一点就不会成立。相反,该分解像前面的示例一样,基于隐藏各种决策。因此,寄存器表示、搜索算法、规则解释等都是模块,而这些问题同时存在于编译型和解释型翻译器中。分解不仅在所有情况下都有效,而且许多例程只需稍作修改即可用于任何类型的翻译器。

这个例子进一步支持了如下说法:不应根据预期处理发生的时间顺序来作模块分解。它还提供了证据,说明谨慎的分解工作可以使一个项目中的相当多工作延续到另一个项目中。关于这一例子的更详细讨论见 [8]

层次结构

在按分解 2 定义的系统中,我们可以找到 Dijkstra [5] 所说明意义上的程序层次。如果存在符号表,它无需任何其他模块即可工作,因此位于第 1 层。如果不使用符号表,行存储位于第 1 层;否则位于第 2 层。输入和循环移位器需要行存储才能工作。输出和字母排序器需要循环移位器;但由于循环移位器和行容器在某种意义上是兼容的,构建这些例程的参数化版本将很容易,使其既可用于对原始行排序或打印,也可用于对循环移位排序或打印。在第一种用法中,它们不需要循环移位器;在第二种用法中则需要。换言之,我们的设计使我们能够为可运行在层次中两个层级之一上的程序提供单一表示。

在讨论系统结构时,人们很容易混淆良好分解的收益和层次结构的收益。如果可以在模块或程序之间定义某种关系,并且该关系是一个偏序(partial ordering),那么我们就有了层次结构。我们关心的关系是“使用”或“依赖于”。最好使用程序之间的关系,因为在许多情况下,一个模块只依赖另一个模块的一部分(例如,循环移位器只依赖行容器的输出部分,而不依赖 SETWORD 的正确工作)。可以想象,即使不存在这样的偏序,我们也能获得正在讨论的那些收益;例如,如果所有模块都处在同一层级。偏序给我们带来两个额外收益。第一,系统的一些部分因使用较低层级的服务而受益(得到简化)。第二,我们能够剪掉上层,仍然拥有可用且有用的产品。例如,符号表可以用于其他应用;行容器可以成为问答系统的基础。层次结构的存在保证我们可以“修剪”树的上层,并在旧树干上开始一棵新树。如果我们设计的系统中“低层”模块使用了某些“高层”模块,我们就不会拥有层次结构;我们会发现移除系统部分要困难得多,而“层级”在系统中也不会有太多意义。

† 此处 “lower” 指编号较低。

既然可以想象我们拥有一个类似版本 1 所示分解类型的系统(重要设计决策位于接口中),同时保留层次结构,那么我们必须得出结论:层次结构和“干净的”分解是系统结构的两个可取但相互独立的性质。

结论

我们试图通过这些例子说明,几乎总是错误地以流程图为基础开始把系统分解为模块。我们建议相反的做法:先列出困难的设计决策,或很可能变化的设计决策。然后设计每个模块,使其向其他模块隐藏这样一个决策。由于在大多数情况下,设计决策会跨越执行时间,模块并不会对应于处理步骤。为了高效实现,我们必须放弃一个模块是一个或多个子例程这一假设,而是允许子例程和程序成为由来自不同模块的代码汇编而成的集合。

Received August 1971; revised November 1971

References

  1. Gauthier, Richard, and Pont, Stephen. Designing Systems Programs, (C), Prentice-Hall, Englewood Cliffs, N.J., 1970.
  2. Hoare, C. A. R. Proof of a program, FIND. Comm. ACM 14, 1 (Jan. 1971), 39-45.
  3. Parnas, D. L. A technique for software module specification with examples. Comm. ACM 15, 5 (May, 1972), 330-336.
  4. Parnas, D. L. Information distribution aspects of design methodology. Tech. Rept., Depart. Computer Science, Carnegie-Mellon U., Pittsburgh, Pa., 1971. Also presented at the IFIP Congress 1971, Ljubljana, Yugoslavia.
  5. Dijkstra, E. W. The structure of “THE”-multiprogramming system. Comm. ACM 11, 5 (May 1968), 341-346.
  6. Galler, B., and Perlis, A. J. A View of Programming Languages, Addison-Wesley, Reading, Mass., 1970.
  7. Parnas, D. L. A course on software engineering. Proc. SIGCSE Technical Symposium, Mar. 1972.
  8. Parnas, D. L. On the criteria to be used in decomposing systems into modules. Tech. Rept., Depart. Computer Science, Carnegie-Mellon U., Pittsburgh, Pa., 1971.
  9. Balzer, R. M. Dataless programming. Proc. AFIPS 1967 FJCC, Vol. 31, AFIPS Press, Montvale, N.J., pp. 535-544.
  10. Mealy, G. H. Another look at data. Proc. AFIPS 1967 FJCC, Vol. 31, AFIPS Press, Montvale, N.J., pp. 525-534.
  11. Wulf, W. A., Russell, D. B., and Habermann, A. N. BLISS, A language for systems programming. Comm. ACM 14, 12 (Dec. 1971), 780-790.

CR Categories: 4.0

Copyright © 1972, Association for Computing Machinery, Inc. 允许为非营利目的重新发表本文全部或部分材料,但须注明本文出处、发行日期,以及转载权由 Association for Computing Machinery 授权这一事实。

标签: 软件工程, 模块化, 软件架构, 经典重读