1. 概述

SOLID 设计原则由 Robert C. Martin 在 2000 年的论文《Design Principles and Design Patterns》中首次提出。这些原则帮助我们构建更易维护、更易理解、更具扩展性的软件系统。

本文重点讲解 SOLID 中的 “L” —— 里氏替换原则(Liskov Substitution Principle, LSP)。

2. 开闭原则回顾

要理解里氏替换原则,必须先搞明白开闭原则(Open/Closed Principle,即 SOLID 中的 “O”)。

开闭原则的核心思想是:软件实体应对扩展开放,对修改关闭。也就是说,新增功能应通过添加新代码实现,而不是修改已有代码。这样能降低模块间的耦合度,提升系统的可维护性。

3. 实际案例:银行系统

我们以一个银行系统为例,深入理解开闭原则及其与里氏替换原则的关系。

3.1. 不符合开闭原则的设计

假设银行系统支持两种账户:活期账户(CurrentAccount)和储蓄账户(SavingsAccount),分别由两个具体类实现。

提款服务 BankingAppWithdrawalService 直接依赖这两个具体类:

1

这种设计的问题显而易见:每当新增一种账户类型(比如定期账户),BankingAppWithdrawalService 就必须修改代码,违反了“对修改关闭”的原则

3.2. 使用开闭原则重构

为解决上述问题,我们引入一个抽象基类 Account,让具体账户类型继承它:

2

现在 BankingAppWithdrawalService 只依赖 Account 抽象类,不再关心具体实现。当新增账户类型时,无需修改服务类,只需新增子类即可。

结果:系统对扩展开放(可新增账户类型),对修改关闭(服务类无需改动)。

3.3. Java 代码示例

定义抽象账户类:

public abstract class Account {
    protected abstract void deposit(BigDecimal amount);

    /**
     * 减少账户余额,需满足金额 > 0 且账户余额不低于最低要求
     *
     * @param amount 提款金额
     */
    protected abstract void withdraw(BigDecimal amount);
}

提款服务类:

public class BankingAppWithdrawalService {
    private Account account;

    public BankingAppWithdrawalService(Account account) {
        this.account = account;
    }

    public void withdraw(BigDecimal amount) {
        account.withdraw(amount);
    }
}

3.4. 新增账户类型:定期存款

银行现在要推出一种高息定期存款账户 FixedTermDepositAccount。从业务角度看,它“是一种”账户,理应继承 Account

public class FixedTermDepositAccount extends Account {
    @Override
    protected void deposit(BigDecimal amount) {
        // 存款逻辑
    }

    @Override
    protected void withdraw(BigDecimal amount) {
        throw new UnsupportedOperationException("定期存款不支持取款!");
    }
}

但问题来了:定期账户不允许取款,withdraw 方法无法正常实现,只能抛出异常。

3.5. 测试新账户类型

尝试使用该账户进行取款:

Account myFixedTermDepositAccount = new FixedTermDepositAccount();
myFixedTermDepositAccount.deposit(new BigDecimal(1000.00));

BankingAppWithdrawalService withdrawalService = new BankingAppWithdrawalService(myFixedTermDepositAccount);
withdrawalService.withdraw(new BigDecimal(100.00));

运行结果:

Withdrawals are not supported by FixedTermDepositAccount!!

程序直接崩溃。明明类型符合,却无法正常工作,说明设计有问题。

3.6. 问题出在哪?

BankingAppWithdrawalService 依赖 Account 接口,它默认所有子类都能正常执行 withdraw 方法。而 FixedTermDepositAccount 明确不支持取款,破坏了父类的行为契约。

⚠️ 关键点:虽然语法上继承了 Account,但行为上无法替代父类,违反了里氏替换原则

3.7. 能不能在服务层处理异常?

理论上可以在 BankingAppWithdrawalService 中捕获 UnsupportedOperationException,但这意味着客户端必须知道子类的特殊行为,破坏了抽象的透明性,也违背了开闭原则。

结论:要真正实现“开闭”,必须确保所有子类都能无缝替换父类,且不破坏客户端逻辑。这就是里氏替换原则的核心价值。

4. 里氏替换原则详解

4.1. 定义

  • Robert C. Martin 的通俗解释:子类型必须能够替换其基类型
  • Barbara Liskov 的原始定义(更严谨):如果对类型 T 的所有程序 P,将对象 o2 替换为子类型 S 的对象 o1 后行为不变,则 S 是 T 的子类型。

简单说:替换后,程序行为不应改变

4.2. 何时子类可替换父类?

仅仅继承方法签名是不够的,子类必须在行为上与父类保持一致

行为契约包括:

  • 公共方法的功能
  • 参数约束(前置条件)
  • 方法执行后的状态(后置条件)
  • 对象的不变性(invariants)
  • 抛出的异常

Java 的继承机制只保证了方法签名的继承,但行为一致性需要开发者主动维护。里氏替换原则正是对这一行为一致性的约束。

5. 重构方案

5.1. 根本原因

FixedTermDepositAccount 之所以无法替换 Account,是因为 Account 错误地假设了“所有账户都支持取款”。这个假设不成立,导致继承关系“名不副实”。

5.2. 重构类图

重新设计继承结构:

3

关键改动:

  • withdraw 方法从 Account 移到新抽象类 WithdrawableAccount
  • 只有支持取款的账户(如活期、储蓄)才继承 WithdrawableAccount
  • 定期账户仍继承 Account,但只保留 deposit 方法

