跳转到内容

Parnas 信息隐藏 1972 — 模块化设计原则

待复核

信息隐藏(information hiding)是 Parnas 提出的模块划分准则:每个模块负责藏住一个容易变的设计决定,只对外暴露稳定接口。日常类比:你家电表箱只露几个开关,电线怎么绕、保险丝怎么换,邻居不用知道——换内部接线时,外面的开关布局可以不动。

论文用 KWIC(关键词轮排索引)当例子:输入若干行文字,输出所有“循环移位”后的行,并按字母序排列。同一套功能可以按两种方式切模块——按流程图步骤切,或按“藏住什么决定”切——后者在改存储格式、是否常驻内存、何时排序时,改动范围小得多。

换句话说,这篇论文的核心不是发明 KWIC,而是回答:模块该按什么标准切开。Parnas 把模块看成责任分配:先决定谁对哪个设计决定负责,再谈代码怎么落盘。

不理解信息隐藏,下面这些事都很难解释:

  • 为什么“接口稳定、实现可换”比“每个步骤一个函数”更能扛住需求变化。
  • 为什么共享全局表结构、内存布局会让改一处牵动全系统。
  • 为什么封装、ADT、面向对象里的 private,都在延续同一条设计纪律。
  • 为什么大系统并行开发时,抽象接口比复杂共享数据格式更容易独立开工。
  1. 按“会变的决定”切模块,不按流程图步骤切:像装修按水电/木工/油漆分工,而不是按“先扫地再刷墙”的时间顺序硬切。流程图式分解把处理阶段当模块;信息隐藏把“怎么存行”“怎么移位”“怎么排序”各自藏进模块。

  2. 接口只暴露必要操作:像自动售货机只留投币和取货口。Line Storage 提供 CHAR / SETCHAR / WORDS 这类调用,调用方不知道字符是四字一包还是一字一格。

  3. 可改性、独立开发、可理解性一起改善:像厨房改灶台不影响客厅家具摆法。论文对比两种 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 变成空操作。
  • 这就是信息隐藏的细活:接口越少泄露内部决定,未来越自由
  1. 把模块当成流程图方框:原因是训练习惯从粗流程图往下拆,结果接口变成共享表结构。
  2. 接口泄露实现细节:原因是把存储顺序、调用时序、控制块格式写进跨模块约定,一改全炸。
  3. 以为模块必须是子程序:原因是忽略论文说的效率问题——频繁跨模块调用可能很慢,需要内联/汇编级拼接,同时保留模块边界用于理解和修改。
  4. 把层次结构当成信息隐藏本身:原因是 Dijkstra 式“uses”偏序很有用,但与“接口是否藏住设计决定”是两件独立的好事。

适用

  • 系统会长期演化,存储格式、算法、设备、资源可用性都可能变。
  • 多人并行开发,需要尽早靠稳定抽象接口开工,而不是先开联合设计大会定共享表。
  • 你希望能单独理解、替换、测试某一块(如排序策略、行存储)。
  • 教学或设计评审时,需要显式列出“最可能变的决定”。

不适用

  • 一次性脚本、两三天写完且几乎不再改的小工具(论文自己也说 KWIC 本可一周写完)。
  • 性能极端敏感、又无法用内联/专用调用序列消化跨模块开销的热路径。
  • 问题域本身几乎没有可变设计决定,硬拆模块只会增加跳转成本。
  • 团队把“藏信息”理解成“不写文档”——隐藏的是实现,不是规格。
  • 已经用共享内存表把性能抠到极限、且变更极少的遗留热路径,强行抽象可能得不偿失。
  • 1971:Parnas 在 IFIP 等场合讨论设计方法中的信息分布问题,技术报告已成形。
  • 1972-05:同系列的模块规格写法论文先在 CACM 发表,强调用函数接口描述模块。
  • 1972-12:本文在 CACM 正式发表,用 KWIC 对比两种分解,把准则说成“信息隐藏”。
  • 同期对照:Dijkstra 的 THE 系统展示了层次“uses”关系;Parnas 明确说层次与干净分解可兼得,但不是同一件事。
  • 后续影响:抽象数据类型、Modula、面向对象封装,都把“藏表示、露操作”当成默认纪律。
  • 今天怎么读:遇到“微服务边界”“库 API 稳定性”争论时,仍可回到这篇的判据——边界是否藏住了最贵的变更。
  1. 模块边界是设计决定的防火墙:先列出会变的决定,再为每个决定建一个模块。
  2. 好接口是少说话的接口:只承诺调用方真正需要的行为,别把内部顺序和布局一并公开。
  3. 可运行代码相同,不代表设计相同:两种分解汇编后可能很像,但改文档、改人、改需求时差很远。
  4. 效率要用实现技巧补,不要用共享全局状态换:保留信息隐藏,再用内联等方式降低调用开销。论文甚至说:最终机器码里模块边界可以模糊,但用于修改与理解的表示必须保留边界。

读完这篇,下次切模块时先问:我在藏哪个会变的决定?而不是:流程图下一框叫什么? 若答不上来“藏的是什么”,边界多半还只是文件名,不是设计。

(暂无反向链接)