第四章:里氏替换原则——子类必须能替换父类

里氏替换原则,简称 LSP。这名字听着挺学术,其实道理很简单:子类对象应该能完全替换父类对象,并且程序的行为不会出错。说白了,就是「儿子得能顶老子的班,还不能捅娄子」。

我刚开始接触这个原则时,觉得这有什么好讲的?继承本来就是 is-a 关系啊。直到我在项目中踩过坑,才明白——继承不是你想用,想用就能用

什么是里氏替换原则?

Barbara Liskov 在 1987 年提出了这个原则。它的核心定义是:

如果 S 是 T 的子类型,那么所有使用 T 对象的地方,都可以替换成 S 对象,并且程序的行为不会发生异常。

嗯,这里要注意:不是「语法上能替换」,而是「行为上能替换」。你想想看,如果子类重写了父类的方法,改变了方法的预期行为,那替换后程序就可能出问题。

违反 LSP 的典型场景

我在项目中遇到过不少违反 LSP 的代码。最经典的就是「正方形-矩形」问题。

// 违反 LSP 的经典案例
class Rectangle {
    protected int width;
    protected int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // 正方形要求宽高相等
    }
    
    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

这段代码有什么问题?我们写个测试看看:

public void testRectangle(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    // 期望面积是 50
    assert rect.getArea() == 50;  // 传入 Square 时,面积是 100!
}

看到了吗?Square 替换 Rectangle 后,行为变了。这就是典型的违反 LSP。我当年第一次看到这个例子时,心想:这设计也太蠢了吧?结果后来自己写代码时,也犯过类似的错误。

继承的规范:四个约束

要遵守 LSP,子类必须满足以下四个约束:

约束 说明 违反后果
前置条件不能强化 子类方法不能要求比父类更严格的条件 调用方按父类传参,子类却拒绝执行
后置条件不能弱化 子类方法必须保证父类承诺的结果 调用方拿到不符合预期的返回值
不变条件必须保持 父类定义的类不变量,子类必须维持 对象状态出现不一致
历史约束 子类不能修改父类允许的状态变更 父类允许修改的属性,子类却禁止了

我个人习惯用一句话记住这四个约束:子类只能扩展,不能收缩。你想想看,如果父类说「这个方法可以传 null」,子类却说「不行,必须传非 null」,那调用方按父类的约定传了 null,不就炸了吗?

实战:避免继承滥用

说实话,我见过太多滥用继承的代码了。有些团队恨不得把所有类都继承一遍,结果搞出一堆「继承地狱」。这里分享几个实战经验。

1. 优先使用组合而非继承

我曾经接手过一个项目,里面有个类继承自 ArrayList,就为了用它的 add 方法。这完全没必要。用组合多好:

// 错误:滥用继承
class ShoppingCart extends ArrayList<Item> {
    public double getTotal() {
        return this.stream().mapToDouble(Item::getPrice).sum();
    }
}

// 正确:使用组合
class ShoppingCart {
    private List<Item> items = new ArrayList<>();
    
    public void addItem(Item item) {
        items.add(item);
    }
    
    public double getTotal() {
        return items.stream().mapToDouble(Item::getPrice).sum();
    }
}

组合的好处是:你只暴露你想暴露的方法。继承会把父类的所有方法都暴露出来,万一有人调了 remove 方法,购物车的数据就乱了。

2. 用接口代替抽象类

接口天然符合 LSP。因为接口只定义行为契约,没有实现细节。子类实现接口时,只要满足契约就行。

// 推荐:用接口定义行为
interface Flyable {
    void fly();
}

class Bird implements Flyable {
    public void fly() {
        System.out.println("鸟儿在飞");
    }
}

class Airplane implements Flyable {
    public void fly() {
        System.out.println("飞机在飞");
    }
}

你看,Bird 和 Airplane 都能替换 Flyable,而且行为完全符合预期。这就是接口的魅力。

3. 警惕「is-a」的陷阱

很多人觉得「企鹅是鸟,鸟会飞,所以企鹅应该继承鸟」。但企鹅不会飞啊!这就是 is-a 关系的陷阱。

避坑指南:我曾经在一个支付系统中,把「微信支付」和「支付宝支付」都继承自一个「支付基类」。结果微信支付需要扫码,支付宝支付需要跳转页面,基类的方法根本没法统一。最后我改成接口 + 策略模式,问题迎刃而解。

知识体系图

下面这张图总结了里氏替换原则的核心要点:

里氏替换原则(LSP)知识体系 子类必须能替换父类 前置条件不能强化 后置条件不能弱化 不变条件必须保持 历史约束 实战建议:避免继承滥用 优先组合 · 多用接口 · 警惕 is-a 陷阱 常见反例:正方形继承矩形 子类改变了父类的行为约定,导致替换后程序出错 正确做法:用接口 + 组合替代继承

总结

里氏替换原则不是什么高深的理论。它就是在告诉我们:继承要谨慎,替换要安全

我的个人经验:每次写继承时,我都会问自己三个问题:

  1. 子类真的能完全替代父类吗?
  2. 子类有没有改变父类的行为约定?
  3. 能不能用组合或接口代替继承?

如果有一个问题的答案是「否」,我就会重新考虑设计。

记住:继承不是代码复用的银弹。它是一把双刃剑,用好了事半功倍,用不好后患无穷。遵守 LSP,让你的代码更健壮、更可维护。


公众号:蓝海资料掘金营,微信deep3321