里氏替换原则
里氏替换原则(Liskov Substitution Principle, LSP)是面向对象设计的核心原则之一,属于 SOLID 中的 L。其核心思想是:子类必须能够替换父类,且替换后程序的行为不变。简单来说,所有使用父类的地方,都应该能透明地替换成子类,而不会引发错误或意外行为。
核心规则
-
行为一致性:子类的方法行为应与父类一致(或更严格)。
-
契约不变:子类不能修改父类定义的方法参数、返回值类型或抛出的异常范围。
-
前置条件不加强:子类方法的输入条件不能比父类更严格。
-
后置条件不削弱:子类方法的输出结果不能比父类更宽松。
违反 LSP 的经典案例
案例 1:正方形继承长方形
数学中,正方形是特殊的长方形,但在编程中直接继承会违反 LSP。
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; } } 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); // 同时修改宽度 } }
问题:当程序以父类 Rectangle
的方式使用子类 Square
时,行为会异常:
public static void testArea(Rectangle rect) { rect.setWidth(5); rect.setHeight(4); System.out.println("期望面积 20,实际面积:" + rect.getArea()); } public static void main(String[] args) { testArea(new Rectangle()); // 输出 20(正确) testArea(new Square()); // 输出 16(错误!) }
原因:Square
修改了 setWidth
和 setHeight
的行为,导致结果不符合预期。
改进方案:
避免让 Square
直接继承 Rectangle
,而是通过接口或组合实现共性。
案例 2:企鹅继承鸟类
假设父类 Bird
有 fly()
方法,但子类企鹅不会飞:
class Bird { public void fly() { System.out.println("飞行中..."); } } class Penguin extends Bird { @Override public void fly() { throw new UnsupportedOperationException("企鹅不会飞!"); } }
问题:当程序调用 Bird
的 fly()
时,替换为 Penguin
会抛出异常:
public static void makeBirdFly(Bird bird) { bird.fly(); // 如果 bird 是企鹅,程序崩溃! } public static void main(String[] args) { makeBirdFly(new Bird()); // 正常 makeBirdFly(new Penguin()); // 抛出异常! }
改进方案:
将飞行能力分离为接口,只有会飞的鸟才实现它:
interface Flyable { void fly(); } class Sparrow implements Flyable { public void fly() { /* 实现飞行 */ } } class Penguin extends Bird { // 不实现 Flyable 接口 }
符合 LSP 的案例
案例:支付系统
父类 Payment
定义支付行为,子类 CreditCardPayment
和 WeChatPayment
实现各自逻辑,但保持行为一致。
abstract class Payment { public abstract void pay(int amount); } class CreditCardPayment extends Payment { @Override public void pay(int amount) { System.out.println("信用卡支付:" + amount + "元"); } } class WeChatPayment extends Payment { @Override public void pay(int amount) { System.out.println("微信支付:" + amount + "元"); } }
使用场景:
无论替换成哪种支付方式,程序行为一致。
public static void processPayment(Payment payment, int amount) { payment.pay(amount); } public static void main(String[] args) { processPayment(new CreditCardPayment(), 100); // 信用卡支付 processPayment(new WeChatPayment(), 200); // 微信支付 }
总结
-
里氏替换原则的本质:子类必须保持与父类的契约,确保继承是“is-a”关系(如“企鹅是鸟”不成立,但“麻雀是鸟”成立)。
-
如何遵守 LSP:
-
优先使用组合而非继承。
-
通过接口分离不同行为。
-
子类不重写父类非抽象方法,避免破坏原有逻辑。
-
我们通过具体案例逐一解释里氏替换原则(LSP)的四个核心规则,并说明如何避免违反这些规则。
1. 行为一致性:子类方法的行为应与父类一致(或更严格)
反例:子类修改父类行为
class Bird { // 父类方法:飞行距离为100米 public int fly() { return 100; } } class Ostrich extends Bird { // 鸵鸟不会飞 @Override public int fly() { return 0; // 直接返回0,违反行为一致性 } }
问题:
当程序中依赖 Bird
的 fly()
方法时,替换为 Ostrich
会导致意外结果:
void calculateDistance(Bird bird) { int distance = bird.fly() * 2; System.out.println("飞行总距离:" + distance); } calculateDistance(new Bird()); // 输出 200(正确) calculateDistance(new Ostrich()); // 输出 0(错误!鸵鸟被误用为会飞的鸟)
改进方案:
分离“会飞”和“不会飞”的行为,通过接口实现:
interface Flyable { int fly(); } class Sparrow implements Flyable { public int fly() { return 100; } } class Ostrich extends Bird { // 不实现 Flyable 接口 }
2. 契约不变:子类不能修改父类方法的参数、返回值或异常
反例:子类修改返回值类型
class Database { // 父类方法返回一个可修改的 List public List<String> getData() { return new ArrayList<>(Arrays.asList("A", "B")); } } class ReadOnlyDatabase extends Database { @Override public List<String> getData() { // 返回不可修改的 List(契约改变!) return Collections.unmodifiableList(super.getData()); } }
问题:
父类允许修改返回的 List
,但子类返回不可修改的列表,导致客户端代码崩溃:
Database db = new ReadOnlyDatabase(); db.getData().add("C"); // 抛出 UnsupportedOperationException
改进方案:
保持返回值行为一致,或通过新方法明确只读特性:
class ReadOnlyDatabase extends Database { // 新增方法,而非重写 getData() public List<String> getReadOnlyData() { return Collections.unmodifiableList(super.getData()); } }
3. 前置条件不加强:子类方法的输入条件不能比父类更严格
反例:子类强化参数校验
class Account { // 父类允许任何金额的取款 public void withdraw(int amount) { balance -= amount; } } class SafeAccount extends Account { @Override public void withdraw(int amount) { if (amount > balance) { // 新增前置条件! throw new IllegalArgumentException("余额不足!"); } super.withdraw(amount); } }
问题:
当父类允许透支,而子类禁止时,替换子类会破坏原有逻辑:
Account account = new SafeAccount(); account.withdraw(1000); // 如果 balance=500,抛出异常 // 但父类 Account 的设计允许透支
改进方案:
如果父类允许透支,子类不应修改这一行为。可通过新类表示“安全账户”:
class SafeAccount { // 不继承 Account public void safeWithdraw(int amount) { if (amount > balance) throw new IllegalArgumentException(); balance -= amount; } }
4. 后置条件不削弱:子类方法的输出结果不能比父类更宽松
反例:子类返回值范围扩大
class Calculator { // 父类保证返回值 >=0 public int sqrt(int x) { if (x < 0) throw new IllegalArgumentException(); return (int) Math.sqrt(x); } } class BuggyCalculator extends Calculator { @Override public int sqrt(int x) { // 未处理负数输入,可能返回负数(后置条件削弱!) return (int) Math.sqrt(x); } }
问题:
父类保证结果非负,但子类未处理负数输入,导致客户端代码出现意外值:
Calculator calc = new BuggyCalculator(); int result = calc.sqrt(-4); // 返回 NaN 或其他未定义值(违反契约)
改进方案:
子类必须维持父类的后置条件:
class BuggyCalculator extends Calculator { @Override public int sqrt(int x) { if (x < 0) throw new IllegalArgumentException(); // 保持后置条件 return super.sqrt(x); } }
总结
规则 | 关键点 | 避免方法 |
---|---|---|
行为一致性 | 子类不改变父类核心逻辑 | 通过接口/组合替代继承 |
契约不变 | 参数、返回值、异常与父类严格一致 | 避免重写非抽象方法 |
前置条件不加强 | 子类不增加输入限制 | 子类复用父类校验逻辑 |
后置条件不削弱 | 子类不放松输出约束 | 子类覆盖时明确校验并抛出异常 |
核心思想:子类必须完全遵守父类的“契约”,才能透明替换父类。
问题背景
假设存在一个报表处理系统,定义了一个通用接口 CommonHandler
,要求所有业务模块实现类完成请求校验(businessCheck
)和业务执行(businessExecute
)功能。以下是两个实现类的关键代码片段:
1. 户用模块策略类(ResidentialStrategy
)
@Service public class ResidentialStrategy implements CommonHandler { @Override public void businessCheck(CommonRequest request) { // 校验报表类型 if (null == ReportEnum.getDateTypeEnum(request.getReportType())) { throw new BusinessException("无效的报表类型"); } // 新增校验:日期字段必填 if (null == request.getDate()) { throw new BusinessException("日期不能为空"); } } }
2. 商用模块策略类(CommercialStrategy
)
@Service public class CommercialStrategy implements CommonHandler { @Override public void businessCheck(CommonRequest request) { // 仅校验报表类型 if (null == ReportEnum.getDateTypeEnum(request.getReportType())) { throw new BusinessException("无效的报表类型"); } } }
接口定义(CommonHandler
)
public interface CommonHandler { void businessCheck(CommonRequest request); CommonResponse businessExecute(CommonRequest request); }
LSP 规则回顾:前置条件不加强
LSP 要求子类(或实现类)方法的输入条件(前置条件)不能比父类(或接口)更严格。若父类未对某个字段进行非空校验,而子类强制校验,则违反了这一规则。
问题分析
在以上代码中:
-
接口契约:
CommonHandler
的businessCheck
方法未明确要求校验date
字段。 -
实现类行为:
-
CommercialStrategy
仅校验reportType
,符合接口契约。 -
ResidentialStrategy
额外校验date
字段,单方面加强前置条件。
-
违反 LSP 的后果
当通过 CommonHandler
接口调用 businessCheck
方法时:
-
替换为
ResidentialStrategy
的实现时,若date
为空,会抛出异常。 -
替换为其他实现类(如
CommercialStrategy
)时,date
为空不会报错。
结果:ResidentialStrategy
无法透明替换 CommonHandler
,破坏了一致性。
改进方案:分层校验设计
通过接口的默认方法(default
)统一基础校验,允许子类在遵守契约的前提下扩展校验逻辑。
1. 修改接口定义
public interface CommonHandler { default void businessCheck(CommonRequest request) { // 基础校验:报表类型必填 if (null == ReportEnum.getDateTypeEnum(request.getReportType())) { throw new BusinessException("无效的报表类型"); } } CommonResponse businessExecute(CommonRequest request); }
2. 实现类按需扩展校验
@Service public class ResidentialStrategy implements CommonHandler { @Override public void businessCheck(CommonRequest request) { // 调用接口默认的基础校验 CommonHandler.super.businessCheck(request); // 扩展校验:日期必填 if (null == request.getDate()) { throw new BusinessException("日期不能为空"); } } }
改进后的设计解析
-
接口明确基础契约
-
通过
default
方法统一实现reportType
校验,所有实现类必须遵守。 -
子类无需重复编写基础校验逻辑。
-
-
子类灵活扩展
-
子类可调用
super.businessCheck()
后补充额外校验(如date
字段)。 -
附加校验不视为加强前置条件,因为基础契约已通过默认方法满足。
-
-
透明替换性保障
-
所有实现类均通过
CommonHandler
接口暴露行为。 -
调用方无需关心具体实现类的校验差异。
-
结论
在接口设计中遵循里氏替换原则,关键在于:
-
明确契约:通过默认方法或文档定义接口的基础行为约束。
-
分层校验:基础校验由接口统一实现,子类按需扩展而非覆盖。
-
契约优先:实现类的扩展逻辑不应破坏接口的原始语义。
通过这种设计,既能保证系统的行为一致性,又能灵活支持不同业务的个性化需求,最终实现高内聚、低耦合的代码结构。