第四章:里氏替换原则——子类必须能替换父类
里氏替换原则,简称 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,让你的代码更健壮、更可维护。
公众号:蓝海资料掘金营,微信deep3321