《论将系统分解为模块的准则》--经典论文赏析

今天分享一下大卫·L·帕纳斯(D.L. Parnas)的经典论文《论将系统分解为模块的准则》的中文翻译。

论将系统分解为模块的准则
D.L. Parnas 卡内基-梅隆大学

本文讨论了模块化作为一种机制,用于提高系统的灵活性和可理解性,同时缩短其开发时间。“模块化”的有效性取决于将系统划分为模块时所使用的准则。本文提出了一个系统设计问题,并描述了常规和非常规的两种分解方式。结果表明,对于所述目标,非常规的分解方式具有显著优势。文中讨论了达成这些分解所依据的准则。如果使用“一个模块由一个或多个子程序组成”这一常规假设来实现,非常规的分解在大多数情况下效率会较低。本文概述了一种避免此效应的替代实现方法。
关键词和短语:软件,模块,模块化,软件工程,KWIC索引,软件设计
CR 分类:4.0

引言
关于模块化编程哲学的一个清晰阐述,可以在 Gouthier 和 Pont 1970 年关于系统程序设计的教科书 中找到,我们引用如下:

项目工作的明确定义的分割确保了系统的模块化。每个任务形成一个独立的、不同的程序模块。在实现时,每个模块及其输入和输出都有明确定义,与其他系统模块的预期接口没有混淆。在检查时,模块的完整性被独立测试;在开始检查之前,协调多个任务完成的调度问题很少。最后,系统以模块化的方式维护;系统错误和缺陷可以追踪到特定的系统模块,从而限制了详细错误搜索的范围。

通常,关于将系统划分为模块所应使用的准则,并没有任何说明。本文将讨论这个问题,并通过示例,提出一些在将系统分解为模块时可以使用的准则。

简要现状报告
模块化编程领域的主要进步是编码技术和汇编器的发展,这些技术
(1)允许编写一个模块时几乎不需要了解另一个模块中的代码,
(2)允许重新汇编和替换模块,而无需重新汇编整个系统。这个功能对于生产大型代码非常宝贵,但最常被用作问题系统示例的系统是高度模块化的程序,并使用了上述技术。

模块化编程的预期好处

模块化编程的预期好处是:
(1)管理上的——开发时间应缩短,因为不同的组将在每个模块上工作,几乎不需要沟通;
(2)产品灵活性——应该能够对某个模块进行大幅更改,而无需更改其他模块;
(3)可理解性——应该能够一次研究系统的一个模块。因此,整个系统可以因为被更好地理解而得到更好的设计。

什么是模块化?

以下是几个称为模块化的部分系统描述。在此上下文中,“模块”被视为一种职责分配,而不是一个子程序。模块化包括了在独立模块工作开始之前必须做出的设计决策。每种方案包含的决策大不相同,但在所有情况下,其意图都是描述所有“系统级”决策(即影响多个模块的决策)。


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

以下对 kwic 索引的描述对于本文就足够了。kwic 索引系统接受一组有序的行,每行是一个有序的单词集合,每个单词是一个有序的字符集合。任何一行都可以通过重复移除第一个单词并将其附加到行尾来进行“循环移位”。kwic 索引系统按字母顺序输出所有行的所有循环移位的列表。

这是一个小系统。除非在极端情况下(庞大的数据库,没有支持软件),这样一个系统可以由一个好的程序员在一两周内完成。因此,促使模块化编程的困难对于这个系统都不重要。因为彻底处理一个大系统是不切实际的,我们必须进行这个练习,将这个问题当作一个大项目来处理。我们给出一种代表当前方法的模块化,以及另一种已成功用于本科课程项目的模块化。


模块化 1

我们看到以下模块:

模块 1:输入 (Input)。 该模块从输入介质读取数据行,并将它们存储在内存中,供其余模块处理。字符以每字四个的方式打包,并使用一个其他情况下未使用的字符来表示单词的结尾。保留一个索引来显示每行的起始地址。

模块 2:循环移位 (Circular Shift)。 该模块在输入模块完成其工作后被调用。它准备一个索引,给出每个循环移位的第一个字符的地址,以及该行在模块 1 创建的数组中的原始索引。它将输出留在内存中,单词成对出现(原始行号,起始地址)。

