软件构架:按什么标准把系统切成模块

D. L. Parnas, On the Criteria To Be Used in Decomposing Systems into Modules, Communications of the ACM, Vol. 15, No. 12 (1972), pp. 1053–1058.

这是模块化设计的奠基之作,也是现代"封装 / 接口 / 信息隐藏"思想的源头。上一篇记的是 Brooks《No Silver Bullet》——讲"软件的本质困难"。这一篇正好补上另一半:面对本质困难,系统到底该按什么标准切分。

一句话结论

模块不该按"处理步骤(流程图)"来切,而该按"要隐藏的设计决策"来切。

每个模块围绕一个将来可能变化、又难以处理的设计决策来组织,并把这个决策对系统其余部分隐藏起来——接口只暴露"能做什么",尽量不透露"内部怎么做"。

问题的由来

模块化编程的好处大家早就在喊:

  1. 管理上——各模块独立开发,沟通成本低,开发更快;
  2. 灵活性——改一个模块,不必动其他模块;
  3. 可理解性——能一个模块一个模块地读,整个系统因此更容易被设计好。

但当时几乎没人说清楚一个关键问题:到底按什么标准来切模块? Parnas 用一个例子把这件事讲透了。

例子:KWIC 索引系统

KWIC(Key Word In Context,上下文关键词)索引系统的定义:

输入若干行文本,每行由若干单词组成;把每行"循环移位"(反复把第一个词移到行尾)产生所有移位;最后把所有移位按字母序输出。

这个小系统一个熟练程序员一两周就能写完。Parnas 故意把它当成大项目来做,好暴露模块划分的方法论差异。

方案一:按处理流程切(常规做法)

  • 模块1 Input:读数据行,存进内存
  • 模块2 Circular Shift:算出所有循环移位的索引
  • 模块3 Alphabetizing:按字母序排
  • 模块4 Output:格式化输出
  • 模块5 Master Control:控制模块间的调用顺序

用一句话说:照着流程图切,每个主要处理步骤就是一个模块。这也是绝大多数程序员的直觉做法。

方案二:按设计决策切(非常规做法)

  • 模块1 Line Storage:提供 CHAR(r,w,c)、WORDS(r) 等抽象函数来存取字符,内部结构对外不可见
  • 模块2 Input:读原始行,交给 Line Storage 存起来
  • 模块3 Circular Shifter:对外提供 CSCHAR(i,w,c),制造出"存着所有循环移位"的假象,实际怎么算怎么存没人知道
  • 模块4 Alphabetizer:提供 ITH(i) 按字母序取索引
  • 模块5 Output
  • 模块6 Master Control

关键差别:模块不再对应处理步骤。Line Storage 被几乎所有动作使用;"循环移位"甚至可能根本不建表,而是每次按需计算。

两个方案一对比,高下立现

看几个"将来大概率会变"的设计决策:

  1. 输入格式
  2. 是否把全部行读进内存
  3. 字符是否四个打包进一个字
  4. 循环移位是"建索引"还是"按需计算"
  5. 一次性排序,还是边用边排
设计决策 方案一(流程切)的改动范围 方案二(决策切)的改动范围
是否全部读进内存 几乎每个模块都要改(存储格式是公共接口) 只动 Line Storage
字符怎么打包 同上,全系统都依赖这个格式 只动 Line Storage
移位怎么算/存 Alphabetizer、Output 都知道这个假设 只动 Circular Shifter
排序何时进行 Output 假设排序已完成 完全被 Alphabetizer 藏住,外部察觉不到

一句话:方案一把"易变的决定"做成了公共接口,于是任何改动都会牵一发而动全身;方案二把每个易变决定都锁进了一个模块。

判据:信息隐藏

两个方案真正的区别,就是切分时用的准则:

  • 方案一的准则:把每个主要处理步骤做成一个模块——本质上是"先画流程图,再照着流程图切"。
  • 方案二的准则:信息隐藏。每个模块由一个它会隐藏起来的设计决策来刻画;接口定义要尽可能少地暴露内部机制。

Parnas 的原话大意:近乎总是错误的做法,就是按流程图开始模块划分。

代价:性能

