软件设计与架构笔记(3)

模块化编程(Modular Programming):信息隐藏与职责分割

上世纪60年代起,人们意识到实现复杂系统的前提是把系统合理分割为相互独立的部分,这些独立的部分被称作模块。与前文提到的结构化编程和过程式编程的区别是,一个模块可包含若干个子程,也允许组装不同模块以实现子程或程序。D.L. Parnas把这种编程技术称为模块化编程[DLP72],其中模块意味着任务职责,而模块化设计则表示一系列的跨模块的“系统级”设计决策。自此,模块化成为软件设计领域的重要主题之一。

针对模块化的研究包含两个基本组成部分:

  1. 一个良好的模块化系统(设计)应具备哪些特征?

  2. 一个良好的模块化系统(设计)应如何实现?

信息隐藏(Information Hiding)

最初的软件设计方法论认为,组织应当建立统一的文档管理系统,软件由设计人员设计好后开放给全体人员,从而让每个人都尽量了解设计背后包含的一系列决策。1971年,Parnas首次提出信息隐藏的概念,反驳了前述的传统设计“广播”实践[DLP71]。

从软件结构的角度看,软件设计包含了对模块自身特征以及模块间的连接(connetion)的描述,其中连接意味着设计对模块间作用的假设。而我们已经知道软件结构最重要的两个目标:系统变更正确性检验,而一个好的软件结构应使上述目标变得更加容易。以简化系统变更为例,如何使针对当前模块的变更不会传递到其它模块呢?答案当然是应尽量使针对当前模块的变更不至于打破其它模块对其所做的假设,即连接的稳定性。那么如何保证连接的稳定呢?直观来看当然是尽量减少假设的规模,即减少连接所包含的信息。

再以软件文档系统为例,实践证明,保证系统设计文档和代码的一致性需要花费可观的成本,这在大多数组织来说都难以实现。同时为了保证文档自身的可理解性,一个好的实践是建立组织统一的标准和术语,但实践证明这也很难做到,因为假设总会根据需求发生变更,而一旦新的假设违反了组织统一标准,则会引起标准的误用,而反过来扩展标准又有可能造成对已有文档假设的破坏。上述复杂性意味着,在一个实践统一文档标准的组织内,标准会尽量维持系统设计的最小假设,而这又会与文档本身的知识传递作用相违背。

Parnas认为设计人员应尽量“控制”信息的传播,例如在设计中只使用外部接口描述该模块,从而避免细节过早暴露,对外部隐藏那些尚待决定、不稳定或不应被外部了解的具体实现。

职责分割(Responsibility Segment)

人们普遍认为应根据功能职责划分系统模块,但缺少统一的划分方法,导致具体划分时会出现不同结果,原因在于实际问题域的复杂性。以比较简单的经典KWIC系统为例,考虑如下两种模块化设计方案:

  1. 系统被划分成5个部分:分别是输入模块I、循环移位(circular shift)模块C、字母排序(Alphabetizing)模块A、输出模块O和主控制模块M。具体来说,I接受行格式的数据输入,把每个单词用四个字母进行压缩表示,其余字母作为单词的结尾,然后将其存储到系统核心(core);当I读完所有数据,C先对核心中每行数据进行循环移位处理,并记录每条新数据到原始数据的索引,最后把数据存储回核心;然后,A从核心中读取数据,把C中生成的数据按字母进行排序并存储回核心;最后O把A中排序好的数据结合I中获得的原始数据进行匹配和格式化输出;主控制模块M负责控制其余模块的调用顺序,进行错误处理和进行一些必要的空间分配等操作。从实践角度考虑,该方案具备良好的职责分割和接口设计。

  2. 系统被划分成6个部分:行存储模块L、输入模块I’、循环移位器C’、排序器A’、输出模块O’ 和主控制模块M’。具体来说,L负责提供对行数据进行操作的功能,例如常见的增删改查等;I’负责读入数据并调用L写入数据;C’用来计算并返回所有的循环移位索引。A’的功能是返回给定索引序号的字母序序号;O’用于输出L或C’中包含的数据;M’与方案1中M的功能类似。该方案也具有良好的职责分割和接口设计。

为了进一步比较这两种方案的区别,首先来看两者在分析该问题时所作出的设计决策及其可能影响:

  1. 输入格式。

  2. 存储介质,例如把所有行数据存储在core中,那么假设数据集较大,则该决策就会面临挑战。

  3. 存储压缩,例如对每个单词进行压缩,假设数据集不大,处理时间反而会因为不必要的压缩而增加。

  4. 为循环移位器创建索引,而非直接存储所有数据,对于较小的数据集而言,后一选项可能更加合适。

  5. 为所有数据进行一次性按字母序排序,而非只在需要时进行搜索或只进行部分排序。在某些场景中,可能更希望把索引计算量分配至不同时间的字母序操作中。

