Skip to main content
类别: 原则
类型: 系统设计原则
来源: 芭芭拉·利斯科夫,OOPSLA 主题演讲 Data Abstraction and Hierarchy,1987 年
别名: LSP;行为子类型;强行为子类型;Liskov Substitution Principle
快速回答里氏替换原则(Liskov Substitution Principle)是指:子类型只有在守住父类型的行为契约时,才能顶替父类型,让调用方不必知道手里到底是哪一个对象。芭芭拉·利斯科夫在 1987 年 OOPSLA 主题演讲里提出替换性质;她与珍妮特·温在 1994 年把它形式化。实务检验很硬:如果调用父方法之前必须先看具体种类,这棵继承树已经在说谎。

什么是里氏替换原则?

里氏替换原则是一条设计规则:子类型必须能用在父类型被期望出现的任何地方,并且不破坏那些调用方已经依赖的正确性。
若对类型 S 的每个对象 o1,都存在类型 T 的对象 o2,使得所有按 T 来写的程序 P 在把 o1 换成 o2 后行为不变,则 S 是 T 的子类型。
这不是编译器检查。编译器对的是名字和签名。这条原则对的是承诺:父类型允许调用方假定的事情,换上子类型之后仍应成立。 日常图像是代课老师。课程表仍写着“第二节数学”。能把这节课上下来的代课老师,才是可替换的。不肯点名、或必须原任坐在教室里才能教的人,不是。学校不该因为椅子上换了人,就把一天重排一遍。 在代码里,正方形继承长方形是最常被撞上的伤。长方形承诺:改宽不必改高。正方形要保持边相等,就得两边一起改。先设宽再读高的代码会吃一惊。家谱写着“是一种”。契约没有。开放封闭原则需要这条规则:如果旧客户必须先检查子类型是哪一种,你就插不进新的子类型。

里氏替换原则的三层理解

  • 入门:自称能填一个角色,就必须做这个角色已经公开的那些工作。能塞进五号电池槽、电压却不同的电芯,形状一样,契约不一样。
  • 实践:动手继承之前,先列出父类型的承诺——调用方可传入什么、可期望返回什么、什么事绝不发生。子类型可以更慷慨,不可以更挑剔。
  • 进阶:行为子类型既是快照,也是历史约束。可变对象不得引入父类型已经排除的状态变化。所以能从中间戳一把的栈,即使也有 push 和 pop,仍不是栈。

起源

里氏替换原则起于类型层次的问题,不是贴在团队墙上的口号。 芭芭拉·利斯科夫当时在 MIT 计算机科学实验室,于 OOPSLA ‘87(奥兰多,1987 年 10 月 4–8 日)做了主题演讲 Data Abstraction and Hierarchy。讲稿收入会议增补(ACM,doi 10.1145/62138.62141),后又刊于 ACM SIGPLAN Notices 23(5): 17–34(1988 年 5 月)。她在 1970 年代已用 CLU 语言做数据抽象。在 OOPSLA 上,她主张:Smalltalk 式的实现继承,并不等于调用方可以信任的类型层次。她读到的一篇论文把栈和队列当成彼此的子类型。这不可能成立:按后进先出写的代码,拿到先进先出就会坏。 她与 珍妮特·温(Jeannette Wing)在 A Behavioral Notion of SubtypingACM Transactions on Programming Languages and Systems 16(6): 1811–1841,1994 年 11 月,doi 10.1145/197320.197383)里给了这想法形式上的牙齿。他们的子类型要求是:凡能对类型 T 的对象证明的性质,对子类型 S 的对象也必须成立。这里的子类型是规格之间的关系,不是类文件之间的关系。 罗伯特·C·马丁1996 年 3 月C++ Report 工程笔记专栏 “The Liskov Substitution Principle” 里,把这想法带进日常面向对象实践。他的工作定义是:
使用基类指针或引用的函数,必须能够在不知情的情况下使用派生类的对象。
马丁 2003 年的《敏捷软件开发:原则、模式与实践》(Prentice Hall)保留了这一检验。大约 2004 年Michael Feathers 把马丁的“前五条”排成 SOLID 口诀。里氏替换原则是其中的 “L”。利斯科夫因数据抽象、容错与分布式计算获得 2008 年 ACM 图灵奖;奖项比这一条规则更宽,但名字因此留在了工程口语里。

