软件构架:按什么标准把系统切成模块
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》——讲"软件的本质困难"。这一篇正好补上另一半:面对本质困难,系统到底该按什么标准切分。
一句话结论
模块不该按"处理步骤(流程图)"来切,而该按"要隐藏的设计决策"来切。
每个模块围绕一个将来可能变化、又难以处理的设计决策来组织,并把这个决策对系统其余部分隐藏起来——接口只暴露"能做什么",尽量不透露"内部怎么做"。
问题的由来
模块化编程的好处大家早就在喊:
- 管理上——各模块独立开发,沟通成本低,开发更快;
- 灵活性——改一个模块,不必动其他模块;
- 可理解性——能一个模块一个模块地读,整个系统因此更容易被设计好。
但当时几乎没人说清楚一个关键问题:到底按什么标准来切模块? 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 被几乎所有动作使用;"循环移位"甚至可能根本不建表,而是每次按需计算。
两个方案一对比,高下立现
看几个"将来大概率会变"的设计决策:
- 输入格式
- 是否把全部行读进内存
- 字符是否四个打包进一个字
- 循环移位是"建索引"还是"按需计算"
- 一次性排序,还是边用边排
| 设计决策 | 方案一(流程切)的改动范围 | 方案二(决策切)的改动范围 |
|---|---|---|
| 是否全部读进内存 | 几乎每个模块都要改(存储格式是公共接口) | 只动 Line Storage |
| 字符怎么打包 | 同上,全系统都依赖这个格式 | 只动 Line Storage |
| 移位怎么算/存 | Alphabetizer、Output 都知道这个假设 | 只动 Circular Shifter |
| 排序何时进行 | Output 假设排序已完成 | 完全被 Alphabetizer 藏住,外部察觉不到 |
一句话:方案一把"易变的决定"做成了公共接口,于是任何改动都会牵一发而动全身;方案二把每个易变决定都锁进了一个模块。
判据:信息隐藏
两个方案真正的区别,就是切分时用的准则:
- 方案一的准则:把每个主要处理步骤做成一个模块——本质上是"先画流程图,再照着流程图切"。
- 方案二的准则:信息隐藏。每个模块由一个它会隐藏起来的设计决策来刻画;接口定义要尽可能少地暴露内部机制。
Parnas 的原话大意:近乎总是错误的做法,就是按流程图开始模块划分。
代价:性能
Parnas 很诚实地指出:信息隐藏的划分方式,如果还假设"一个模块 = 一个子程序",在多数情况下效率更低——因为跨模块调用变多了。
他的解法是打破这个假设:不要求"模块 = 子程序",而允许程序由来自不同模块的代码装配而成,并保留程序的多种表示(运行用的、修改用的、文档用的、理解用的)。换句话说,运行时的程序结构,不必等于模块的责任结构。
几条可操作的具体准则
- 一个数据结构,连同它的内部链接、访问与修改过程,属于同一个模块——不要当作多个模块的共享接口。
- 调用某例程的指令序列,和该例程本身,属于同一模块。
- 操作系统中队列控制块的格式,应藏在一个"控制块模块"里。
- 字符编码、字母序等约定信息,藏进单个模块。
- 处理的先后顺序,尽量藏进单个模块。
还有一个容易被忽略的区分:层次结构(hierarchy / uses 关系)与"干净分解"是两个独立的好性质,别把两者混为一谈。
一条好用的自检判据
改掉某一个设计决策,需要动几个模块?
- 按设计决策切 → 答案通常接近 1;
- 按流程切 → 答案常常是 N。
对今天工程的落地意义
这篇 1972 年的文章,对今天的机器学习 / 视觉工程几乎原样适用:
- 别按 pipeline 阶段切代码,要按"易变的决策"切。 数据处理、模型骨干、增强策略、损失函数、评测指标都是易变决策,每个都应各自封装。改损失函数不该动数据加载代码。
- 接口只暴露能力,不暴露实现。
load_batch()返回张量就行;不要让下游知道数据是 npz 还是 lmdb、关键点存哪个字段。这些一旦泄进接口,换存储就是全项目重构。 - 隐藏"约定":类别定义、关键点顺序、归一化参数、颜色空间——收进一个模块,避免散落各处导致 train / eval 不一致(CV 项目最常见的坑)。
- 区分"层次结构"与"干净分解":刻意分层的 backbone / head 不等于模块解耦,两者要分别检查。
- 和 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)。
浙公网安备 33010602011771号