类的设计原则(三):里氏替换原则(LSP)——继承体系的契约精神

摘要

里氏替换原则(Liskov Substitution Principle, LSP)是面向对象继承设计的"宪法",它规定了子类如何正确地扩展父类功能而不破坏系统行为。本文将深入剖析LSP的核心契约、实现方法、违反后果及现代应用,通过丰富的Java代码示例展示如何构建符合替换性的继承体系,并分析其与多态、设计模式的关系。

一、LSP的本质解析

1.1 经典定义

Barbara Liskov在1987年提出:

"若对每个类型T的对象o1,都存在类型S的对象o2,使得在所有针对T编写的程序P中,用o2替换o1后P的行为不变,则S是T的子类型。"

1.2 核心契约矩阵

契约维度 父类保证 子类承诺
方法签名 参数/返回类型 协变返回/不变参数
前置条件 输入约束 不强于父类
后置条件 输出承诺 不弱于父类
不变量 对象状态约束 保持或增强
异常 抛出异常类型 不抛出新异常

1.3 违反LSP的典型症状

  • 子类方法抛出父类未声明的异常
  • 子类方法返回与父类不兼容的类型
  • 子类强化了前置条件导致父类用法失效
  • 子类弱化后置条件破坏调用方预期

二、LSP的代码实践

2.1 典型违反案例

// 矩形基类
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;
    }
}

// 正方形子类 - 违反LSP
class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width); // 强制保持宽高相等
    }
    
    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height); // 强制保持宽高相等
    }
}

// 客户端代码
public class GeometryTest {
    public static void resize(Rectangle rect) {
        while (rect.getArea() <= 100) {
            rect.setWidth(rect.getWidth() + 1);
            rect.setHeight(rect.getHeight() + 1);
        }
    }
    
    public static void main(String[] args) {
        Rectangle rect = new Square();  // 多态替换
        rect.setWidth(5);
        rect.setHeight(10);
        resize(rect);  // 无限循环!违反LSP
    }
}

2.2 符合LSP的重构方案

// 形状接口
interface Shape {
    int getArea();
}

// 矩形实现
class Rectangle implements Shape {
    private int width;
    private int height;
    
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

// 正方形实现
class Square implements Shape {
    private int side;
    
    public Square(int side) {
        this.side = side;
    }
    
    public int getArea() {
        return side * side;
    }
}

// 客户端代码
public class GeometryTest {
    public static void enlarge(Shape shape) {
        // 通过其他方式扩大形状(不依赖set方法)
    }
}

2.3 重构效果对比

维度 重构前 重构后
替换性 正方形无法替换矩形 均可作为Shape使用
行为一致性 setHeight影响width 各自独立实现
扩展性 难以添加新形状 易扩展新形状
多态安全 运行时行为异常 编译时类型安全

三、LSP的高级应用

3.1 契约式设计(Design by Contract)

// 银行账户基类
abstract class BankAccount {
    protected double balance;
    
    // 前置条件:amount必须为正数
    public final void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        doDeposit(amount);
    }
    
    // 后置条件:balance必须增加amount
    protected abstract void doDeposit(double amount);
    
    // 不变量:balance不能为负
    public boolean isBalanceValid() {
        return balance >= 0;
    }
}

// 子类实现
class SavingsAccount extends BankAccount {
    protected void doDeposit(double amount) {
        balance += amount;
        // 可能添加利息计算等扩展
    }
}

class CheckingAccount extends BankAccount {
    protected void doDeposit(double amount) {
        balance += amount - 1;  // 违反后置条件!手续费不应影响存款契约
    }
}

3.2 集合框架中的LSP

// Java集合框架完美体现LSP
List<String> list = new ArrayList<>();  // 可替换为LinkedList
list.add("item");
list = Collections.unmodifiableList(list); // 不可变列表仍符合List契约

// 违反LSP的反例(假设存在)
class BucketList<E> extends ArrayList<E> {
    @Override
    public boolean add(E e) {
        if (size() >= 10) throw new BucketFullException(); // 强化前置条件
        return super.add(e);
    }
}

