IT 论文精读 · PAPER 43
David Parnas · Carnegie Mellon · CACM 1972
把一个大程序拆成几块(模块)来写,几乎是所有软件的必经步骤。可 1972 年,David Parnas 追问了一个当时没人认真回答的问题:到底该按什么标准去切这一刀?他给的答案叫信息隐藏(information hiding)——这正是今天你能悄悄换掉一个模块而不牵动全身、一个大团队能各写各的、以及「面向对象」「API」这些词能存在的根本原因。
最自然的切法是按步骤:程序先读输入、再加工、再排序、最后输出——那就一步一个模块,照着流程图一刀刀分。听起来天经地义,几乎每个新手都会这么干。
问题是,这些「按步骤」切出来的模块,其实共用着一个心照不宣的秘密:数据在内存里长什么样(一行怎么存、一个字怎么摆)。哪天你想换个更省内存的存法,会发现几乎每个模块都得跟着改——因为每块都直接摸着那份数据格式。牵一发动全身,这正是软件难改、难分工的病根。
他说:别按步骤切,按「秘密」切。先坐下来列一张单子——哪些设计决定以后最可能变?(数据怎么存、用什么排序法、外部数据什么格式……)然后让每一个这样的决定,单独藏进一个模块里,只有它自己知道。别的模块想用它,只能隔着一扇「窗口」(一组说好的函数)打交道,永远看不见窗口后面是怎么实现的。
把模块想成一家餐厅的厨房:你(别的模块)只在取餐窗口点菜、拿菜,厨房里用什么锅、按什么菜谱、怎么摆盘,是厨房关起门来的秘密。哪天厨房换了灶具、改了做法,只要窗口端出来的还是那道菜,你根本不用知道、也一行都不用改。于是「改动被关在一个房间里」,出不来;而且大家各管一个房间,事先只要把「窗口长什么样」约定好,就能同时开工、互不打扰。
别按「程序的处理步骤」拆模块,按「以后最可能变的设计决定」拆——每个模块藏一个这样的「秘密」,只把一扇稳定的窗口(接口)露给别人。于是改动被关在一块地方、团队能各写各的。这条准则长成了后来的封装、抽象数据类型、面向对象和 API。
(一句诚实的代价:你得猜对「什么会变」。猜错了,就把根本不会变的东西藏了起来白费劲,甚至为了「万一要变」过度设计。)
想看两种拆法的对照图、KWIC 例子和它如何长成面向对象? → 切到精读版
Parnas 指出:把系统拆成模块,不该按「处理步骤 / 流程图」来切,而应按信息隐藏(information hiding)——先列出「最可能改变的设计决定」,让每个模块各藏一个这样的「秘密」,只通过一个尽量少暴露内部的接口与外界打交道。这样改动被局限在单个模块内、团队能独立开发、模块也各自可独立理解。这篇短文是封装、抽象数据类型、面向对象、乃至现代 API / 微服务边界的思想源头。
作者 David Parnas,写于卡内基梅隆大学,发表在 1972 年 12 月的《ACM 通讯》(CACM)。它上承 1960 年代对「结构化编程」「模块化」的讨论(Dijkstra 的《Go To 有害》正是同一波思潮),却把「模块化」从一句口号变成一条可操作的判定准则;下启 1970 年代的抽象数据类型(Liskov 等)、1980 年代的面向对象,以及 Parnas 本人后续的「uses 层次」(1979)与「程序族」思想。它常被列为软件工程被引最多、影响最深的文章之一。
到 1970 年代初,「把大程序拆成模块」已被公认是好事——能分工、能各自编译调试、能替换。但当时人们说的「模块化」只到「切成几块」为止,几乎没人回答「按什么标准切」。而 Parnas 敏锐地看到:拆法不同,收益天差地别。同样是拆成模块,一种拆法让系统好改好分工,另一种拆法让它处处掣肘。
他用一个小系统(KWIC 索引)当靶子,摆出两种都「看起来合理」的拆法,逐条比较它们在可改性、可分工、可理解上的表现,从而逼出真正该用的准则。他要反对的,正是当时最流行、最「自然」的那种拆法——按流程图的处理步骤切。
任务很简单:给一堆文字行,输出它们所有循环移位、并按字母序排列。比如「the quick fox」会生成「quick fox the」「fox the quick」等,全部行的所有移位混在一起排序输出。麻雀虽小,够用来对照两种拆法。
照着数据的流动,一步一个模块:①输入(读入并存进内存)→ ②循环移位(生成所有移位)→ ③字母排序 → ④输出 → 外加一个 ⑤主控 管调度。干净、直观、和流程图一一对应。
但有个隐患:这五块都直接读写同一份内存里的数据,都得知道「行、字、字符到底怎么存」。输入模块决定了把字符打包存(比如四个字符塞一个机器字省内存),那么移位、排序、输出模块全都得照这个格式去读。存储格式这个决定,被五个模块共享着。
Parnas 的替代方案,起点完全不同——不看流程,先列「难做的、或以后最可能改的设计决定」,每个决定单独封进一个模块,对其余模块隐藏起来。于是同一个 KWIC 拆成:
取第 i 行第 j 字第 k 个字符 之类的函数——别人取字符,不知道也不关心它是打包存还是一字一格。关键差别一句话:拆法一的模块之间靠「共享一份数据结构」联系,接口把内部表示暴露无遗;拆法二的模块之间只靠「一组函数」联系,接口尽量不透露里面是怎么做的。每个模块都是一个黑盒,外面看得见的只有窗口。
① 可改性(changeability)。想换存储格式?拆法一里四个模块全得动;拆法二里改动只落在「行存储模块」内部,接口不变,别人无感。同理,想把「移位预先全算好」改成「用到时现算」以省内存,拆法二里只动循环移位器一家。把「会变的决定」和「依赖它的代码」隔开,是这条准则的全部要害。
② 可独立开发(independent development)。拆法二里模块之间只需事先约定接口(函数长什么样、行为如何),各队就能关起门各写各的实现,几乎不用互相打听内部;拆法一里大家共享数据结构,任何一方改动内部都要惊动别人,协作成本高。
③ 可理解性(comprehensibility)。拆法二里每个模块能被单独读懂、单独验证,因为它的行为由接口完整描述,不必先搞懂别的模块的内部。
Parnas 特别澄清两点,都很反直觉:其一,「模块」不是「流程图上的一步」,而是一份「职责的分派」——是「谁来负责保守哪个秘密」。其二,该被隐藏的往往不是「细枝末节」,而恰恰是那些「难的、或早晚要变的核心设计决定」;接口要设计得尽量少暴露内部,暴露越少,日后越自由。
这是一篇论证性(而非实验)的论文,它的「结果」是那张对照表:同一个 KWIC 系统,两种拆法在可改性、可分工、可理解三项上高下立判——按步骤切的版本,一个存储决定的改动会波及全部模块;按秘密切的版本,同样的改动被关在单个模块里。结论掷地有声:决定模块化好坏的,不是「切了没有」,而是「按什么标准切」;正确的标准是信息隐藏,而不是流程图。他也正面回应了效率顾虑——隐藏实现看似要多绕一层函数调用,但接口是设计约定、不等于必须用低效的子程序调用实现,完全可以在保持隐藏的前提下高效落地。
这篇短文把「模块化」从口号变成方法论,几乎定义了此后半个世纪的软件结构思想:
① 一句话:拆模块别按「处理步骤/流程图」切,按「信息隐藏」切——每个模块藏一个「最可能变的设计决定」,只露稳定接口。
② 痛点:按步骤切出的模块共享同一份数据格式,一个存储决定改动就波及全部模块,难改也难分工。
③ 核心:先列「难的/会变的设计决定」,每个决定封进一个模块当「秘密」,别人只能隔着一组函数(接口)用它,看不见实现。
④ 三大收益:可改性(改动被关在盒内)、可独立开发(只需约定接口)、可理解(每个模块单独可懂)。
⑤ 两点澄清:模块是「职责分派」不是「流程图一步」;该藏的恰是核心难题、接口要尽量少暴露。
⑥ 论证方式:拿 KWIC 索引对照两种拆法,逐条比可改/可分工/可理解——按秘密切完胜。
⑦ 影响:封装、抽象数据类型、面向对象的 class、API / 微服务边界,全是这条准则的后代。
⑧ 局限:成败押在「猜对什么会变」;多层抽象有开销、易过度设计;抽象会泄漏;只给准则未给整体结构(后由 uses 层次 / 程序族补足)。