优势:职责更清晰,避免“伪继承”。

5.3. 重构提款服务

服务类现在依赖 WithdrawableAccount

public class BankingAppWithdrawalService {
    private WithdrawableAccount withdrawableAccount;

    public BankingAppWithdrawalService(WithdrawableAccount withdrawableAccount) {
        this.withdrawableAccount = withdrawableAccount;
    }

    public void withdraw(BigDecimal amount) {
        withdrawableAccount.withdraw(amount);
    }
}

现在,只有真正支持取款的账户才能被注入,从类型系统层面杜绝了运行时异常

6. 行为一致性规则

为确保子类可替换,需遵守以下规则(源自 Liskov 和 Guttag 的著作):

6.1. 签名规则:参数类型

子类方法参数类型应与父类相同或更宽泛(协变参数,Java 不支持)。

Java 要求重写方法的参数类型必须完全匹配,因此自动满足此规则。

6.2. 签名规则:返回类型

子类方法可返回比父类更具体的类型(返回类型协变)。

示例:

public abstract class Foo {
    public abstract Number generateNumber();    
}

public class Bar extends Foo {
    @Override
    public Integer generateNumber() { // Integer 是 Number 的子类
        return 10;
    }
}

❌ 若返回更宽泛类型(如 Object),则可能破坏客户端对返回类型的假设。

Java 支持返回类型协变,编译器会强制检查。

6.3. 签名规则:异常

子类方法应抛出比父类更少或更具体的异常(尤其是受检异常)。

  • 允许减少异常数量
  • 允许抛出父类异常的子类
  • ❌ 禁止抛出新的或更宽泛的受检异常

⚠️ 注意:Java 允许重写方法抛出任何 RuntimeException,不受此限制。

6.4. 属性规则:类不变式(Invariants)

类不变式指对象在整个生命周期中必须保持为真的条件。

子类必须维持或强化父类的不变式

示例:

public abstract class Car {
    protected int limit;
    // 不变式:speed < limit
    protected int speed;
    protected abstract void accelerate();
}

public class HybridCar extends Car {
    private int charge;
    // 自身不变式:charge >= 0

    @Override
    protected void accelerate() {
        // 必须确保 speed < limit
    }
}

子类可以定义自己的不变式,但不能破坏父类的不变式

6.5. 属性规则:历史约束(History Constraint)

子类不应允许父类禁止的状态变更

示例:里程不可重置

public abstract class Car {
    protected int mileage; // 创建后只能递增,不可重置
    public Car(int mileage) { this.mileage = mileage; }
}

public class ToyCar extends Car {
    public void reset() {
        mileage = 0; // ❌ 违反历史约束
    }
}

此类操作会破坏依赖“里程不可变”的客户端逻辑。

6.6. 方法规则:前置条件(Preconditions)

前置条件是方法执行前必须满足的条件。

子类可弱化(放宽)前置条件,但不能强化(收紧)

示例:输入范围从 1-5 放宽到 1-10

public class Bar extends Foo {
    @Override
    // 前置条件:0 < num <= 10(比父类更宽松)
    public void doStuff(int num) {
        if (num <= 0 || num > 10) {
            throw new IllegalArgumentException("Input out of range 1-10");
        }
    }
}

若子类收紧条件(如仅支持 1-3),则原合法输入(4,5)会失败,破坏客户端

6.7. 方法规则:后置条件(Postconditions)

后置条件是方法执行后必须满足的条件。

子类可强化(增加)后置条件,但不能弱化(减少)

示例:刹车后不仅减速,还回收能量

public class HybridCar extends Car {
    @Override
    // 后置条件:speed 必须减少(父类)
    // 新增:charge 必须增加
    protected void brake() {
        // 实现逻辑
    }
}

客户端只关心 speed 减少,额外保证(charge 增加)是安全的。若子类不保证 speed 减少,则直接破坏契约。

7. 常见“坏味道”识别

以下代码特征通常暗示 LSP 被违反:

7.1. 抛出 UnsupportedOperationException

@Override
protected void withdraw(BigDecimal amount) {
    throw new UnsupportedOperationException("不支持取款");
}

踩坑提示:这是典型的“设计错误,用异常掩盖”的做法,应重构继承结构。

7.2. 空实现

子类方法体为空,不执行任何逻辑:

public void deleteFile(String path) {
    // Do nothing.
}

与抛异常类似,说明方法不应存在于该类。

7.3. 客户端依赖具体子类

使用 instanceof 或向下转型:

if (fileSystem instanceof ReadOnlyFileSystem) {
    // 特殊处理
}

⚠️ 信号:抽象设计失败,客户端被迫了解内部实现。

7.4. 方法返回固定值

@Override
protected int getRemainingFuel() {
    return 0; // 玩具车永远没油
}

若该值在父类中是动态的,子类却固定返回,说明行为不一致,可能破坏依赖该行为的逻辑。

8. 总结

里氏替换原则是构建高质量继承体系的基石。它确保:

  • 子类可无缝替换父类,不破坏客户端
  • 继承关系反映真实的行为“is-a”关系,而非仅仅是语法继承
  • 配合开闭原则,实现真正的可扩展性

核心思想:继承不只是“代码复用”,更是“契约继承”。设计继承时,务必问自己:这个子类能否在任何使用父类的地方“无缝替换”而不出问题?

文中示例代码已托管至 GitHub:https://github.com/eugenp/tutorials/tree/master/patterns-modules/solid


原始标题:Liskov Substitution Principle in Java | Baeldung