一、里氏替换原则的定义
里氏替换原则(LSP):如果对每一个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P,在把 o1 替换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
更通俗的两种表达:
- 定义 2 :所有引用基类的地方,必须能透明地使用其子类的对象。
- 一句话 :子类可以扩展父类的功能,但不能改变父类原有的功能。
记忆口诀:LSP = 子类能当父类用,用了之后什么都没变。
二、为什么需要里氏替换原则(动机)
LSP 是继承复用的基石。只有当"衍生类可以替换基类、且软件单位功能不受影响"时,基类才真正能被复用,衍生类也才能在基类基础上增加新行为。
LSP 还是开闭原则(OCP)的补充:实现 OCP 的关键步骤是抽象化,而基类与子类的继承关系正是抽象化的具体表现------LSP 就是对"如何正确落地这种抽象化"的规范。
但继承是一把双刃剑:
- 父类里已经实现好的非抽象方法,本质上是在设定一系列"规范和契约";
- 它不强制子类必须遵从,但如果子类任意修改这些方法,就会破坏整个继承体系;
- 一旦类被继承,父类修改时必须考虑所有子类,父类一变、子类功能可能集体故障,程序可移植性下降、耦合性上升。
LSP 表达的就是这一层约束:别动父类定好的契约。
三、核心思想:LSP 的 4 层含义
LSP 落实到编码,包含以下 4 层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;
- 子类中可以增加自己特有的方法;
- 当子类重载(overload)父类方法时,方法的输入参数(前置条件)应比父类更宽松;
- 当子类实现父类的抽象方法时,方法的返回值(后置条件)应比父类更严格(协变返回类型)。
第 3、4 点源自"按契约设计(Design by Contract)"视角:子类方法前置条件不能比父类更强 (否则父类能进的调用子类进不去),后置条件不能比父类更弱(否则父类保证的结果子类保证不了)。宽松入参、严格出参,才能保证透明替换。
四、代码举例:继承的"风险"反例(❌ 违反 LSP)
需求:先由类 A 完成"两数相减",后来类 B 要在 A 基础上新增"两数相加再加 100"。
java
// A:负责减法
class A {
public int func1(int a, int b) {
return a - b;
}
}
public class Client {
public static void main(String[] args) {
A a = new A();
System.out.println("100-50=" + a.func1(100, 50));
System.out.println("100-80=" + a.func1(100, 80));
}
}
运行结果(正常):
100-50=50
100-80=20
问题出现在类 B:它继承了 A,却无意中重写了 func1 改成加法 ,只新增了 func2:
java
// B 继承 A,但重写了 func1 的语义
class B extends A {
@Override
public int func1(int a, int b) {
return a + b; // 把"减法"改成了"加法",破坏了父类契约
}
public int func2(int a, int b) {
return func1(a, b) + 100; // 这里调用的已是 B 自己的加法
}
}
public class Client {
public static void main(String[] args) {
B b = new B();
System.out.println("100-50=" + b.func1(100, 50));
System.out.println("100-80=" + b.func1(100, 80));
System.out.println("100+20+100=" + b.func2(100, 20));
}
}
运行结果(相减功能已出错):
100-50=150
100-80=180
100+20+100=220
为什么错 :类 B 起名时"无意中重写了父类的 func1",导致所有原本调用相减逻辑的代码,一旦换成子类 B,全部变成了加法------引用基类 A 能正常完成的功能,替换成子类 B 后发生了异常。这正是 LSP 违反:子类改变了父类原有的功能。实际项目中,这种写法在频繁使用多态时,运行时出错概率极高。
五、进阶:正确做法(✅ 用组合替代继承)
LSP 被破坏的根因是:B 不该通过"继承 A 并改写 func1"来复用减法 。通用修法是------去掉 B 对 A 的继承,改用依赖 / 聚合 / 组合关系,让 A 的契约永远不被破坏:
java
// 减法器:专注减法,契约稳定
class SubCalculator {
public int func1(int a, int b) {
return a - b;
}
}
// 加法求和器:通过【组合】持有减法能力,自己只管加法
class AddCalculator {
private final SubCalculator sub = new SubCalculator(); // 组合复用,不继承
// B 自己的加法(不再是"重写 A 的减法")
public int func1(int a, int b) {
return a + b;
}
// 新增:两数相加再 +100
public int func2(int a, int b) {
return func1(a, b) + 100;
}
// 若确实需要减法,委托组合对象,绝不改写契约
public int subtract(int a, int b) {
return sub.func1(a, b);
}
}
public class Client {
public static void main(String[] args) {
SubCalculator a = new SubCalculator();
System.out.println("100-50=" + a.func1(100, 50)); // A 的减法永不被影响
AddCalculator b = new AddCalculator();
System.out.println("100+20+100=" + b.func2(100, 20));
System.out.println("100-50=" + b.subtract(100, 50)); // 减法仍正确
}
}
要点:
- 继承关系被切断 :
AddCalculator不再extends SubCalculator,SubCalculator.func1永远表示减法,契约不被破坏; - 复用靠组合 :
AddCalculator持有SubCalculator实例来复用减法,而非继承改写; - 这正是 LSP 落地口诀------"能组合就别为复用而继承";当确实要表达"is-a"且子类不破坏父类契约时,才用继承。
另一种等价修法(按原笔记思路):让 A、B 共同继承一个"更通用的基类",各自实现自己的契约、互不改写------核心同样是不让子类改写父类的非抽象方法。
六、JDK 与真实框架中的 LSP
✅ 正面范例(良好的可替换性):
List体系 :ArrayList/LinkedList/Vector都是List的子类型,任何接收List的地方都能透明传入任一实现;Map体系 :HashMap/TreeMap/ConcurrentHashMap对Map可无缝替换;InputStream体系 :FileInputStream/ByteArrayInputStream/BufferedInputStream对InputStream消费者完全透明;Number体系 :Integer/Double/Long都是Number,在需要数值的地方可替换(这些包装类被设为final,也避免了被错误改写)。
❌ 反面教材(JDK 里真实的 LSP 违反):
java.util.Stack extends Vector:Stack本应是"后进先出"栈,却继承了Vector,于是调用者可以对一个Stack调insertElementAt、add任意位置插入元素,直接破坏栈的 LIFO 契约------这是不推荐用继承的经典反例,现代更推荐用ArrayDeque实现栈语义;java.sql.Date extends java.util.Date:SQL 的Date表示"不带时间的日期",却继承了带时间的java.util.Date,二者语义冲突,是被广泛讨论的 LSP 异味。
这些反面例子说明:即便 JDK 本身,也曾因"为了复用而继承"踩过 LSP 的坑,更提醒我们继承前先问"子类真的能完全替代父类吗"。
七、典型应用场景
- 多态调用的业务体系 :如支付策略(
AliPay/WechatPay都替换PayStrategy)、日志实现,必须保证可透明替换; - 框架扩展点 :Spring 的
ApplicationContext各实现、HandlerInterceptor各实现,都靠 LSP 保证替换安全; - 重构"为复用而继承"的旧代码:发现子类在重写父类非抽象方法时,改用组合;
- 设计基类/抽象类时:明确哪些方法是"契约"(禁止子类破坏),哪些是"可扩展点"。
八、遵循 LSP 的收益 / 违反的代价
遵循 LSP 的收益:
- 继承体系健壮:父类契约稳定,子类只扩不破,修改子类不影响依赖父类的代码;
- 多态安全:放心用父类类型接收子类对象,运行时不会"行为突变";
- 真正可复用:基类能被放心复用,符合 OCP(对扩展开放、对修改关闭);
- 降低耦合风险:减少"父类一改、子类集体故障"的连锁反应。
违反 LSP 的代价:
- 隐蔽的运行时错误:编译通过,运行却行为异常(如本文相减变相加);
- 继承体系脆弱:父类修改牵一发动全身;
- 可移植性下降、耦合上升:被继承的类处处受制。
九、与其它原则辨析 + 总结
LSP vs 开闭原则(OCP) :LSP 是 OCP 的基石与补充。OCP 靠抽象化实现,而抽象化靠继承落地;没有 LSP 保证"子类可替换",抽象化就无法安全替换,OCP 也就无从谈起。
LSP vs 单一职责原则(SRP) :二者常一起被违反。B 既想当"减法器"又想当"加法器",既违反 SRP(多职责)又违反 LSP(改写父类契约)。拆分职责往往同时修复两者。
LSP vs 组合复用原则 :当"为复用而继承"会破坏 LSP 时,优先用组合/聚合/依赖替代继承------本文本例就是典型。
一句话总结 :继承不是用来"顺手复用父类方法"的,而是用来表达"子类可完全替代父类"的。只要子类需要改写父类已有的非抽象方法,就该警惕 LSP 违反------退一步,用组合。
落地清单:
- 子类是否改写了父类的非抽象方法?→ 是,极可能违反 LSP;
- 把子类对象赋给父类引用后,原有功能行为变了吗?→ 变了,违反 LSP;
- 能否用组合持有父类能力,而非继承改写?→ 能,优先组合。