四、LSP的边界把控

4.1 继承与组合的选择

场景 继承适用 组合适用
关系类型 "is-a"严格关系 "has-a"或"uses-a"
行为要求 完全符合父类契约 需要部分功能
变化频率 父类稳定少变 可能频繁变化
复用需求 需要多态特性 仅需功能复用

4.2 继承设计检查表

  1. 子类是否完全实现父类抽象方法?
  2. 子类是否强化了前置条件?
  3. 子类是否弱化了后置条件?
  4. 子类是否保持或加强了不变量?
  5. 子类是否引入了新的异常?

五、LSP的常见误区

5.1 语法继承vs行为继承

// 语法上合法但违反LSP
class Penguin extends Bird {  // 企鹅不会飞!
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

// 正确设计
class Bird { /* 基础功能 */ }
interface FlyingBird { void fly(); }
class Sparrow extends Bird implements FlyingBird { /*...*/ }
class Penguin extends Bird { /*...*/ }

5.2 正确实践建议

  • 优先组合:除非严格满足"is-a"关系,否则使用组合
  • 契约测试:为父类编写单元测试,子类必须通过
  • 避免重写:用抽象方法强制子类实现而非重写具体方法
  • 接口隔离:通过细粒度接口定义角色能力

六、LSP在现代架构中的应用

6.1 微服务接口设计

// 支付服务接口
interface PaymentService {
    PaymentResult process(PaymentRequest request) 
        throws PaymentException;
}

// REST实现
class RestPaymentService implements PaymentService {
    public PaymentResult process(PaymentRequest request) {
        // HTTP调用实现
    }
}

// gRPC实现
class GrpcPaymentService implements PaymentService {
    public PaymentResult process(PaymentRequest request) {
        // gRPC调用实现
    }
}

// 客户端代码无需关心具体实现
PaymentService service = new GrpcPaymentService();
service.process(request);  // 可替换为Rest实现

6.2 测试替身(Test Double)

// 用户存储接口
interface UserRepository {
    User findById(String id);
    void save(User user);
}

// 真实实现
class JpaUserRepository implements UserRepository { /*...*/ }

// 测试替身 - 必须符合LSP
class MockUserRepository implements UserRepository {
    private Map<String, User> users = new HashMap<>();
    
    public User findById(String id) {
        return users.get(id);
    }
    
    public void save(User user) {
        users.put(user.getId(), user);
    }
}

// 在生产代码和测试中可互换使用

七、LSP的演进思考

7.1 与SOLID其他原则的关系

原则 与LSP的协同
单一职责 职责单一更易满足LSP
开闭原则 LSP是OCP的基础
接口隔离 细粒度接口降低LSP难度
依赖倒置 依赖抽象使LSP更易实施

7.2 函数式编程视角

// 函数签名中的LSP
Function<Integer, Number> processor = n -> n * 1.5;
processor = n -> BigInteger.valueOf(n);  // 返回值类型更具体 - 协变

Consumer<Number> consumer = n -> System.out.println(n);
consumer = n -> System.out.println(n.intValue());  // 参数类型更抽象 - 逆变

总结

里氏替换原则是面向对象继承体系的基石,其核心价值在于:

  1. 多态安全:保证子类替换父类时的行为一致性
  2. 设计规范:指导如何正确建立继承关系
  3. 架构稳定:防止继承滥用导致的系统脆弱
  4. 团队协作:明确子类开发者的契约责任

实践路线图

  1. 严格评估"is-a"关系是否成立
  2. 明确父类契约(前置/后置条件、不变量)
  3. 子类设计时进行契约验证
  4. 优先考虑组合而非继承
  5. 为复杂继承体系编写契约测试

记住:LSP不是禁止继承,而是规范继承的正确使用方式。在下一篇文章中,我们将探讨接口隔离原则(ISP)如何定义恰当的接口粒度,这与LSP密切相关。