以下分别使用是否容易变更是否可独立开发是否便于理解三个具有共识的软件设计目标分析和比较上述方案。

易变更性,由于都拥有唯一的输入模块,那么决策1的任何潜在变化都不会导致输入模块以外的变化;对决策2和3来说,由于涉及数据的格式表示,方案1中由于多个模块都需要直接读写core中的数据,一旦存储格式因假设发生变化就会导致所有关联模块的修改,相应的方案2由于独立出了行存储功能,因此依旧把改动影响限制在了一个模块之内。同样,决策4的变更可能导致存储格式的变化,方案2中的C’模块只用于计算而非存储,因此变更影响小于方案1;决策5的情况则与决策4类似,方案2具有更好的易变更性。

可独立开发,方案1的模块间接口实际上是通过数据存取间接实现的,其实质是数据格式和表结构的设计,在这种情况下,所有相关模块的接口设计都存在一定联系。而方案2通过若干个函数及其参数就实现了模块间的接口,因此对模块分别可独立开发有更好的支持。

可理解性,根据方案1,为了理解模块职责,需要至少理解模块I、C、A内部的实现,特别是数据存取的设计和实现,才能了解模块间的相互关系;相反方案2只需要通过接口函数的定义就可以了解模块职责了。

从职责分割的角度来说,上述两种方案都给出了初看相当合理的划分,但经过分析我们的结论显然是方案2比方案1更好,那么如何实现诸如本例中更好的职责分割呢?

一种用于模块分割的标准与系统设计方法

实际经验表明,人们直观上会倾向于作出符合上节提到的方案1的设计,这是因为方案1更加显而易见:例如借助流程图(flowchart)工具描述系统功能和流程,再自然映射在模块的划分上。而方案2的核心在于每个模块都努力隐藏其设计决策,包括接口和定义也都以较少的信息呈现,Panars认为这反映了一种以隐藏自身设计决策为目标的模块间分割标准,与传统的流程式思考模式有显著区别。

由于构建计算机系统的复杂性,设计人员在60年代起开始采用一些系统模拟语言(Simulation languages)辅助系统设计。显然,当系统需求越复杂,模拟语言也就会变得更复杂,就越难以满足设计人员的目标,因此模拟语言最初的应用并不成功。1968年,沃森研究院的F. W. Zurcher描述了一种迭代式的多层建模方法,通过在不同的抽象层级(Levels of abstraction)上安排设计决策,为设计人员提供了有效的系统思考工具。

Zurcher提出在一个模型中构建系统的多重表示,即抽象层级。以计算机系统为例,在最上层,该模型只表示系统的若干基本任务,并且给定这些功能所达到的目标,即始终优先回答what;进一步,在下一层引入CPU、存储层级和文件系统的概念,并指定连接上层每个任务的程序和数据的划分;然后,再下一层级描述更加细节的系统表示,直到完整描述了整个系统设计,即回答how。迭代在实现上述方法中同样重要。在采用模拟语言时,先实现上一层的系统设计描述程序,程序应包括本层所含的所有抽象定义;在进入下一层时,构建一个独立的可被上层操作的实现本层抽象意义的模拟程序,从而实现迭代式的层次设计结构。

这种层级建模方法中,每一层都仅包含该层定义范围内的设计决策,即令设计人员更容易理解模型及其具体行为,并且把针对设计的修改限定在本层级范围内,降低了设计变更的影响范围。当进入正式实现阶段时,编程人员可以用具体的算法和数据结构实现替换最底层的模拟程序,从而构建完整系统。

结论

如果说结构化编程奠定了现代编程语言的基础,那么模块化编程则为软件设计提供了应对复杂问题的有效工具。与结构化编程和过程式编程几乎一锤定音相比,模块化编程在过去50年间历经了长期演进,虽然70年代开始大量编程语言开始引入模块(module)的概念,但抽象表达本身的复杂性使整个软件设计和开发过程经历了飞速的变革,而这一切源于模块化的设计思想。

引用

[DLP72], On the Criteria to be Used in Decomposing Systems into Modules

[DLP71], Information distribution aspects of design methodology

[FWZ68], ITERATIVE MULTI-LEVEL MODELLING - A METHODOLOGY FOR COMPUTER SYSTEM DESIGN

Comments