> ## Documentation Index
> Fetch the complete documentation index at: https://meta.niceshare.site/llms.txt
> Use this file to discover all available pages before exploring further.

# 里氏替换原则

> 里氏替换原则是指子类型必须能替换父类型，且不破坏程序已依赖的正确性。了解利斯科夫起源、Java Properties 案例与边界。

<Info>
  **类别**: 原则<br />
  **类型**: 系统设计原则<br />
  **来源**: 芭芭拉·利斯科夫，OOPSLA 主题演讲 *Data Abstraction and Hierarchy*，1987 年<br />
  **别名**: LSP；行为子类型；强行为子类型；Liskov Substitution Principle
</Info>

<Note>
  **快速回答** — **里氏替换原则**（Liskov Substitution Principle）是指：子类型只有在守住父类型的行为契约时，才能顶替父类型，让调用方不必知道手里到底是哪一个对象。芭芭拉·利斯科夫在 1987 年 OOPSLA 主题演讲里提出替换性质；她与珍妮特·温在 1994 年把它形式化。实务检验很硬：如果调用父方法之前必须先看具体种类，这棵继承树已经在说谎。
</Note>

## 什么是里氏替换原则？

里氏替换原则是一条设计规则：子类型必须能用在父类型被期望出现的任何地方，并且不破坏那些调用方已经依赖的正确性。

> 若对类型 S 的每个对象 o1，都存在类型 T 的对象 o2，使得所有按 T 来写的程序 P 在把 o1 换成 o2 后行为不变，则 S 是 T 的子类型。

这不是编译器检查。编译器对的是名字和签名。这条原则对的是承诺：父类型允许调用方假定的事情，换上子类型之后仍应成立。

日常图像是代课老师。课程表仍写着“第二节数学”。能把这节课上下来的代课老师，才是可替换的。不肯点名、或必须原任坐在教室里才能教的人，不是。学校不该因为椅子上换了人，就把一天重排一遍。

在代码里，正方形继承长方形是最常被撞上的伤。长方形承诺：改宽不必改高。正方形要保持边相等，就得两边一起改。先设宽再读高的代码会吃一惊。家谱写着“是一种”。契约没有。[开放封闭原则](/zh/principles/open-closed)需要这条规则：如果旧客户必须先检查子类型是哪一种，你就插不进新的子类型。

### 里氏替换原则的三层理解

* **入门**：自称能填一个角色，就必须做这个角色已经公开的那些工作。能塞进五号电池槽、电压却不同的电芯，形状一样，契约不一样。
* **实践**：动手继承之前，先列出父类型的承诺——调用方可传入什么、可期望返回什么、什么事绝不发生。子类型可以更慷慨，不可以更挑剔。
* **进阶**：行为子类型既是快照，也是历史约束。可变对象不得引入父类型已经排除的状态变化。所以能从中间戳一把的栈，即使也有 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 Subtyping*（*ACM 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 图灵奖；奖项比这一条规则更宽，但名字因此留在了工程口语里。

## 核心要点

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

<Steps>
  <Step title="“是一种”是契约，不是长得像">
    共用几个字段、或图个方便去继承某个父类，并不构成子类型。父类型的调用方有权得到公开行为，而不是你的继承图。企鹅继承了 `fly()` 再抛异常，并不是“一种特殊的鸟”，而是父类型声称得太多。[关注点分离](/zh/principles/separation-of-concerns)告诉你把工作拆开。这条原则告诉你，不要用假家谱再粘回去。
  </Step>

  <Step title="不要把门收窄，也不要把结果变弱">
    子类型可以放宽前置条件（接受更多输入），加强后置条件（对输出保证更多）。箭头不能反转。父类型说“任意正数金额”，子类型拒绝小于 100 的金额，就不可替换。父类型说“返回非空列表”，子类型返回空，就不可替换。多出来的方法可以。把旧方法做得更安静、更挑剔，不行。
  </Step>

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

  <Step title="调用方必须查看具体种类，层次就已经失败">
    一串针对运行时类型的 `if`，或“别在这个特殊子类上调用”的注释，就是气味。每来一个新表亲，这些调用方都得改——这条原则和[开放封闭原则](/zh/principles/open-closed)会一起失效。[单一职责原则](/zh/principles/single-responsibility)常常压在底下：一个类型扛了两份工作，继承被当成胶水。
  </Step>
</Steps>

## 应用场景

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

<CardGroup cols={2}>
  <Card title="在加上下一个表亲之前，先劈开说谎的层次">
    正方形必须保持边相等，它就不是可以随便改尺寸的长方形。定期存款不能取现，它就不是父类型已经承诺了 `withdraw` 的账户。做更瘦的父类型——或者不要父类型——只给每种东西它真正能兑现的方法。助教的评分类型也是同一刀：“不能改分的测验”不是脚本其余部分默认可改的“可评分项”。
  </Card>

  <Card title="用岗位本身、而不是用徽章，去核对待岗角色">
    不能签采购单的代理经理，替换不了流程已经依赖的经理。要么给副手同样的签字权，要么改流程，让签字不再属于这个槽位。职位名称是继承图。工作流才是契约。这里的意外既是[最小惊讶原则](/zh/principles/least-astonishment)的失败，也是替换的失败。
  </Card>

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

  <Card title="把“兼容”的公共表格当成契约，而不是长得像">
    办事窗口处理不了的替代证件、“视同”税表、或无法按常规窗口办理的临时许可，都不可替换。市民用多跑的腿为错配付钱。若公开表格写着任何匹配附件都可以，每一份匹配附件就必须真的可以。若有些不行，就把限制印在父表格上；不要藏进一个仍用同一标题的子类型里。
  </Card>