核心要点

里氏替换原则真正值钱的时候,是一种新东西必须填进旧槽位。它不是禁止额外功能。它是一条关于哪些承诺不许收回的规则。
1

“是一种”是契约,不是长得像

共用几个字段、或图个方便去继承某个父类,并不构成子类型。父类型的调用方有权得到公开行为,而不是你的继承图。企鹅继承了 fly() 再抛异常,并不是“一种特殊的鸟”,而是父类型声称得太多。关注点分离告诉你把工作拆开。这条原则告诉你,不要用假家谱再粘回去。
2

不要把门收窄,也不要把结果变弱

子类型可以放宽前置条件(接受更多输入),加强后置条件(对输出保证更多)。箭头不能反转。父类型说“任意正数金额”,子类型拒绝小于 100 的金额,就不可替换。父类型说“返回非空列表”,子类型返回空,就不可替换。多出来的方法可以。把旧方法做得更安静、更挑剔,不行。
3

不变式和历史,在替换之后仍须成立

父类型声称对每个合法对象都为真的事情,替换后仍须为真。属性表本应只装字符串,却能通过父方法塞进整数,不变式已经破了。历史也要紧:若父类型从不允许把某个值缩小,子类型的 setWidth 顺带砍掉高度,就是在改写调用方以为已经合上的过去。
4

调用方必须查看具体种类,层次就已经失败

一串针对运行时类型的 if,或“别在这个特殊子类上调用”的注释,就是气味。每来一个新表亲,这些调用方都得改——这条原则和开放封闭原则会一起失效。单一职责原则常常压在底下:一个类型扛了两份工作,继承被当成胶水。

应用场景

当一个槽位会被几种东西填上、而旧客户不该去读警告标签时,再用里氏替换原则。不要用它逼所有相似名词都挂到同一个父类上。

在加上下一个表亲之前,先劈开说谎的层次

正方形必须保持边相等,它就不是可以随便改尺寸的长方形。定期存款不能取现,它就不是父类型已经承诺了 withdraw 的账户。做更瘦的父类型——或者不要父类型——只给每种东西它真正能兑现的方法。助教的评分类型也是同一刀:“不能改分的测验”不是脚本其余部分默认可改的“可评分项”。

用岗位本身、而不是用徽章,去核对待岗角色

不能签采购单的代理经理,替换不了流程已经依赖的经理。要么给副手同样的签字权,要么改流程,让签字不再属于这个槽位。职位名称是继承图。工作流才是契约。这里的意外既是最小惊讶原则的失败,也是替换的失败。

写出一个替补真正能担任的社团职务

学校或社区团体常常设一个“财务”,然后发现备份的人进不了银行、交不了报表、也拿不到钥匙。章程承诺的是一个角色。备份填上的是一个名字。先列出这个职务必须做的事——报账、保管钥匙、出席会议——再问谁能顶上。必须原任在场才能转的值日表,不是值日表。

把“兼容”的公共表格当成契约,而不是长得像

办事窗口处理不了的替代证件、“视同”税表、或无法按常规窗口办理的临时许可,都不可替换。市民用多跑的腿为错配付钱。若公开表格写着任何匹配附件都可以,每一份匹配附件就必须真的可以。若有些不行,就把限制印在父表格上;不要藏进一个仍用同一标题的子类型里。

经典案例

