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
直接依赖这两个具体类:
这种设计的问题显而易见:每当新增一种账户类型(比如定期账户),BankingAppWithdrawalService
就必须修改代码,违反了“对修改关闭”的原则。
3.2. 使用开闭原则重构
为解决上述问题,我们引入一个抽象基类 Account
,让具体账户类型继承它:
现在 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. 重构类图
重新设计继承结构:
关键改动:
- 将
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