Parnas 信息隐藏 1972 — 模块化设计原则
待复核信息隐藏(information hiding)是 Parnas 提出的模块划分准则:每个模块负责藏住一个容易变的设计决定,只对外暴露稳定接口。日常类比:你家电表箱只露几个开关,电线怎么绕、保险丝怎么换,邻居不用知道——换内部接线时,外面的开关布局可以不动。
论文用 KWIC(关键词轮排索引)当例子:输入若干行文字,输出所有“循环移位”后的行,并按字母序排列。同一套功能可以按两种方式切模块——按流程图步骤切,或按“藏住什么决定”切——后者在改存储格式、是否常驻内存、何时排序时,改动范围小得多。
换句话说,这篇论文的核心不是发明 KWIC,而是回答:模块该按什么标准切开。Parnas 把模块看成责任分配:先决定谁对哪个设计决定负责,再谈代码怎么落盘。
不理解信息隐藏,下面这些事都很难解释:
- 为什么“接口稳定、实现可换”比“每个步骤一个函数”更能扛住需求变化。
- 为什么共享全局表结构、内存布局会让改一处牵动全系统。
- 为什么封装、ADT、面向对象里的 private,都在延续同一条设计纪律。
- 为什么大系统并行开发时,抽象接口比复杂共享数据格式更容易独立开工。
-
按“会变的决定”切模块,不按流程图步骤切:像装修按水电/木工/油漆分工,而不是按“先扫地再刷墙”的时间顺序硬切。流程图式分解把处理阶段当模块;信息隐藏把“怎么存行”“怎么移位”“怎么排序”各自藏进模块。
-
接口只暴露必要操作:像自动售货机只留投币和取货口。Line Storage 提供
CHAR/SETCHAR/WORDS这类调用,调用方不知道字符是四字一包还是一字一格。 -
可改性、独立开发、可理解性一起改善:像厨房改灶台不影响客厅家具摆法。论文对比两种 KWIC 分解后指出:第二种把存储布局、移位是否物化、排序何时完成等变化关在单个模块里。第一种里,这些决定往往写进跨模块共享的表格式,改起来像拆承重墙。
案例 1:流程图式分解——共享内存布局
Section titled “案例 1:流程图式分解——共享内存布局”Input → CircularShift → Alphabetize → Output# 各模块都直接读写同一块“行数组 + 指针表”逐部分解释:
- 每个模块对应处理流水线的一步,看起来很自然。
- 接口其实是共享的核心格式:字符怎么打包、行从哪开始、移位索引长什么样。
- 一旦决定“不全放内存”或“改打包方式”,几乎每个模块都要改。
- 独立开发也更难:几个组必须先一起把共享表设计定死,才能真正分头写。
案例 2:信息隐藏分解——Line Storage 藏存储
Section titled “案例 2:信息隐藏分解——Line Storage 藏存储”// 对外只暴露操作,不暴露内部数组布局interface LineStorage { char(line: number, word: number, i: number): string setChar(line: number, word: number, i: number, ch: string): void words(line: number): number lineCount(): number}逐部分解释:
- Circular Shifter、Alphabetizer、Output 只通过这些函数读写行。
- 内部可以从“全在内存”换成“磁盘分页”,或从四字符打包换成一字一格,外面代码不用动。
- 论文强调:模块是责任分配,不一定等于最终机器码里的一个子程序。
案例 3:Circular Shifter 只承诺“有移位”,少承诺顺序
Section titled “案例 3:Circular Shifter 只承诺“有移位”,少承诺顺序”interface CircularShifter { setup(lines: LineStorage): void csChar(shift: number, word: number, i: number): string // 更好:不要规定移位列表的固定生成顺序 originalLineOf(shift: number): number}逐部分解释:
- 早期设计还规定了移位列表的顺序,等于多泄露了一条可改决定。
- 若只保证“所有移位都存在、不重复、能找回原行”,就可以让移位按字母序生成,甚至让
ALPH变成空操作。 - 这就是信息隐藏的细活:接口越少泄露内部决定,未来越自由。
- 把模块当成流程图方框:原因是训练习惯从粗流程图往下拆,结果接口变成共享表结构。
- 接口泄露实现细节:原因是把存储顺序、调用时序、控制块格式写进跨模块约定,一改全炸。
- 以为模块必须是子程序:原因是忽略论文说的效率问题——频繁跨模块调用可能很慢,需要内联/汇编级拼接,同时保留模块边界用于理解和修改。
- 把层次结构当成信息隐藏本身:原因是 Dijkstra 式“uses”偏序很有用,但与“接口是否藏住设计决定”是两件独立的好事。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 系统会长期演化,存储格式、算法、设备、资源可用性都可能变。
- 多人并行开发,需要尽早靠稳定抽象接口开工,而不是先开联合设计大会定共享表。
- 你希望能单独理解、替换、测试某一块(如排序策略、行存储)。
- 教学或设计评审时,需要显式列出“最可能变的决定”。
不适用:
- 一次性脚本、两三天写完且几乎不再改的小工具(论文自己也说 KWIC 本可一周写完)。
- 性能极端敏感、又无法用内联/专用调用序列消化跨模块开销的热路径。
- 问题域本身几乎没有可变设计决定,硬拆模块只会增加跳转成本。
- 团队把“藏信息”理解成“不写文档”——隐藏的是实现,不是规格。
- 已经用共享内存表把性能抠到极限、且变更极少的遗留热路径,强行抽象可能得不偿失。
历史小故事(可跳过)
Section titled “历史小故事(可跳过)”- 1971:Parnas 在 IFIP 等场合讨论设计方法中的信息分布问题,技术报告已成形。
- 1972-05:同系列的模块规格写法论文先在 CACM 发表,强调用函数接口描述模块。
- 1972-12:本文在 CACM 正式发表,用 KWIC 对比两种分解,把准则说成“信息隐藏”。
- 同期对照:Dijkstra 的 THE 系统展示了层次“uses”关系;Parnas 明确说层次与干净分解可兼得,但不是同一件事。
- 后续影响:抽象数据类型、Modula、面向对象封装,都把“藏表示、露操作”当成默认纪律。
- 今天怎么读:遇到“微服务边界”“库 API 稳定性”争论时,仍可回到这篇的判据——边界是否藏住了最贵的变更。
- 模块边界是设计决定的防火墙:先列出会变的决定,再为每个决定建一个模块。
- 好接口是少说话的接口:只承诺调用方真正需要的行为,别把内部顺序和布局一并公开。
- 可运行代码相同,不代表设计相同:两种分解汇编后可能很像,但改文档、改人、改需求时差很远。
- 效率要用实现技巧补,不要用共享全局状态换:保留信息隐藏,再用内联等方式降低调用开销。论文甚至说:最终机器码里模块边界可以模糊,但用于修改与理解的表示必须保留边界。
- 原论文 PDF:On the Criteria To Be Used in Decomposing Systems into Modules
- Parnas, “A technique for software module specification with examples”, CACM 1972 —— 同系列的接口规格写法。
- liskov-abstraction-1974 —— 用操作定义类型,把“藏表示”推进到语言机制。
- dijkstra-goto-1968 —— 另一条软件工程经典:控制结构与可理解性。
- lampson-hints-1983 —— 系统设计启发式,可与信息隐藏对照阅读。
- brooks-no-silver-bullet-1986 —— 讨论本质复杂度时,模块化仍是少数有效武器之一。
- liskov-abstraction-1974 —— ADT 把信息隐藏落成“类型 = 操作集合”。
- dijkstra-goto-1968 —— 同样关心程序如何被人理解与修改。
- dijkstra-1965 —— THE 系统层次结构,论文用来对照“uses”偏序。
- lampson-hints-1983 —— 接口、不变式、可替换实现等工程提示。
- brooks-no-silver-bullet-1986 —— 强调没有银弹时,仍肯定概念完整性与分解。
- no-silver-bullet —— 图谱中的同主题入口,便于对照阅读。
- solid —— 现代 SOLID 里的单一职责/开闭,常被追溯到信息隐藏传统。
读完这篇,下次切模块时先问:我在藏哪个会变的决定?而不是:流程图下一框叫什么? 若答不上来“藏的是什么”,边界多半还只是文件名,不是设计。
(暂无反向链接)