里氏替换原则作为操作伤口的有数字窗口,是 Java 的 Properties——不是声称一个库类就是类型论的全部。 JDK 1.01996 年)起,java.util.Properties 表示一份可从流加载、也可存回流的持久字符串表。它被写成 Hashtable 的子类,而 Hashtable 接受任何非空键值。父类型的公开契约,比子类型的公开工作更宽。因为这份继承,putputAll 可以打在 Properties 对象上。官方 Java API 至今仍说“强烈不鼓励”这样用,因为调用方能塞进非字符串的键或值。若再对这份“被污染”的对象调用 storelist,调用会失败——典型是类型转换错误。子类型并没有收回父方法。它继承了一扇自己守不住的门。 人们指着这件事已经几十年。2016 年 5 月 17 日,OpenJDK 缺陷 JDK-8157123 以 “java.util.Properties class is violating the Liskov’s Substitution Principle (LSP)” 为题提交。建议的修法是组合:在 Properties 里面持有一张表,而不是成为一张 Hashtable。缺陷以 Won’t Fix 关闭。公开的类型关系是对既有调用方的承诺;拿掉 extends 会是兼容性断裂。JDK 92017 年)重做了这个类,条目不再存在继承来的那张表里。发行说明 JDK-8175789 记录:值放在内部的并发映射中,并且 getter 不再同步,以减少死锁。当前 OpenJDK 源码的注释仍写着:Properties 不把值存在继承来的 Hashtable 里。这个类仍然 extends Hashtable。截至 2026 年,这句层次上的谎话还留在公开 API 里。 案例展示的是原则作为锁定,而不是风格偏好。一旦你发布“这个是那种”,你可以改内脏,可以写警告。你不能悄悄收回替换。边界说明:若使用 setProperty 并保持字符串,Properties 仍能工作。失败的是类型声称,不是每一个调用点。给新设计的教训,是 2016 年那条未能采纳的绕路:把表组合进来,不要去继承它。

边界与失效场景

当父类型的契约已经是一只杂物袋时,里氏替换原则会失效。Java 的 List “可选操作”——add 在定长或不可改列表上可以抛异常——是库级别的妥协。于是每个实现者都成了部分子类型。调用方必须读脚注。这不是再加脚注的许可证;它警告父类型画得太宽。在再堆一个会抛异常的表亲之前,先把父类型削瘦,或把接口劈开。 它也会在给已发布的谎言做补丁时失效。Properties 就是展品:正确设计是组合,而正确设计已经不可得。把一棵二十年的层次叫做“学习机会”,并不等于可以照抄。抄 2016 年的绕路,不要抄 1996 年的 extends。 常见误用是一个上帝类型,一半方法写成 NotSupported。不会飞的鸟、不能取现的账户、不能关机的设备,都不是“特化的子类型”,而是过大父类型下面更挑剔的孩子。另一种误用是行为已经分叉,却为了复用代码去继承。那种复用应放进共享助手,这是没有假“是一种”的 DRY。子类型在父类型承诺安全的调用上抛出意外异常,也和快速失败打架:程序是失败了,却失败在别人以为安全的那一次调用里。

常见误区

这个中文名容易撞上“能编译就能替换”“抛不支持就行”,以及把 SOLID 当成给类做性格测验。
编译器检查名字、类型和可见性。它不检查 setWidth 是否仍是调用方以为的意思,也不检查属性表是否仍只装字符串。行为子类型是关于已经能工作的程序的声称。绿灯构建是必要的,远远不够。
若父类型公开说过这个方法属于日常使用,抛异常就是收回承诺。标准库里的可选操作是已知污点,不是该抄的模式。要么这个方法不该在父类型上,要么每个子类型都必须兑现。写着“别在这里调用”的注释是契约上的洞,不是设计。
利斯科夫 1987 年的演讲用对象,因为那是那场会议。同样的形状出现在角色、API、文件和代理人身上。若槽位写着“任何财务”“任何附件”“任何支付方式”,顶上的人就必须做这个槽位的工作。一个“兼容”却弄坏旧读取方的 JSON 字段,是同一种失败,只是没有 class 关键字。

相关概念

这些页面挨着同一个问题:怎样让一种新东西填进旧槽位,又不让已经依赖这个槽位的人吃一惊。

开放封闭原则

新行为作为新零件到来。这条原则检验新零件是否真能坐进旧槽位。

单一职责原则

每个模块一个变更理由。假子类型常常出现在一个父类型扛了两份工作的时候。

关注点分离

把不同决策分开。更瘦的父类型往往是更干净的切口,而不是更深的树。

最小惊讶原则

能编译、行为却古怪的替换,天生就是一次惊讶。

快速失败

在父类型本该安全的地方抛异常的子类型,失败得太晚,而且失败在别人的调用里。

迪米特法则

只跟直接邻居说话。去查看表亲内部的调用方,已经绕开了父类型的契约。

一句话总结

若新零件需要一张警告、写着调用方不得把它当成父类型,它就不是子类型——把父类型削瘦,或把多出来的行为组合在旁边。