核心记住一句话:基类能够针对所有的子类进行替换。
换到软件系统的实现里面就是:在软件系统中,子类应该可以替换任何基类能够出现的位置,并且经过替换以后,代码还能正常工作。
里氏替换原则(LSP)——让代码“讲武德”
生动理解:
-
别搞“特权”
父类能做的,子类必须全盘接手,比如:-
✅ 父类「鸟」会飞,子类「麻雀」也能飞
-
❌ 子类「企鹅」继承「鸟」却不会飞(坑爹设计!)
-
-
禁止“魔改”
子类方法参数要比父类更宽松,返回值要比父类更严格。即是不修改方法的核心功能、不加强输入约束、不削弱输出保证。 -
异常不乱抛
子类不新增父类未声明的异常类型。
1、3两点好理解,重点是第二点提及的不加强输入约束,不削弱输出保证是什么意思。这里举一个不加强输入约束的例子。
// 父类:动物可以吃任何食物
class Animal {void eat(Food food) { // 接受广义的Food类型System.out.println("动物吃食物");}
}// 子类:狗只能吃狗粮(违反LSP)
class Dog extends Animal {@Overridevoid eat(DogFood food) { //看似很优雅的设计实现,整体体现多态的特性 但是违反LSP 输入约束更严格(参数类型特化)System.out.println("狗吃狗粮");}
}
为什么违反LSP?
-
前置条件被加强
-
父类契约:
eat()
方法接受任何Food
类型(如Food
可以是肉类、蔬菜等)。 -
子类修改:
Dog
要求参数必须是DogFood
(如狗粮是Food
的子类)。 -
结果:原本能处理
Food
的调用逻辑,在替换为Dog
后,如果传入非DogFood
的Food
类型(如Meat
),程序将无法正常工作。
-
public class Main {public static void main(String[] args) {Animal animal = new Dog(); // 多态引用(父类指向子类对象)Food meat = new Meat(); // 创建一个肉类食物animal.eat(meat); // 编译通过,但运行时可能崩溃!// 因为Dog的eat()实际需要DogFood参数}
}
-
预期行为:父类
Animal
的eat()
本应处理所有Food
类型。 -
实际行为:子类
Dog
的eat()
无法处理Meat
,导致运行时错误(如ClassCastException
)。
提出一种符合LSP规范的设计方案:使用泛型
// 定义泛型父类,约束食物类型
abstract class Animal<T extends Food> {abstract void eat(T food); // 子类可指定具体食物类型
}// 狗只能吃狗粮(明确声明泛型类型)
class Dog extends Animal<DogFood> {@Overridevoid eat(DogFood food) { // ✅ 输入类型与父类泛型一致System.out.println("狗吃狗粮");}
}
优点:
-
调用方在编译时即可明确
Dog
需要DogFood
,避免了运行时错误。 -
通过泛型参数传递类型信息,符合LSP的隐式契约。
那么不削弱输出保证也好理解了,可以理解为子类方法的返回值或状态变更不能比父类更宽松。
我们考虑状态变更的例子。比如,父类的方法保证在调用后某个字段不为null,而子类的方法可能在调用后该字段仍为null,这违反了后置条件。针对返回值,比如,父类的方法可能返回一个非空集合,而子类的方法返回了可能为空的集合,这也是后置条件更宽松的情况。举个实际场景的例子——使用银行账户,父类的取款方法保证余额不会透支,而子类的方法允许透支,导致状态变更更宽松,这违反了后置条件。
LSP的核心教训
-
子类不能“挑食”:若父类方法接受
Food
,子类必须同样接受所有Food
类型。 -
泛型是你的朋友:当需要类型特化时,用泛型在编译期约束类型,而非运行时强制检查。
-
契约不可违背:子类必须遵守父类方法的“承诺”(参数范围、返回值、异常等),否则继承关系应被质疑。