模块 3:按字母排序 (Alphabetizing)。 该模块将模块 1 和 2 产生的数组作为输入。它产生一个与模块 2 产生的格式相同的数组。然而,在这种情况下,循环移位以另一种顺序(按字母顺序)列出。

模块 4:输出 (Output)。 使用模块 3 和模块 1 产生的数组,该模块产生一个格式良好的输出,列出所有的循环移位。在一个复杂的系统中,每行的实际开始处会被标记,可能会插入指向更多信息的指针,并且循环移位的开始可能实际上不是行中的第一个单词,等等。

模块 5:主控 (Master Control)。 该模块的作用主要是控制其他四个模块之间的顺序。它还可能处理错误消息、空间分配等。

应该清楚的是,上述内容并不构成最终文档。在开始工作之前,必须提供更多的信息。定义文档将包括许多显示核心格式、指针约定、调用约定等的图片。在开始工作之前,必须指定四个模块之间的所有接口。

这是所有模块化编程支持者所认为的模块化。系统被划分为许多具有明确定义的接口的模块;每个模块都足够小和简单,可以被彻底理解和良好编程。小规模实验表明,对于指定的任务,这大致是大多数程序员会提出的分解。

模块化 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 行中单词的数量作为值。

这些例程的调用方式存在某些限制;如果违反这些限制,例程将“陷入”一个错误处理子程序,该子程序由例程的用户提供。还提供了额外的例程,向调用者揭示任何行中的单词数、当前存储的行数以及任何单词中的字符数。提供了函数 DELINE 和 DELWRO 来删除已存储行的部分。[3] 和 [8] 中给出了类似模块的精确规范,我们在此不再重复。

模块 2:输入 (INPUT)。 该模块从输入介质读取原始行,并调用行存储模块在内部存储它们。

模块 3:循环移位器 (Circular Shifter)。 该模块提供的主要功能类似于模块 1 中提供的功能。该模块给人一种印象,即我们创建了一个行存储器,其中包含的不是所有的行,而是所有的行的循环移位。因此,函数调用 CSCHAR(l,w,c) 提供了代表第 l 个循环移位中第 w 个单词的第 c 个字符的值。它被规定为 (1) 如果 i < j,则行 i 的移位排在行 j 的移位之前,以及 (2) 对于每一行,第一个移位是原始行,第二个移位是通过对第一个移位进行一次单词旋转获得的,等等。提供了一个函数 CSSETUP,在调用其他函数使其具有指定值之前必须先调用它。关于此类模块的更精确规范,请参见 [8]。

模块 4:字母排序器 (Alphabetizer)。 该模块主要由两个函数组成。一个,ALPH,必须在另一个具有定义值之前被调用。第二个,ITH,将充当索引。ITH(i) 将给出在字母顺序中排第 i 位的循环移位的索引。[8] 中给出了这些函数的正式定义。

模块 5:输出 (Output)。 该模块将给出所需的行集或循环移位的打印输出。

模块 6:主控 (Master Control)。 功能与上述模块化类似。

两种模块化的比较

概述。 两种方案都可行。第一种非常常规;第二种已成功用于一个课程项目 [7]。两者都将编程简化为相对独立地编程许多小的、可管理的程序。

首先注意,两种分解可以共享所有的数据表示和访问方法。我们讨论的是切割可能是同一对象的两种不同方式。根据分解 1 构建的系统在汇编后可能与根据分解 2 构建的系统相同。两种方案之间的差异在于它们划分工作分配的方式以及模块之间的接口。两种情况下使用的算法可能是相同的。即使可运行表示相同,系统也有本质上的不同。这是可能的,因为可运行表示仅用于运行;其他表示用于更改、文档化、理解等。在这两种其他表示上,两个系统不会相同。

可更改性。 有许多设计决策是有问题的,并且在许多情况下很可能发生变化。这是一个部分列表。

  1. 输入格式。
  2. 将所有行存储在内存中的决定。对于大型作业,可能不方便或不切实际地在任何时刻将所有行都保存在内存中。
  3. 将字符每四个打包到一个字中的决定。在我们处理少量数据的情况下,打包字符可能被证明是不可取的;每个字符一个字的方式将节省时间。在其他情况下,我们可能会打包,但采用不同的格式。
  4. 为循环移位制作索引而不是实际存储它们的决定。同样,对于小型索引或大内存,将它们写出来可能是更可取的方法。或者,我们可以选择在 CSSETUP 期间不准备任何东西。所有计算都可以在调用其他函数(如 CSCHAR)期间完成。
  5. 一次性对列表进行字母排序的决定,而不是 (a) 在需要时搜索每个项目,或 (b) 像 Hoare 的 FIND [2] 中那样部分排序。在许多情况下,将字母排序所涉及的计算分布到生成索引所需的时间上是有利的。

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

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

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

