4、里氏替换原则:子类必须能替换父类
里氏替换原则,简称 LSP。这名字听着挺学术,其实道理很简单:子类对象必须能替换掉父类对象,并且程序的行为不会出错。
我刚开始接触这个原则时,觉得这不是废话吗?继承不就是为了复用父类行为吗?后来在项目中踩了几次坑,才明白这个原则有多重要。说白了,它是在约束我们「怎么用继承」。
4.1 什么是里氏替换原则?
Barbara Liskov 在 1987 年提出了这个原则。核心定义是:
如果 S 是 T 的子类型,那么所有使用 T 对象的地方,都可以用 S 对象替换,且程序的行为不会发生异常。
嗯,这里要注意。替换后不是要求行为完全一致,而是不能破坏程序的正确性。你想想看,如果子类重写了父类的方法,但调用方拿到子类对象后,发现行为跟预期完全不一样,那继承的意义就没了。
4.2 违反 LSP 的典型场景
我在项目中见过不少违反 LSP 的代码。最经典的就是「正方形-矩形」问题。
// 父类:矩形
public 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;
}
}
// 子类:正方形
public 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(4);
// 期望面积是 20
assert rect.getArea() == 20; // 传入 Square 时,结果是 16!
}
为什么会这样?因为 Square 重写了 setWidth 和 setHeight,破坏了父类的行为约定。调用方以为设置宽高后面积是 20,结果正方形给了 16。
我曾经在一个支付系统中见过类似的坑。 有人把「优惠券」设计成「支付方式」的子类。结果在计算支付金额时,优惠券的「支付」方法直接返回了优惠金额,而不是实际扣款。测试时没发现,上线后用户用优惠券支付,订单状态全乱了。
4.3 继承的正确使用姿势
我个人习惯,使用继承前先问自己三个问题:
- 子类真的是父类的一种吗? 比如「猫是动物」,没问题。「正方形是矩形」,从数学上对,但从行为上不对。
- 子类能完全替代父类吗? 如果调用方用父类引用操作子类对象,会不会出现意外?
- 子类有没有削弱父类的功能? 比如父类方法抛出异常,子类重写后抛出更宽泛的异常,这就违反了 LSP。
来看一个符合 LSP 的例子:
// 抽象父类:支付处理器
public abstract class PaymentProcessor {
public abstract boolean processPayment(double amount);
public abstract String getPaymentType();
}
// 子类:支付宝支付
public class AlipayProcessor extends PaymentProcessor {
@Override
public boolean processPayment(double amount) {
// 调用支付宝 API
System.out.println("支付宝支付:" + amount);
return true;
}
@Override
public String getPaymentType() {
return "Alipay";
}
}
// 子类:微信支付
public class WechatProcessor extends PaymentProcessor {
@Override
public boolean processPayment(double amount) {
// 调用微信 API
System.out.println("微信支付:" + amount);
return true;
}
@Override
public String getPaymentType() {
return "Wechat";
}
}
这两个子类替换父类后,调用方完全不用关心具体实现。这就是 LSP 的价值。
4.4 避免继承滥用的实战案例
我记得有个项目,团队里新人特别喜欢用继承。看到两个类有相似字段,就搞个父类出来。结果继承层次越来越深,改一个父类方法,十几个子类跟着受影响。
这里我分享几个实战中总结的避坑指南:
我曾经接手过一个老系统,继承层次有 6 层深。 最顶层的父类叫 BaseEntity,里面有个 save 方法。结果某个子类重写了 save,但忘记调用 super.save(),导致数据没落库。排查了整整两天。
什么时候该用继承?
- 子类和父类有明确的「is-a」关系
- 子类不会改变父类的核心行为
- 父类的所有方法对子类都有意义
什么时候该用组合?
- 只是复用代码,没有「is-a」关系
- 子类需要屏蔽父类的某些方法
- 继承层次超过 3 层
来看一个用组合替代继承的例子:
// 错误做法:用继承复用打印功能
public class OrderService extends Printer {
public void createOrder() {
print("创建订单");
}
}
// 正确做法:用组合
public class OrderService {
private Printer printer;
public OrderService(Printer printer) {
this.printer = printer;
}
public void createOrder() {
printer.print("创建订单");
}
}
你想想看,OrderService 和 Printer 有「is-a」关系吗?没有。OrderService 不是一种 Printer,它只是需要打印功能。用组合更合适。
4.5 如何判断是否违反 LSP
我总结了一个简单的检查清单:
| 检查项 | 说明 |
|---|---|
| 子类是否削弱了父类的功能 | 比如父类方法返回 Collection,子类返回 List 没问题。但如果父类返回 List,子类返回 null,就违反了 |
| 子类是否抛出了新的异常 | 父类没抛异常,子类抛了,调用方会崩溃 |
| 子类是否改变了父类的约定 | 比如父类 setter 不校验参数,子类加了非空校验,调用方传 null 就会出问题 |
| 子类是否破坏了父类的不变量 | 比如正方形的宽高必须相等,但父类矩形没有这个约束 |
记住一句话: 继承不是为了复用代码,而是为了表达「子类是父类的一种」。如果只是为了复用,用组合。如果子类会改变父类的行为,那就不该继承。
最后说个我自己的习惯。每次写完继承关系,我都会写个单元测试,用父类引用去调用子类对象。如果测试通过,说明 LSP 没被违反。如果测试要改,那继承设计就有问题。
说白了,里氏替换原则就是提醒我们:别把继承当成代码复用的工具,它应该是抽象和扩展的工具。用好它,你的代码会更容易维护,也更经得起需求变化。