里氏替换原则:继承关系建错了会怎样
「正方形是一种矩形」在数学上成立,在代码里却会出问题。判断继承关系是否正确的标准不是「是不是一种」,而是子类能不能在父类出现的任何地方替换它,而调用方不需要知道差别。
一、先说结论
- LSP 的判断标准是行为契约,不是概念上的从属关系。 「正方形是矩形」为真,但
Square extends Rectangle为假。 - 三个可检查的信号:子类抛
UnsupportedOperationException、子类收紧了输入条件、调用方需要instanceof区别对待。 - 违反的根源通常是「用继承复用代码」,而不是表达「可替换的子类型」。
- 修复方式几乎总是同一个:改用组合,或者把共同契约提取成更弱的接口。
- JDK 里也有违反的例子(
Arrays.asList返回的固定大小列表),说明这不是理论洁癖,而是真实的接口误用来源。
二、经典案例:正方形与矩形
class Rectangle {
protected int width, height;
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { this.width = w; this.height = w; } // 保持正方形
@Override void setHeight(int h) { this.width = h; this.height = h; }
}调用方按 Rectangle 的契约写代码:
void resizeAndCheck(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // 传入 Square 时得到 16,断言失败
}实测在 JDK 21 上,同一个方法传入 Rectangle 得到 20,传入 Square 得到 16。
Rectangle 的隐含契约是「宽和高可以独立设置」,Square 破坏了它。问题不在于 Square 的实现,而在于继承关系本身——可变的正方形不是可变矩形的子类型。
2.1 正确的做法
做法一:不要继承,各自独立。
interface Shape { int area(); }
record Rectangle(int width, int height) implements Shape {
public int area() { return width * height; }
}
record Square(int side) implements Shape {
public int area() { return side * side; }
}做法二:让对象不可变。 冲突来自「可变」——不提供 setter,改尺寸时返回新对象,Square 就可以安全地成为 Rectangle 的子类型(因为没有「独立设置宽高」这个契约了)。
三、三个可检查的信号
3.1 子类抛 UnsupportedOperationException
List<String> list = Arrays.asList("a", "b");
list.add("c"); // UnsupportedOperationException:固定大小的列表Arrays.asList 返回的列表声明为 List,却不支持 add。调用方按 List 的契约写代码就会在运行时失败。同类的还有 Collections.unmodifiableList() 和 Java 9 的 List.of()。
JDK 的做法是在文档中标注「可选操作」,这是一种妥协:把编译期能发现的问题推迟到了运行时。自己设计接口时应该避免——把「能不能写」做成不同的接口,而不是同一个接口的不同实现。
3.2 子类收紧了输入条件
class Account {
void withdraw(BigDecimal amount) { ... } // 任意正数
}
class SavingsAccount extends Account {
@Override void withdraw(BigDecimal amount) {
if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new IllegalArgumentException("单笔不能超过 1000"); // 比父类更严格
}
...
}
}调用方持有 Account 引用时,不知道这个限制的存在。规则是:子类可以放宽输入条件、收紧输出条件,不能反过来(前置条件不能加强,后置条件不能削弱)。
如果限制是真实的业务规则,它应该体现在类型或方法签名上,比如让 withdraw 返回一个结果对象,明确表达「可能被拒绝」。
3.3 调用方需要 instanceof
void handle(Notification n) {
if (n instanceof SmsNotification sms) {
sendWithRateLimit(sms); // 短信要限频
} else {
n.send();
}
}出现这种代码,说明子类的行为无法透明替换。要么把差异纳入接口(给 Notification 增加 rateLimit() 之类的契约),要么承认它们不是同一族类型。
需要区分的是:模式匹配本身不是坏事。密封类(sealed)配合 switch 模式匹配是有意为之的「封闭的类型集合」,这时候穷举分支是合法的设计。区别在于类型集合是否封闭、调用方是否被期望知道所有分支。
四、为什么会建错
绝大多数违反 LSP 的继承,动机都是复用代码:父类里有现成的方法,继承一下就能用。但继承同时带来了「子类型可替换」的承诺,而复用并不需要这个承诺。
// 为了复用而继承:Stack 继承了 Vector 的全部行为
class Stack<E> extends Vector<E> { ... } // JDK 的历史遗留
// 结果:可以绕过栈的语义直接操作中间元素
Stack<String> stack = new Stack<>();
stack.push("a"); stack.push("b");
stack.remove(0); // 合法,但破坏了栈的语义判断方法很简单:问自己需要的是「行为复用」还是「类型替换」。只要复用,就用组合:
class Stack<E> {
private final Deque<E> items = new ArrayDeque<>(); // 组合,只暴露栈的操作
void push(E e) { items.addLast(e); }
E pop() { return items.pollLast(); }
}五、设计时的检查清单
- [ ] 子类有没有抛出「不支持」的异常?
- [ ] 子类有没有对参数加更严格的校验?
- [ ] 子类有没有改变方法的副作用范围(比如父类只读、子类还写了日志表)?
- [ ] 调用方有没有需要
instanceof判断? - [ ] 父类的方法注释里,有没有子类不满足的隐含约定?
- [ ] 如果只是想复用代码,是不是应该改用组合?
其中最容易被忽略的是隐含约定:父类文档里写的「本方法不会修改集合」「返回值永不为 null」,同样是契约的一部分。用 @Override 时要读父类的文档,而不只是看签名。
六、常见误区
- 「概念上是一种,就可以继承」:概念从属与行为可替换是两回事。
- 「子类加个限制没关系」:调用方拿着父类引用,不知道限制存在。
- 「抛
UnsupportedOperationException是标准做法」:JDK 这么做是历史妥协,新代码应该用更小的接口。 - 「组合比继承啰嗦」:组合多写几行委托方法,换来的是明确的契约边界。
- 「用了接口就不会违反 LSP」:接口实现同样要满足契约,比如
equals的对称性、Comparator的传递性。
小结
继承是一种承诺:任何用父类的地方,都能换成子类而不出问题。建立继承关系前先检查这个承诺能否兑现——子类会不会拒绝某些操作、会不会加严输入、调用方会不会需要区分。如果只是想复用实现,组合是更诚实的选择。
组合与继承的实测对比(继承 HashSet 导致计数翻倍),以及封闭类型集合上用密封类型替代继承树的写法,见 封装、组合与多态。
其他设计原则的判断标准,见 设计原则。
配套实验
- codesphere-labs/design/encapsulation-composition-polymorphism:
src/Liskov.java复现可变Square继承可变Rectangle的反例,以及各自实现Shape的不可变 record(验证记录)
参考资料
- Barbara Liskov、Jeannette Wing,《A Behavioral Notion of Subtyping》(1994)
- Joshua Bloch,《Effective Java》第 3 版,第 18 条「复合优先于继承」
- JDK 21:Arrays.asList
- JDK 21:Sealed Classes