芭芭拉·利斯科夫
Barbara Liskov
1968 年,芭芭拉·利斯科夫(Barbara Liskov)从斯坦福大学计算机科学系获得博士学位——她是美国最早从计算机科学系拿到博士学位的女性之一(更早的玛丽·肯尼斯·凯勒 Mary Kenneth Keller 于 1965 年在威斯康星大学取得,常被列为美国第一位计算机科学博士)。她的博士论文研究的是下国际象棋残局的程序,导师是人工智能先驱约翰·麦卡锡(John McCarthy)。
五十五年后,她依然在 MIT 从事研究,仍然活跃。她留给编程世界的遗产,藏在你每次写下 if (dog instanceof Animal) 或设计一个接口的时候——你用的正是她定义的规则。
破除误解:Liskov 替换原则不是"继承的语法规则"
很多程序员知道"Liskov 替换原则"(Liskov Substitution Principle,LSP),但把它理解为一条关于类继承的语法规定。这是误解。
利斯科夫的贡献远不止于此。她在 1974 年和 1980 年代的工作回答了一个更根本的问题:什么是类型?子类型应该满足什么条件才是真正的子类型,而不只是碰巧用了同一个名字的另一个类型?
她的回答不是语法规则,而是语义契约:如果 S 是 T 的子类型,那么在程序中所有使用 T 的地方,替换为 S 之后,程序的行为必须与原来一致。这是一个关于程序意义的陈述,而非关于语法的规定。
这个区分,在面向对象语言遍地泛滥、"继承"被滥用为代码复用工具的 1990 至 2010 年代,产生了深远的影响。
现场:1974 年的 MIT,CLU 的诞生
在 CLU 之前,利斯科夫已经在系统方向出过手。1972 年,她发表了 Venus 操作系统的设计——一个实验性的分时操作系统,用信号量组织进程同步。亲手写一个真实系统的经历,让她切身体会到没有结构约束的代码会混乱到什么程度。CLU 的设计动机,正是对这种混乱的回答。
1974 年,利斯科夫在 MIT 开始领导设计 CLU 语言(取自 cluster,簇)。设计 CLU 的动机不是学术好奇,而是一个非常实际的工程问题:为什么大型程序这么难维护、这么容易出 bug?
利斯科夫的诊断是:当时的编程语言把数据结构(如整数、数组)和操作这些数据的代码分离开来,程序的任意部分都可以直接操纵内部数据——没有边界,没有封装。一个函数修改了一个全局数组的某个字段,影响了完全不相干的另一个函数,这种错误极难追踪。
她的解决方案是抽象数据类型(Abstract Data Types,ADT):把数据和操作数据的方法绑定在一起,对外只暴露操作接口,隐藏内部实现细节。
CLU 的核心语法单元叫做 cluster(簇):
stack = cluster is push, pop, top, empty
rep = array[int]
push = proc(s: cvt, x: int)
array[int]$addh(s, x)
end push
pop = proc(s: cvt) returns (int)
...
end pop
end stack这个设计在今天的程序员看来像极了 Java 的 class 或者 Python 的 class——那是因为几乎所有今天的面向对象语言,都在 CLU 奠定的思路上建造。
CLU 还有一个常被忽略的实现取舍:表示独立性靠编译期检查强制执行。cluster 外部的代码拿不到 rep 的具体类型,甚至不能写依赖内部布局的模式匹配;只有标了 cvt(convert)的过程参数才能在过程体内把抽象类型视为表示类型。这样一来,“封装”不是靠程序员自觉加 private,而是类型系统直接拒绝越界访问。后来的 Modula、Ada 私有类型,以及今天 Rust 的模块可见性规则,都能在这条线上找到亲戚——区别在于 CLU 把抽象数据类型当成语言的一等公民来设计,而不是事后打补丁。
CLU 的具体贡献包括:
| 特性 | 说明 |
|---|---|
| 抽象数据类型 | 首个在语言层面系统支持 ADT 的语言 |
| 迭代器(Iterator) | 首创受控遍历的迭代器模式,Python for x in collection 的先祖 |
| 异常(Exception) | 把异常处理(exception handling)作为语言特性,而非临时约定 |
| 多返回值 | 函数可以返回多个值(Go 语言继承了这一设计) |
| 参数化类型 | 泛型(Generic)的早期形式 |
表格里有两项值得展开。迭代器:在 CLU 之前,遍历一个集合意味着调用方必须知道它的内部结构——数组下标还是链表指针——这本身就破坏了抽象。CLU 的迭代器把"下一个元素是什么"封装在类型内部:实现用 yield 逐个产出元素,调用方只写 for x in collection,完全不接触表示细节。异常:CLU 把异常设计为类型接口的一部分——一个操作除了声明正常返回什么,还要声明它可能发出(signal)哪些异常;调用方可以就地处理,也可以继续传播。异常不是"出错就跳走"的临时手段,而是契约里写明的一种正常语义。这两条思想,今天分别活在 Python 的生成器和 Java 的受检异常里。
Liskov 替换原则:超越 OOP 的形式定义
1987 年,利斯科夫在一篇题为《数据抽象与层次》(Data Abstraction and Hierarchy)的演讲中,首次清晰表述了后来以她命名的原则。1994 年,她与耶内特·温(Jeannette Wing)在论文中给出了更严格的形式化表述:
直觉表述:如果对于每一个类型 T 的对象 o₁,都有一个类型 S 的对象 o₂,使得对所有使用 T 的程序 P,当用 o₂ 替换 o₁ 时,P 的行为不变;那么 S 是 T 的子类型。
用更简单的话说:子类型的实例,必须能无感知地替换父类型的实例,且程序的正确性不受影响。
这个原则的力量,在反例中最清晰:
反例:正方形是矩形的子类吗?
几何上,正方形(Square)是矩形(Rectangle)的特例。但在面向对象设计中,如果让 Square 继承 Rectangle,问题就出现了:
rect = Rectangle()
rect.set_width(5)
rect.set_height(3)
assert rect.area() == 15 # 矩形:宽*高=15,正确
# 如果把 Rectangle 替换为 Square:
sq = Square()
sq.set_width(5) # Square 的 set_width 会同时改变高度
sq.set_height(3) # 把高度改为3,但同时也把宽度改为3
assert sq.area() == 15 # 实际得到 9,断言失败!Square 不满足 LSP,因为替换后程序行为改变了。结论:几何上的"是",不等于程序语义上的"是子类型"。
这个例子揭示了继承的深层陷阱:语法上的继承很容易,语义上的正确子类型关系却需要认真设计。
可变集合提供另一类经典冲突:若 ImmutableSet 与 MutableSet 共享一个读接口,只读客户端可以安全地依赖任一实现;一旦有人把可变集合当作不可变集合传下去,调用方对“迭代期间内容不变”的隐含假设就被打破。解决办法通常不是更深的继承,而是把能力拆成更小的接口——可读、可写、可迭代——让类型系统表达“我只需要什么”,而不是“我是什么的一种”。利斯科夫替换原则在此表现为接口分离的压力:能替换的前提,是双方先同意同一份足够窄的契约。
可变集合提供另一类经典冲突:若 ImmutableSet 与 MutableSet 共享一个读接口,只读客户端可以安全地依赖任一实现;一旦有人把可变集合当作不可变集合传下去,调用方对“迭代期间内容不变”的隐含假设就被打破。解决办法通常不是更深的继承,而是把能力拆成更小的接口——可读、可写、可迭代——让类型系统表达“我只需要什么”,而不是“我是什么的一种”。利斯科夫替换原则在此表现为接口分离的压力:能替换的前提,是双方先同意同一份足够窄的契约。
利斯科夫与温的形式化把"行为一致"拆成了几条可检查的约束,核心是不对称的两条:子类型的前置条件只能放宽,不能收紧——调用方按父类型的要求传参,子类型不能偷偷提出更高要求;后置条件只能加强,不能减弱——父类型承诺的结果,子类型至少要兑现。此外还有父类型不变量必须保持,以及一条常被忽略的"历史约束":子类型不能引入父类型中不可能出现的状态变化方式——正方形改宽度时连动高度,违反的正是这一条。注意整套定义里没有出现"继承"二字:它约束的是行为,任何机制——接口实现、鸭子类型——只要满足这些约束,都构成合法的子类型关系。
分布式系统:Argus 与事务内存
1980 年代,利斯科夫将注意力转向分布式系统,领导开发了 Argus 编程语言。Argus 的核心问题是:当计算分布在多台机器上,机器可能随时崩溃,如何保证程序的正确性?
Argus 引入了原子行为(Atomic Actions)的概念——多个操作要么全部成功,要么全部回滚,即使中间有机器崩溃。这与数据库的事务(Transaction)概念高度相关,但应用到了更一般的分布式程序设计中。
Argus 的抽象单位叫守护者(guardian):一个封装持久状态、运行在单一节点上的对象,外部只能通过它导出的操作访问。跨节点的操作被组织成原子动作,且可以嵌套——子动作的失败可以由父动作捕获并补偿,而不必整体放弃。原子性的实现靠两阶段提交:所有参与者先表决"能否提交",协调者收齐赞成票后才正式生效,任何一环崩溃都触发回滚。这套"对象加嵌套事务"的组合,在 1980 年代就把今天微服务架构最头疼的两个问题——远程调用的部分失败、跨服务的一致性——摆到了语言层面来回答。
她还在 1990 年代研究了拜占庭容错(Byzantine Fault Tolerance)的实用化,与Miguel Castro合作提出了 PBFT 算法(Practical Byzantine Fault Tolerance,1999),这是第一个在实际系统中可用的拜占庭容错共识协议,成为今天区块链共识算法研究的基础。
PBFT 的关键突破不在“证明拜占庭将军问题有解”——Lamport 等人早已给出理论框架——而在把通信轮次、摘要认证与视图切换设计到局域网规模可运行:在 $n=3f+1$ 的副本配置下容忍 $f$ 个任意故障,正常路径只需两轮多播量级的交换即可提交。它让“恶意或失控的节点”从论文假设变成可部署的系统假设,也直接影响了后来联盟链与部分许可共识的工程模板。利斯科夫一贯的风格在此再现:先把正确性条件写清楚,再逼出一套能跑起来的协议与实现。
荣誉与影响
| 年份 | 荣誉 |
|---|---|
| 1968 | 斯坦福大学计算机科学博士(美国最早从计算机科学系获得该学位的女性之一,导师 John McCarthy) |
| 2004 | IEEE John von Neumann 奖章 |
| 2008 | ACM 图灵奖(颁奖词:"对编程语言与系统设计在实践与理论基础上的贡献,尤其是数据抽象、容错与分布式计算";2009 年 3 月颁发) |
| 2012 | 美国国家工程院院士 |
利斯科夫是历史上第二位获得图灵奖的女性(第一位是 Frances Allen,2006 年),也是为数不多的将理论研究与实际系统实现相结合的获奖者之一——她不只是提出了 ADT 的思想,还把它实现成了 CLU 编译器。
她长期在 MIT 领导编程方法论小组,培养出一批在语言、系统与验证交叉地带工作的学生与合作者;Argus、Thor 对象数据库以及后来的拜占庭容错工作,都体现同一条研究策略——用语言机制把分布式与容错的难点前置到类型与运行时里,而不是留给每个应用自己发明约定。对今天的软件工程师来说,她的遗产与其说是某一门已停用的语言,不如说是一组仍在生效的判断标准:抽象边界画在哪里、替换是否保持含义、失败是否变成可推理的原子动作。
代价与争议
利斯科夫曾指出,CLU 对工业界的直接影响不如预期——尽管它的思想通过 Simula、Smalltalk 等语言间接影响了 C++ 和 Java,但 CLU 本身并没有广泛部署。她将此部分归因于当时的"类型恐惧症":工业界害怕强类型系统带来的复杂性,更偏爱 C 那样灵活但危险的弱类型风格。
更结构性的原因或许是时机与生态:1970 年代中后期的主机与迷你机环境缺少今天这样的包管理与跨平台运行时,一门强调安全抽象的研究语言很难靠自身吸引足够多的库作者。CLU 的思想反而借道教材、论文与后续语言渗透——迭代器、异常接口、参数化抽象先成为“常见设计”,再在 Java 泛型、Python 协议与 Go 多返回值里以不同语法重生。评价这类工作若只看语言流行度,会系统性低估其对工具链默认假设的重塑。
更结构性的原因或许是时机与生态:1970 年代中后期的主机与迷你机环境缺少今天这样的包管理与跨平台运行时,一门强调安全抽象的研究语言很难靠自身吸引足够多的库作者。CLU 的思想反而借道教材、论文与后续语言渗透——迭代器、异常接口、参数化抽象先成为“常见设计”,再在 Java 泛型、Python 协议与 Go 多返回值里以不同语法重生。评价这类工作若只看语言流行度,会系统性低估其对工具链默认假设的重塑。
LSP 在实践中也面临批评:严格遵守 LSP 有时会让继承层次变得过于复杂,或强迫使用组合(Composition)而非继承——而这恰恰又是软件工程中著名的争论:"继承 vs 组合"。利斯科夫的工作实际上为"优先使用组合"一派提供了理论支撑。
还有一个微妙之处:LSP 在形式上定义了子类型关系,但实际的面向对象语言(Java、C++ 等)并不强制执行 LSP——语言的类型系统允许你用继承创造出违反 LSP 的子类,编译器不会警告你。这意味着 LSP 是一条设计规则,而非语言约束,执行依赖于程序员的训练和代码审查。
这条裂缝在库设计中尤其危险:基类作者若在文档外依赖“子类不会覆盖某方法”或“调用顺序固定”,后续扩展者很容易在不知情时打破替换性。反向也成立——框架回调、模板方法里对钩子的隐含时序假设,常常比公开签名更脆弱。利斯科夫本人强调的是行为规约先于继承树形状:先写出前置条件、后置条件与不变式,再决定谁可以替换谁;若规约写不清,所谓“面向对象重构”不过是在搬动尚未定义的承诺。对教学而言,与其让学生背 SOLID 口诀,不如拿正方形/矩形与集合可变性这类反例,强迫他们区分“看起来像”和“可替换”。
跨域连接
- 本质主义:把"正方形是矩形"当成子类型,是把日常语言里的"是"读成了本质包含。她的反例把判据换成用法:在所有使用父类型的上下文中都能顶替,才叫是。这是"意义即使用"落到可执行判据上的一次难得实例——判定不看定义,看替换之后程序还对不对。
- 生命之树与系统发育:分类学曾按外观相似度嵌套,分子证据把它推翻了——相似不等于同源。继承层次犯的是同一个错:按"看起来像"建树,而不是按"可替换的性质"建树。共同教训是,层级结构的合法性必须由一条可检验的关系定义,否则它只是命名上的方便。
- 信息不对称:信息不对称通常被当作市场失灵的来源,封装却是刻意制造并维持不对称:调用方不知道内部表示,实现方才有自由更换它。推论跟得很紧——一旦调用方开始依赖接口未曾承诺的可观察行为,这层不对称就漏了,替换成本立刻回到封装之前。
- 社会契约:替换原则的形式是成对的义务:调用方不得依赖未被承诺的性质,实现方不得削弱已作出的承诺,违约的判定只看后果不看意图。它与契约论还有一个更实际的共同点——写下来不等于能执行。编译器并不检查替换原则,执行只能落在评审与测试上。
- 软件测试:既然替换原则一般情况下不可判定,工程上只能逼近它:把父类型的测试套件原样跑在子类型上,通过即认为契约未被削弱。这条做法把一个语义命题落成了可执行手段,也划出了它的上限——测试覆盖不到的行为,替换性就无从谈起。
参考文献
- Liskov, B. The Design of the Venus Operating System. Communications of the ACM 15(3), 1972.(她早期的操作系统工作)
- Liskov, B. & Zilles, S. Programming with Abstract Data Types. ACM SIGPLAN Notices 9(4), 1974.(ADT 概念的开创性论文)
- Liskov, B., Snyder, A., Atkinson, R. & Schaffert, C. Abstraction Mechanisms in CLU. Communications of the ACM 20(8), 1977.(CLU 抽象机制的正式描述)
- Liskov, B. Data Abstraction and Hierarchy. ACM SIGPLAN Notices 23(5), 1988.(LSP 首次表述)
- Liskov, B. & Wing, J. M. A Behavioral Notion of Subtyping. ACM TOPLAS 16(6), 1994.(LSP 的形式化定义)
- Liskov, B. Distributed Programming in Argus. Communications of the ACM 31(3), 1988.(Argus 语言与分布式编程模型)
- Liskov, B. & Guttag, J. Abstraction and Specification in Program Development. MIT Press, 1986.(数据抽象方法论教科书)
- Castro, M. & Liskov, B. Practical Byzantine Fault Tolerance. OSDI 1999.