第五个变化在第一个分解中也会很困难。输出模块会期望索引在其开始之前已经完成。第二个分解中的字母排序器模块是被设计的,用户无法检测字母排序实际是何时完成的。不需要更改任何其他模块。
独立开发。 在第一个模块化中,模块之间的接口是上述相当复杂的格式和表组织。这些代表了不能轻易做出的设计决策。表的结构和组织对于各个模块的效率至关重要,必须仔细设计。这些格式的开发将是模块开发的主要部分,并且该部分必须是几个开发小组之间的共同努力。在第二个模块化中,接口更加抽象;它们主要由函数名称以及参数的数量和类型组成。这些是相对简单的决策,模块的独立开发应该更早开始。
可理解性。 要理解第一个模块化中的输出模块,必须理解字母排序器、循环移位器和输入模块的一些内容。输出模块使用的表的某些方面,只有根据其他模块的工作方式才有意义。由于其他模块中使用的算法,表的结构会存在约束。系统只能作为一个整体来理解。我的主观判断是,在第二个模块化中情况并非如此。

准则

许多读者现在会看到每种分解所使用的准则。在第一个分解中,使用的准则是使处理中的每个主要步骤成为一个模块。有人可能会说,要得到第一个分解,需要先做一个流程图。这是分解或模块化最常用的方法。它是所有程序员培训的产物,教导我们应该从粗略的流程图开始,然后从那里进入详细的实现。流程图对于具有大约 5,000-10,000 条指令的系统来说是一个有用的抽象,但随着我们超越这个范围,它似乎不够了;需要一些额外的东西。
第二个分解是使用“信息隐藏” [4] 作为准则进行的。模块不再对应于处理中的步骤。例如,行存储模块在系统的几乎每个动作中都被使用。根据所使用的方法,字母排序可能对应于处理中的一个阶段,也可能不对应。类似地,循环移位在某些情况下可能根本不制作任何表,而是按需计算每个字符。第二个分解中的每个模块的特点在于它知道一个设计决策,并对所有其他模块隐藏该决策。其接口或定义被选择为尽可能少地揭示其内部工作原理。

循环移位模块的改进

为了说明这种准则的影响,让我们仔细看看第二个分解中循环移位模块的设计。事后看来,现在表明这个定义揭示了比必要更多的信息。虽然我们小心地隐藏了存储或计算循环移位列表的方法,但我们指定了该列表的顺序。如果我们只指定(1)循环移位当前定义中指示的行都将存在于表中,(2)它们中没有一个会被包含两次,以及(3)存在一个额外的函数,允许我们在给定移位的情况下识别原始行,那么程序就可以有效地编写。通过规定移位的顺序,我们提供了比必要更多的信息,从而不必要地限制了我们可以构建而不更改定义的系统类别。例如,我们没有允许一个系统,其中循环移位是按字母顺序产生的,ALPH 是空的,而 ITH 只是返回其参数作为值。我们在构建具有第二个分解的系统时未能做到这一点,显然必须归类为设计错误。
除了每个模块向系统其余部分隐藏某个设计决策这一通用准则外,我们还可以提到一些似乎可取的具体分解示例。

  1. 一个数据结构、其内部链接、访问过程和修改过程是一个单一模块的一部分。它们不像传统做法那样由许多模块共享。这个概念也许只是对 Balzer [9] 和 Mealy [10] 论文背后假设的阐述。考虑到这一点的设计显然是 BLISS [11] 设计背后的思想。
  2. 调用给定例程所需的指令序列和例程本身是同一模块的一部分。这个规则在用于实验的 Fortran 系统中不相关,但对于用汇编语言构建的系统来说,它变得至关重要。对于真实的机器来说,没有完美的通用调用序列,因此随着我们继续寻找理想的序列,它们往往会发生变化。通过将生成调用的责任分配给负责该例程的人员,我们使这种改进更容易,并且也更可行地在同一软件结构中拥有几个不同的序列。
  3. 操作系统和类似程序中队列中使用的控制块的格式必须隐藏在一个“控制块模块”中。传统上,使这种格式成为各种模块之间的接口。因为设计演变迫使控制块格式频繁更改,这样的决定通常被证明是极其昂贵的。
  4. 字符代码、字母顺序和类似数据应该隐藏在一个模块中,以获得最大的灵活性。
  5. 某些项目将被处理的顺序应(尽可能实际地)隐藏在单个模块内。从设备添加到操作系统中某些资源的不可用等各种变化,使得排序顺序极其多变。