</CardGroup>

## 经典案例

里氏替换原则作为操作伤口的有数字窗口，是 **Java 的 `Properties` 类**——不是声称一个库类就是类型论的全部。

从 **JDK 1.0**（**1996 年**）起，`java.util.Properties` 表示一份可从流加载、也可存回流的持久字符串表。它被写成 `Hashtable` 的子类，而 `Hashtable` 接受任何非空键值。父类型的公开契约，比子类型的公开工作更宽。因为这份继承，`put` 和 `putAll` 可以打在 `Properties` 对象上。官方 Java API 至今仍说“强烈不鼓励”这样用，因为调用方能塞进非字符串的键或值。若再对这份“被污染”的对象调用 `store` 或 `list`，调用会失败——典型是类型转换错误。子类型并没有收回父方法。它继承了一扇自己守不住的门。

人们指着这件事已经几十年。**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 9**（**2017 年**）重做了这个类，条目不再存在继承来的那张表里。发行说明 **JDK-8175789** 记录：值放在内部的并发映射中，并且 getter 不再同步，以减少死锁。当前 OpenJDK 源码的注释仍写着：`Properties` 不把值存在继承来的 `Hashtable` 里。这个类**仍然 extends** `Hashtable`。截至 **2026 年**，这句层次上的谎话还留在公开 API 里。

案例展示的是原则作为锁定，而不是风格偏好。一旦你发布“这个是那种”，你可以改内脏，可以写警告。你不能悄悄收回替换。边界说明：若使用 `setProperty` 并保持字符串，`Properties` 仍能工作。失败的是*类型*声称，不是每一个调用点。给新设计的教训，是 2016 年那条未能采纳的绕路：把表组合进来，不要去继承它。

## 边界与失效场景

当父类型的契约已经是一只杂物袋时，里氏替换原则会失效。Java 的 `List` “可选操作”——`add` 在定长或不可改列表上可以抛异常——是库级别的妥协。于是每个实现者都成了部分子类型。调用方必须读脚注。这不是再加脚注的许可证；它警告父类型画得太宽。在再堆一个会抛异常的表亲之前，先把父类型削瘦，或把接口劈开。

它也会在给已发布的谎言做补丁时失效。`Properties` 就是展品：正确设计是组合，而正确设计已经不可得。把一棵二十年的层次叫做“学习机会”，并不等于可以照抄。抄 2016 年的绕路，不要抄 1996 年的 extends。

常见误用是一个上帝类型，一半方法写成 `NotSupported`。不会飞的鸟、不能取现的账户、不能关机的设备，都不是“特化的子类型”，而是过大父类型下面更挑剔的孩子。另一种误用是行为已经分叉，却为了复用代码去继承。那种复用应放进共享助手，这是没有假“是一种”的 [DRY](/zh/principles/dry-principle)。子类型在父类型承诺安全的调用上抛出意外异常，也和[快速失败](/zh/principles/fail-fast)打架：程序是失败了，却失败在别人以为安全的那一次调用里。

## 常见误区

这个中文名容易撞上“能编译就能替换”“抛不支持就行”，以及把 SOLID 当成给类做性格测验。

<AccordionGroup>
  <Accordion title="子类能对着父类编译，就满足里氏替换原则">
    编译器检查名字、类型和可见性。它不检查 `setWidth` 是否仍是调用方以为的意思，也不检查属性表是否仍只装字符串。行为子类型是关于已经能工作的程序的声称。绿灯构建是必要的，远远不够。
  </Accordion>

  <Accordion title="子类型可以对自己不喜欢的方法抛“不支持”">
    若父类型公开说过这个方法属于日常使用，抛异常就是收回承诺。标准库里的可选操作是已知污点，不是该抄的模式。要么这个方法不该在父类型上，要么每个子类型都必须兑现。写着“别在这里调用”的注释是契约上的洞，不是设计。
  </Accordion>

  <Accordion title="里氏替换原则只属于面向对象的类">
    利斯科夫 1987 年的演讲用对象，因为那是那场会议。同样的形状出现在角色、API、文件和代理人身上。若槽位写着“任何财务”“任何附件”“任何支付方式”，顶上的人就必须做这个槽位的工作。一个“兼容”却弄坏旧读取方的 JSON 字段，是同一种失败，只是没有 class 关键字。
  </Accordion>
</AccordionGroup>

## 相关概念

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

<CardGroup cols={3}>
  <Card title="开放封闭原则" href="/zh/principles/open-closed">新行为作为新零件到来。这条原则检验新零件是否真能坐进旧槽位。</Card>
  <Card title="单一职责原则" href="/zh/principles/single-responsibility">每个模块一个变更理由。假子类型常常出现在一个父类型扛了两份工作的时候。</Card>
  <Card title="关注点分离" href="/zh/principles/separation-of-concerns">把不同决策分开。更瘦的父类型往往是更干净的切口，而不是更深的树。</Card>
  <Card title="最小惊讶原则" href="/zh/principles/least-astonishment">能编译、行为却古怪的替换，天生就是一次惊讶。</Card>
  <Card title="快速失败" href="/zh/principles/fail-fast">在父类型本该安全的地方抛异常的子类型，失败得太晚，而且失败在别人的调用里。</Card>
  <Card title="迪米特法则" href="/zh/principles/law-of-demeter">只跟直接邻居说话。去查看表亲内部的调用方，已经绕开了父类型的契约。</Card>
</CardGroup>

## 一句话总结

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