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 继承的正确使用姿势

我个人习惯,使用继承前先问自己三个问题:

  1. 子类真的是父类的一种吗? 比如「猫是动物」,没问题。「正方形是矩形」,从数学上对,但从行为上不对。
  2. 子类能完全替代父类吗? 如果调用方用父类引用操作子类对象,会不会出现意外?
  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 没被违反。如果测试要改,那继承设计就有问题。

说白了,里氏替换原则就是提醒我们:别把继承当成代码复用的工具,它应该是抽象和扩展的工具。用好它,你的代码会更容易维护,也更经得起需求变化。