里氏替换原则(Liskov Substitution Principle,简称 LSP)由 Barbara Liskov 在 1987 年提出,是面向对象设计的五大 SOLID 原则之一。它的核心主张是:如果类型 S 是类型 T 的子类型,那么程序中所有使用 T 对象的地方,都应该能够透明地使用 S 对象,且不会破坏程序原有的正确性。换句话说,子类必须能够替换掉它们的父类,而调用方完全感知不到差异。

一、里氏替换原则的底层逻辑
从形式化角度看,里氏替换原则定义了所谓“行为子类型化”(behavioral subtyping)。一个子类型不仅要语法上兼容父类,更要在语义上遵守父类所建立的契约。这些契约通常包括:前置条件不能比父类更严格,后置条件不能比父类更弱,以及子类不能抛出父类方法未声明的受检异常。只有这样,依赖父类型抽象的调用代码才不会因为传入了子类型实例而产生非预期结果。
在 Java 这类静态类型语言中,编译器只保证类型系统的结构兼容,并不验证行为兼容。例如子类重写方法时缩小了返回值范围,编译器可能通过,但运行时逻辑已经违背了替换安全。因此 LSP 更多是一种设计纪律,而非语法强制。理解它需要从“客户代码对父类的信任假设”出发:客户相信父类方法做了什么,子类就只能在此基础上增强,而不能推翻。
1.1 契约视角下的替换条件
我们可以将父类方法看作一份合同:输入什么、输出什么、在什么情况下失败。子类作为合同的实现方,可以承诺“做得更多”,但不能“要求更多”或“保证更少”。比如父类声明“传入正数返回平方根”,子类就不能要求传入非负数以外还要是整数,否则就收紧了前置条件;子类也不能返回近似值而声称精确值,否则弱化了后置条件。
这种设计约束直接影响了 API 的演化稳定性。遵循 LSP 的继承体系,可以让上层业务在不修改一行代码的情况下接入新子类,这也是开闭原则能够成立的基础。反之,破坏 LSP 的继承往往意味着调用方必须写大量的 instanceof 判断,多态优势丧失殆尽。
二、Java 中常见的违反示例
很多 Java 初学者会以为“能继承、能编译”就是合理的复用,结果在运行时踩坑。下面是一段典型的违反里氏替换原则的代码:父类 Rectangle 表示矩形,子类 Square 试图表示正方形,但重写了设置宽高的方法。
class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) {
this.width = w;
}
public void setHeight(int h) {
this.height = h;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int w) {
// 正方形要求宽高相等,破坏了父类独立设置宽高的契约
this.width = w;
this.height = w;
}
@Override
public void setHeight(int h) {
this.width = h;
this.height = h;
}
}
上述代码中,如果调用方拿到一个 Rectangle 引用并依次调用 setWidth(5)、setHeight(4),期望面积是 20。但当实际对象是 Square 时,面积变成了 16。这就是典型的替换失败:子类改变了父类方法的可观察行为。客户代码基于父类建立的假设被打破,程序正确性受损。
另一个常见问题是子类重写方法时抛出父类没有声明的新异常。例如父类方法只声明了 IOException,子类却抛出了 RuntimeException 的子类如 IllegalArgumentException,虽然 Java 语法允许非受检异常,但从契约角度看,它引入了父类调用方未预期的控制流,同样削弱了替换安全性。
2.1 用抽象消解错误的继承关系
要解决 Rectangle 与 Square 的困境,应当放弃让 Square 继承 Rectangle,而是提取一个更抽象的 Shape 接口,各自实现面积计算,互不干扰。这样客户代码依赖 Shape,而不是依赖具体尺寸设置行为。
interface Shape {
int getArea();
}
class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int w, int h) {
this.width = w;
this.height = h;
}
@Override
public int getArea() {
return width * height;
}
}
class Square implements Shape {
private int side;
public Square(int s) {
this.side = s;
}
@Override
public int getArea() {
return side * side;
}
}
在这个重构版本里,Rectangle 和 Square 都是 Shape 的子类型,但彼此没有继承羁绊。调用方通过 Shape 接口获取面积,完全不需要关心背后是矩形还是正方形,替换安全自然成立。这也体现了“组合优于继承”的工程取向。
当业务确实需要从同一基类派生时,应当使用模板方法模式,把可变部分延迟到子类,而不变的部分和契约由父类固化。这样子类只能填充空白,无法重写已经定义好的流程契约,从机制上防止 LSP 被破坏。
三、在 Java 中落地里氏替换原则的实践
要在日常开发中守住这条规范,第一步是设计父类时明确契约:用注释或文档说明方法的前置条件、后置保证和不变量。Java 的 @throws 标签、Objects.requireNonNull 等断言可以显式表达前置条件,便于子类对照。
第二步是在单元测试中采用“子类型可替换”测试法。对父类编写的测试套件,应当能够直接运行在子类实例上而不失败。如果某子类让父类的测试挂掉,那就说明它违反了 LSP。这种测试驱动的方式比人工评审更可靠。
3.1 利用泛型与通配符强化替换
Java 泛型中的上界通配符(? extends T)本质上就是里氏替换在类型系统里的映射。当方法接受 List<? extends Number> 时,无论传入 ArrayList<Integer> 还是 LinkedList<Double>,读取操作都是安全的,因为所有子类型都能当 Number 用。理解这一点,有助于在 API 设计时用类型边界表达替换关系。
public static double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) {
total += n.doubleValue();
}
return total;
}
上例中的 sum 方法不关心具体数字类型,只依赖 Number 的 doubleValue 契约。任何 Number 子类型都能替换进来,且行为一致。这就是 LSP 在集合与泛型层面的自然体现。相反,若方法内部对 Integer 做了特殊强转,就悄悄破坏了替换假设。
最后需要强调,里氏替换原则不是反对继承,而是反对“语义不正确”的继承。当发现子类越来越像父类又处处违背后者约定时,应当果断抽离共同抽象或用委托替代。保持替换性,系统的扩展成本才会随模块数量近似线性增长,而不是指数崩坏。
四、总结与避坑清单
里氏替换原则是 Java OOP 设计行为规范里的承重墙。它要求子类在行为上可被视为父类,不抛出异常、不收紧输入、不弱化输出。实践中,避免让子类改变父类方法的明显语义,避免用继承表达“is-like”而非“is-a”的关系。
以下是简要的避坑清单:
- 重写方法时不要抛出父类未声明的受检异常
- 不要缩小方法的可访问性,例如父类 public 子类改成 protected
- 不要改变方法的核心后置条件,如返回值含义或范围
- 当子类需要大量推翻父类行为时,改用组合或接口抽象
- 为父类编写的行为测试应当能在子类上原样通过
把这份清单贴在代码评审模板里,能显著降低继承滥用带来的隐性故障。里氏替换原则守住了,多态才真正可信,业务代码才能在不接触底层实现的情况下平滑演进。
里氏替换原则Liskov_Substitution_PrincipleJava_OOP修改时间:2026-08-09 08:03:38