里氏替换原则详解:子类必须能透明替换基类(含继承风险演示 + 正确重构 + JDK 案例)

一、里氏替换原则的定义

里氏替换原则(LSP):如果对每一个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P,在把 o1 替换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

更通俗的两种表达:

  • 定义 2 :所有引用基类的地方,必须能透明地使用其子类的对象。
  • 一句话 :子类可以扩展父类的功能,但不能改变父类原有的功能。

记忆口诀:LSP = 子类能当父类用,用了之后什么都没变。


二、为什么需要里氏替换原则(动机)

LSP 是继承复用的基石。只有当"衍生类可以替换基类、且软件单位功能不受影响"时,基类才真正能被复用,衍生类也才能在基类基础上增加新行为。

LSP 还是开闭原则(OCP)的补充:实现 OCP 的关键步骤是抽象化,而基类与子类的继承关系正是抽象化的具体表现------LSP 就是对"如何正确落地这种抽象化"的规范。

但继承是一把双刃剑:

  • 父类里已经实现好的非抽象方法,本质上是在设定一系列"规范和契约";
  • 它不强制子类必须遵从,但如果子类任意修改这些方法,就会破坏整个继承体系;
  • 一旦类被继承,父类修改时必须考虑所有子类,父类一变、子类功能可能集体故障,程序可移植性下降、耦合性上升。

LSP 表达的就是这一层约束:别动父类定好的契约。


三、核心思想:LSP 的 4 层含义

LSP 落实到编码,包含以下 4 层含义:

  1. 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;
  2. 子类中可以增加自己特有的方法;
  3. 当子类重载(overload)父类方法时,方法的输入参数(前置条件)应比父类更宽松;
  4. 当子类实现父类的抽象方法时,方法的返回值(后置条件)应比父类更严格(协变返回类型)。

第 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

✅ 正面范例(良好的可替换性):

  1. List 体系 :ArrayList / LinkedList / Vector 都是 List 的子类型,任何接收 List 的地方都能透明传入任一实现;
  2. Map 体系 :HashMap / TreeMap / ConcurrentHashMap 对 Map 可无缝替换;
  3. InputStream 体系 :FileInputStream / ByteArrayInputStream / BufferedInputStream 对 InputStream 消费者完全透明;
  4. 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 的坑,更提醒我们继承前先问"子类真的能完全替代父类吗"。


七、典型应用场景

  1. 多态调用的业务体系 :如支付策略(AliPay / WechatPay 都替换 PayStrategy)、日志实现,必须保证可透明替换;
  2. 框架扩展点 :Spring 的 ApplicationContext 各实现、HandlerInterceptor 各实现,都靠 LSP 保证替换安全;
  3. 重构"为复用而继承"的旧代码:发现子类在重写父类非抽象方法时,改用组合;
  4. 设计基类/抽象类时:明确哪些方法是"契约"(禁止子类破坏),哪些是"可扩展点"。

八、遵循 LSP 的收益 / 违反的代价

遵循 LSP 的收益:

  1. 继承体系健壮:父类契约稳定,子类只扩不破,修改子类不影响依赖父类的代码;
  2. 多态安全:放心用父类类型接收子类对象,运行时不会"行为突变";
  3. 真正可复用:基类能被放心复用,符合 OCP(对扩展开放、对修改关闭);
  4. 降低耦合风险:减少"父类一改、子类集体故障"的连锁反应。

违反 LSP 的代价:

  1. 隐蔽的运行时错误:编译通过,运行却行为异常(如本文相减变相加);
  2. 继承体系脆弱:父类修改牵一发动全身;
  3. 可移植性下降、耦合上升:被继承的类处处受制。

九、与其它原则辨析 + 总结

LSP vs 开闭原则(OCP) :LSP 是 OCP 的基石与补充。OCP 靠抽象化实现,而抽象化靠继承落地;没有 LSP 保证"子类可替换",抽象化就无法安全替换,OCP 也就无从谈起。

LSP vs 单一职责原则(SRP) :二者常一起被违反。B 既想当"减法器"又想当"加法器",既违反 SRP(多职责)又违反 LSP(改写父类契约)。拆分职责往往同时修复两者。

LSP vs 组合复用原则 :当"为复用而继承"会破坏 LSP 时,优先用组合/聚合/依赖替代继承------本文本例就是典型。

一句话总结 :继承不是用来"顺手复用父类方法"的,而是用来表达"子类可完全替代父类"的。只要子类需要改写父类已有的非抽象方法,就该警惕 LSP 违反------退一步,用组合。

落地清单:

  • 子类是否改写了父类的非抽象方法?→ 是,极可能违反 LSP;
  • 把子类对象赋给父类引用后,原有功能行为变了吗?→ 变了,违反 LSP;
  • 能否用组合持有父类能力,而非继承改写?→ 能,优先组合。
相关推荐
kevien_G12 小时前
微服务之OpenFeign最佳实践
java·微服务·架构
江湖有缘2 小时前
Docker实战 | 使用Docker部署ShipShipShip自托管产品更新与功能投票系统
java·linux·服务器·docker
范什么特西2 小时前
亲和路由11
java
诺NA亚2 小时前
ElasticSearch+RAG初使用
java
SamDeepThinking3 小时前
synchronized 和 lock 有什么区别?
java·后端·面试
m0_587383003 小时前
无人自助拼豆系统的全栈开发方案
java·后端·struts·小程序·架构
临期冰淇淋3 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
人道领域3 小时前
【面试八股速通篇01】锁与并发工具
java·开发语言·面试·c#
Wang's Blog3 小时前
Java 项目实战: 交友项目 - Dubbo 入门案例全流程复盘
java·dubbo·交友