Parnas 很诚实地指出:信息隐藏的划分方式,如果还假设"一个模块 = 一个子程序",在多数情况下效率更低——因为跨模块调用变多了。

他的解法是打破这个假设:不要求"模块 = 子程序",而允许程序由来自不同模块的代码装配而成,并保留程序的多种表示(运行用的、修改用的、文档用的、理解用的)。换句话说,运行时的程序结构,不必等于模块的责任结构。

几条可操作的具体准则

  • 一个数据结构,连同它的内部链接、访问与修改过程,属于同一个模块——不要当作多个模块的共享接口。
  • 调用某例程的指令序列,和该例程本身,属于同一模块。
  • 操作系统中队列控制块的格式,应藏在一个"控制块模块"里。
  • 字符编码、字母序等约定信息,藏进单个模块。
  • 处理的先后顺序,尽量藏进单个模块。

还有一个容易被忽略的区分:层次结构(hierarchy / uses 关系)与"干净分解"是两个独立的好性质,别把两者混为一谈。

一条好用的自检判据

改掉某一个设计决策,需要动几个模块?

  • 按设计决策切 → 答案通常接近 1;
  • 按流程切 → 答案常常是 N。

对今天工程的落地意义

这篇 1972 年的文章,对今天的机器学习 / 视觉工程几乎原样适用:

  1. 别按 pipeline 阶段切代码,要按"易变的决策"切。 数据处理、模型骨干、增强策略、损失函数、评测指标都是易变决策,每个都应各自封装。改损失函数不该动数据加载代码。
  2. 接口只暴露能力,不暴露实现。 load_batch() 返回张量就行;不要让下游知道数据是 npz 还是 lmdb、关键点存哪个字段。这些一旦泄进接口,换存储就是全项目重构。
  3. 隐藏"约定":类别定义、关键点顺序、归一化参数、颜色空间——收进一个模块,避免散落各处导致 train / eval 不一致(CV 项目最常见的坑)。
  4. 区分"层次结构"与"干净分解":刻意分层的 backbone / head 不等于模块解耦,两者要分别检查。
  5. 和 Brooks 那篇连起来看:Brooks 说软件难在本质(需求与概念结构),Parnas 给出一条把"变更"关进笼子的具体切法。 一个讲"为什么难",一个讲"怎么组织"。

术语表 / Key Terms

  • decompose / decomposition — 分解、切分;本文指把一个系统切成模块的划分方式
  • modularization — 模块化(方案);文中特别定义为「一种责任划分(responsibility assignment),而非一个子程序」
  • information hiding — 信息隐藏;Parnas 提出的核心准则——每个模块隐藏一个设计决策,不对外暴露
  • design decision — 设计决策;模块划分应围绕「将来可能变、又难处理」的这类决定
  • interface — 接口;模块对外暴露的部分,应尽可能少地透露内部机制
  • flowchart — 流程图;Parnas 批评的「照着流程图切模块」的根源
  • KWIC (Key Word In Context) — 上下文关键词(索引);本文的示例系统
  • circular shift — 循环移位;把行首单词反复移到行尾
  • alphabetize / alphabetization — 按字母序排列 / 排序
  • call sequence — 调用序列;调用某例程所需的指令序列
  • control block — 控制块;操作系统中队列等使用的数据结构
  • conceal / hide — 隐藏;模块对内部实现的遮蔽
  • changeability — 可修改性;模块化的收益之一
  • comprehensibility — 可理解性;能逐个模块独立读懂
  • independent development — 独立开发;接口简单则各模块可早期并行开发
  • hierarchy / uses relation — 层次结构 / 使用关系;与「干净分解」是两个独立的好性质,别混为一谈
  • efficiency penalty — 效率代价;频繁跨模块调用带来的性能损失
  • trap(to an error handler) — (出错时)跳转到错误处理程序;文中描述存储模块对越界调用的处理
  • runnable representation — 可运行表示;文中强调「运行时的程序结构 ≠ 修改/文档/理解用的结构」

论文笔记系列。上一篇:软件构架:本质任务和附属任务(Brooks, No Silver Bullet, 1986/1987)。

posted @ 2026-09-12 17:42  Quentin_Yws154  阅读(25)  评论(0)    收藏  举报