Skip to content

里氏替换原则:继承关系建错了会怎样 ​

「正方形是一种矩形」在数学上成立,在代码里却会出问题。判断继承关系是否正确的标准不是「是不是一种」,而是子类能不能在父类出现的任何地方替换它,而调用方不需要知道差别。

一、先说结论 ​

  • LSP 的判断标准是行为契约,不是概念上的从属关系。 「正方形是矩形」为真,但 Square extends Rectangle 为假。
  • 三个可检查的信号:子类抛 UnsupportedOperationException、子类收紧了输入条件、调用方需要 instanceof 区别对待。
  • 违反的根源通常是「用继承复用代码」,而不是表达「可替换的子类型」。
  • 修复方式几乎总是同一个:改用组合,或者把共同契约提取成更弱的接口。
  • JDK 里也有违反的例子(Arrays.asList 返回的固定大小列表),说明这不是理论洁癖,而是真实的接口误用来源。

二、经典案例:正方形与矩形 ​

java
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 的契约写代码:

java
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 的实现,而在于继承关系本身——可变的正方形不是可变矩形的子类型。

错误:可变的 Square 继承可变的 RectangleRectangle契约:宽高独立设置SquaresetWidth 同时改高resizeAndCheck(r)r.setWidth(5)r.setHeight(4)Rectangle → 20Square → 16正确:去掉继承,各自实现接口interface ShapeRectangle不可变 recordSquare不可变 recordJDK 21 实测:同一个 resizeAndCheck 方法,传入 Rectangle 返回 20,传入 Square 返回 16
图 1 · 调用方按矩形的契约「宽和高可以独立设置」写代码,传入正方形后面积变成 16;去掉继承或让对象不可变,这个契约就不存在了

2.1 正确的做法 ​

做法一:不要继承,各自独立。

java
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 ​

java
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 子类收紧了输入条件 ​

java
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 ​

java
void handle(Notification n) {
    if (n instanceof SmsNotification sms) {
        sendWithRateLimit(sms);          // 短信要限频
    } else {
        n.send();
    }
}

出现这种代码,说明子类的行为无法透明替换。要么把差异纳入接口(给 Notification 增加 rateLimit() 之类的契约),要么承认它们不是同一族类型。

需要区分的是:模式匹配本身不是坏事。密封类(sealed)配合 switch 模式匹配是有意为之的「封闭的类型集合」,这时候穷举分支是合法的设计。区别在于类型集合是否封闭、调用方是否被期望知道所有分支。

四、为什么会建错 ​

绝大多数违反 LSP 的继承,动机都是复用代码:父类里有现成的方法,继承一下就能用。但继承同时带来了「子类型可替换」的承诺,而复用并不需要这个承诺。

java
// 为了复用而继承:Stack 继承了 Vector 的全部行为
class Stack<E> extends Vector<E> { ... }     // JDK 的历史遗留

// 结果:可以绕过栈的语义直接操作中间元素
Stack<String> stack = new Stack<>();
stack.push("a"); stack.push("b");
stack.remove(0);          // 合法,但破坏了栈的语义

判断方法很简单:问自己需要的是「行为复用」还是「类型替换」。只要复用,就用组合:

java
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 导致计数翻倍),以及封闭类型集合上用密封类型替代继承树的写法,见 封装、组合与多态。

其他设计原则的判断标准,见 设计原则。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权