效率和实现

如果我们不小心,第二种分解将被证明比第一种效率低得多。如果每个函数实际上都是作为一个带有复杂调用序列的过程来实现的,那么由于模块之间反复切换,将会有大量此类调用。第一种分解不会遇到这个问题,因为模块之间的控制转移相对较少。

为了节省过程调用开销,又能获得我们上面看到的优点,我们必须以一种不寻常的方式来实现这些模块。在许多情况下,最好由汇编器将例程插入代码中;在其他情况下,将插入高度专业化且高效的转移。要成功且高效地利用第二种分解,需要一种工具,通过它,程序可以像函数是子程序一样编写,但通过任何合适的实现方式进行汇编。如果使用这种技术,模块之间的分离在最终代码中可能不清楚。因此,额外的程序修改功能也将是有用的。换句话说,程序的几种表示(前面提到的)必须在机器中与执行它们之间映射的程序一起维护。

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

在早期尝试将这些分解规则应用于设计项目时,我们构建了一个用于 [6] 中描述的符号表示法的马尔可夫算法翻译器。虽然我们的意图不是研究语言的编译型和解释型翻译器之间的关系,但我们发现我们的分解对于该语言的纯编译器和几种解释器都是有效的。尽管每种类型的编译器的最终运行表示会有深刻而实质性的差异,但我们发现早期分解中隐含的决策对所有情况都适用。

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

这个例子为以下陈述提供了额外的支持:处理预期发生的时间顺序不应用于进行模块分解。它进一步证明,仔细的分解工作可以导致从一个项目到另一个项目的大量工作延续。
这个例子的更详细讨论包含在 [8] 中。

层次结构

我们可以在根据分解 2 定义的系统中找到 Dijkstra [5] 所说明的意义上的程序层次结构。如果存在符号表,它可以在没有任何其他模块的情况下运行,因此它位于第 1 级。如果未使用符号表,则行存储位于第 1 级,否则位于第 2 级。输入和循环移位器需要行存储才能运行。输出和字母排序器将需要循环移位器,但由于循环移位器和行存储器在某种意义上是兼容的,很容易构建这些例程的参数化版本,可用于对原始行或循环移位进行字母排序或打印。在第一种用法中,它们不需要循环移位器;在第二种用法中,它们需要。换句话说,我们的设计允许我们拥有一个单一的表示,用于可能在层次结构的两个级别中的任何一个上运行的程序。

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

由于可以想象,我们可能拥有一个具有版本 1 所示分解类型(重要的设计决策在接口中)但保留层次结构的系统,我们必须得出结论,层次结构和“清晰”的分解是系统结构的两个理想但独立的属性。

结论

我们试图通过这些例子证明,基于流程图开始将系统分解为模块几乎总是错误的。我们建议,应该从一个困难的设计决策或可能更改的设计决策列表开始。然后设计每个模块,以向其他模块隐藏这样的决策。因为在大多数情况下,设计决策超越了执行时间,模块将不对应于处理中的步骤。为了实现高效的实现,我们必须放弃“一个模块是一个或多个子程序”的假设,而是允许子程序和程序成为来自各个模块的代码的汇编集合。

参考文献

  1. Gauthier, Richard, and Pont, Stephen. Designing Systems Programs, ©, 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. Databases 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.

ACM 通讯 1972 年 12 月 第 15 卷 第 12 期 1058


这篇论文是软件工程领域的奠基性文献之一,首次明确提出了“信息隐藏”作为模块分解的关键准则,对后来的面向对象编程和软件设计原则产生了深远影响。希望这个翻译能帮助您理解其核心思想。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值