开闭原则(OCP)与里氏替换原则(LSP)

共 17 题
📑 题目列表 17 题
#
★★★

1. 开闭原则(OCP)的扩展性边界中通过继承 vs 通过组合的取舍

开闭原则(OCP)要求"对扩展开放、对修改关闭",实现该原则时应如何取舍继承与组合两种扩展方式?

  • OCP 的核心定义
  • 继承实现扩展的局限
  • 组合/策略实现扩展的优势

OCP 指"对扩展开放、对修改关闭":软件实体应通过扩展新代码来应对变化,而不是修改既有代码。实现扩展有两种方式:继承(子类覆写方法)和组合(依赖注入行为/策略)。继承实现扩展简单,但有局限:耦合基类、易产生脆弱基类问题、层级深、无法在运行时动态改变行为。组合则更优——通过接口抽象 + 策略注入,把变化封装在可替换的策略中,新增功能只需新增一个实现类,不改既有代码,且支持运行时动态切换。因此现代实践更推崇"组合优于继承"作为 OCP 的落地手段。

OCP 的精髓是"把变化封装起来"。组合通过接口与策略把变化点抽离,使系统对新行为开放、对修改关闭。继承虽然也能实现扩展,但修改基类常波及子类,破坏"对修改关闭"。面试时点出"组合 + 接口抽象是 OCP 的主流实现"即可。

#
★★★

2. Liskov Substitution Principle(LSP)——子类型必须能够替换父类型而不改变程序正确性

解释 Liskov 替换原则(LSP)的定义,并说明它为什么重要?

  • LSP 的正式定义
  • 子类型替换父类型不破坏正确性
  • 违反 LSP 的后果

Liskov 替换原则(LSP)由 Barbara Liskov 提出:如果 S 是 T 的子类型,那么任何使用 T 的地方都可以用 S 替换,且程序行为不发生破坏正确性的变化。也就是说,基类/接口的契约(哪些方法可用、前置后置条件、返回约定)必须被子类完整保持。LSP 是多态的基础:如果子类破坏了基类契约(如基类方法抛异常、实现空操作、改变语义),那么调用方按基类协议编写的代码在替换为子类后就会出错。这会导致"instanceof 判断""类型检查"泛滥,破坏开闭原则。

LSP 是"继承正确性"的保证。它要求子类只能加强(或保持)基类契约,不能削弱。违反 LSP 的典型是"正方形继承矩形"——替换后行为变化。坚持 LSP 才能让多态安全、让 OCP 可信。

#
★★★

3. Contract-based substitutability(契约可替换)中前置条件、后置条件、不变量的继承规则

从契约式设计(Design by Contract)角度,说明子类继承父类时前置条件、后置条件、不变量应如何约束?

  • 前置条件、后置条件、不变量的概念
  • 继承时前置条件只能放宽、后置条件只能加强
  • 不变量必须保持

契约式设计把方法的行为描述为前置条件(调用前必须满足)、后置条件(调用后保证成立)和不变量(对象始终成立的性质)。子类替换父类(LSP)时遵循的规则是:前置条件只能放宽(不能比父类更苛刻),后置条件只能加强(不能比父类更弱),不变量必须保持(子类不能破坏父类保证的状态性质)。如果子类收窄前置条件,调用方按父类契约传入的合法参数会失败;如果子类削弱后置条件,调用方按父类契约得到的保证会落空。两者都破坏 LSP。

契约是 LSP 的精确表述。子类"只能加强不能削弱"既是契约要求也是逻辑要求——子类替换父类后,父类保证的承诺必须仍然成立。这条规则是判断继承是否安全的核心工具。

#
★★★

4. LSP 的违反反例中正方形继承矩形(resize 行为变化)、子类抛异常、缩小前置条件

给出 LSP 的典型反例(正方形继承矩形、子类抛异常、缩小前置条件),说明它们如何违反 LSP?

  • 正方形继承矩形反例
  • 子类抛出不受支持异常
  • 缩小前置条件

经典反例:若 Square 继承 Rectangle,方法 setWidth 和 setHeight 在 Rectangle 中独立设置,但 Square 要求宽高相等。调用方按 Rectangle 逻辑"先 setWidth(5) 再 setHeight(3) 期望面积为 15",替换成 Square 后面积却是 9,行为被破坏,违反 LSP。其他反例:子类对父类方法抛 UnsupportedOperationException(如只读集合继承可变集合——一个方法在父类可调、子类却抛出异常);子类缩小前置条件(父类允许 0-100 的输入,子类只接受 0-50),调用方按父类契约传入 60 会失败。这些反例的共同点是"子类削弱了父类承诺的契约"。

判断继承是否违反 LSP 的关键是"is-a 关系是否成立"——不仅是逻辑上的 is-a,更是行为上的 can-substitute。正方形与矩形是"数学 is-a"但非"行为 is-a";只读集合与可变集合同理。解决方式是改用组合或接口,而非继承。

class Rectangle {
    void setWidth(int w) { this.w = w; }
    void setHeight(int h) { this.h = h; }
    int area() { return w * h; }
}
class Square extends Rectangle {   // 违反 LSP
    @Override void setWidth(int w) { this.w = w; this.h = w; }
    @Override void setHeight(int h) { this.h = h; this.w = h; }
}
// 调用方按 Rectangle 契约:setWidth(5); setHeight(3); area()==15 失败
#
★★★

5. Sealed class(封闭类)作为可控的多态边界(Java 17、Kotlin、Scala 3)

说明 Java 17 的 sealed class(封闭类)如何提供可控的多态边界,并对比 Kotlin、Scala 3?

  • sealed class 的作用与语法
  • 限定允许的子类集合
  • 与穷尽模式匹配(exhaustive)配合

sealed class(封闭类)允许显式声明"哪些类可以继承自己",从而限定多态的子类集合。Java 17 中 sealed class Shape permits Circle, Square 表示只有 Circle、Square 可以继承 Shape。这让多态边界可控:编译器知道所有子类型,配合 record 可做穷尽性模式匹配(如 switch 表达式,编译器能检查所有分支是否覆盖),比"开放继承 + 运行时判断"更安全。Kotlin 的 sealed interface/class 与 Scala 3 的 sealed trait 概念类似,都用于限定类型层次并支持穷尽匹配。封闭类减少了"意外的子类"导致的契约破坏,也增强了 LSP 的可控性。

sealed class 是语言层面对"多态边界"的约束。它把"子类集合"从无限开放收敛为有限集合,配合模式匹配让编译器帮助验证穷尽性与安全性,是函数式编程与面向对象结合的产物。

#
★★★

6. OCP 的「测试开放」维度中如何用契约测试(Contract Test)守护扩展点,使新增实现不破坏既有行为,并把契约测试纳入 CI 门禁?

如何用契约测试(Contract Test)守护 OCP 的扩展点,使新增实现不破坏既有行为,并纳入 CI 门禁?

  • 契约测试的概念与作用
  • 用契约测试守护扩展点
  • 纳入 CI 门禁

契约测试(Contract Test)为"扩展点"定义了必须满足的契约,作为所有实现必须通过的守门层。做法是:为接口定义一套契约测试(抽象基类或测试接口),断言接口的通用行为(前置/后置条件、边界、异常),所有实现类都继承这套契约测试并运行。这样新增实现时,只要契约测试通过,就保证不会破坏既有行为;既有调用方依赖的接口契约始终被守护。将这组契约测试纳入 CI 门禁(如作为必跑测试套件、失败即阻断合并),任何破坏契约的提交都会被拦截,从而让"对扩展开放、对修改关闭"可被机器验证。

契约测试把 OCP 从"口头原则"变成"可验证的工程实践"。它本质是 LSP 的测试化:用一套测试固化基类契约,强制所有子类遵守。契约测试 + CI 门禁 = 扩展点既开放又安全,新增实现不会偷偷破坏既有行为。

#
★★

7. 协变(covariance)与逆变(contravariance)在函数参数的 LSP 边界

协变与逆变如何在函数参数上体现 LSP 边界?

  • 协变、逆变概念
  • 参数类型应逆变、返回类型应协变
  • 与 LSP 的关系

在类型系统里,协变(covariance)指泛型方向与继承方向一致(子类型可替换父类型的位置),逆变(contravariance)相反。对于函数来说,LSP 要求:参数类型应逆变(更宽泛)——子类方法参数若比父类更宽,则能接受父类调用方传入的所有合法参数;返回类型应协变(更具体)——子类返回更具体的类型不会破坏父类契约。如果参数逆变了(子类参数收窄),调用方按父类参数传入的值会不被接受,违反 LSP。Java 的泛型默认不变,但可用 ? extends(协变)和 ? super(逆变)通配符控制。

这是 LSP 在类型系统层面的精确表述:参数逆变、返回协变。直觉上"子类方法能接受更广的输入、返回更具体的输出",才保证替换安全。理解协变逆变对编写泛型 API 和设计继承层次非常重要。

#
★★

8. Override 的可见性限制(Java 不能缩窄、C# 同样不允许)

说明 Java 与 C# 在覆写方法可见性上的限制(Java 不能缩窄、C# 同样不允许)?

  • Java 覆写不能缩窄可见性
  • C# 覆写可改变可见性
  • 两种设计对 LSP 的影响

Java 规定覆写方法不能缩窄可见性:父类方法是 public,子类覆写必须也是 public,否则编译错误。这是为了维护 LSP——调用方通过父类 public 方法调用,若子类把可见性收窄为 private,替换后调用失败。C# 与 Java 类似,覆写(override)时同样不允许改变访问级别(编译器报 CS0507),可见性必须与父类一致;只有用 new 隐藏(而非覆写)基类成员时才可重新声明可见性。两者的共同点都是在语言层面保护"父类契约可被替换"。

"不能缩窄可见性"是 LSP 在语言层面的强制。Java 与 C# 都要求覆写保持父类的访问级别(C# 中改变访问级别会报 CS0507),保证子类至少保持父类的可访问性,避免替换后因可见性变化导致调用失败。理解这一约束有助于跨语言设计继承层次。

#
★★

9. 返回类型协变(return type covariance)的 LSP 安全

为什么返回类型协变(return type covariance)是 LSP 安全的?

  • 返回类型协变概念
  • 为什么是安全的
  • 子类返回更具体的类型

返回类型协变指子类覆写方法时可以返回比父类更具体的类型(子类型)。Java 从 1.5 起支持返回类型协变。这是 LSP 安全的,因为调用方通过父类引用接收返回值,只期望它是父类声明的返回类型;子类返回更具体的子类型,必然也是父类型,因此调用方的一切使用依旧成立,还额外获得了更多信息。即"返回更具体"只会加强契约,不会破坏契约。这与参数逆变配合,构成"子类参数更宽、返回更具体"的 LSP 安全模式。

返回类型协变安全的内在原因是"子类型替代父类型没有破坏性"——返回子类型是父类型的强化。它是编译器允许的、符合 LSP 的自然扩展。相比之下,参数若协变则危险(Java 数组协变就带来运行期风险)。

#
★★

10. OCP 的实现中多态、策略与扩展点?

OCP 的实现方式有哪些?多态、策略模式和扩展点如何配合?

  • 多态作为 OCP 基础
  • 策略模式封装变化
  • 扩展点(extension point)设计

OCP 的实现依赖于多态与抽象:通过接口/抽象类定义"扩展点",用不同的实现类(多态)承载变化,新增行为时新增实现类而非修改既有代码。策略模式(Strategy)是 OCP 的典型落地——把算法族封装成策略对象,通过依赖注入在运行时选择,新增策略即可扩展。扩展点(extension point)是设计出来的"将来可能变化的位置",如回调接口、插件、SPI、模板方法中的钩子。三者配合:多态提供机制,策略提供模式,扩展点提供"预留的变化位置",共同实现"对扩展开放、对修改关闭"。

OCP 的核心是"识别变化点并抽象之"。多态 + 策略 + 扩展点构成一套完整方案:接口定义契约开扩展点,策略封装变化,容器做运行时选择。但扩展点要适度,避免投机式预设。

#
★★

11. LSP 与「集合协变」的工程陷阱中 Java 数组协变与泛型不变性(List 不是 List)如何导致运行期错误,设计上应如何规避?

说明 Java 数组协变与泛型不变性(List 不是 List)如何导致运行期错误,以及如何规避?

  • Java 数组协变与运行期数组存储检查
  • 泛型不变性为何是安全的
  • 用通配符规避

Java 数组是协变的:Dog[] 可以赋值给 Animal[],这是语言早期设计。但数组在运行期会做存储检查(ArrayStoreException),如果 Animal[] a = new Dog[1]; a[0] = new Cat(); 会在运行期抛异常,把编译期错误拖到运行期。而泛型是不变的:List<Dog> 不是 List<Animal>,编译期就拒绝把 Dog 列表当作 Animal 列表用,从而避免运行期错误。要安全地让泛型协变,用通配符 List<? extends Animal>(只读,不能写入)或 List<? super Dog>(逆变,只写),编译器强制类型安全。设计上应优先用泛型 + 通配符,避免数组协变。

数组协变牺牲了编译期安全换取便利,是历史包袱;泛型不变性保证了类型安全。规避方式是:用 List<? extends T> 表达只读协变、List<? super T> 表达只写逆变,让编译器在编译期拦截类型错误。这是 LSP 在集合层面的工程实践。

#

12. 异常抛出协变(throws covariance)的争议

讨论异常抛出协变(throws covariance)的争议?

  • Java 的 throws 协变规则
  • 子类能否抛出更宽/更窄异常
  • 争议点

Java 允许子类覆写方法时抛出"更窄"的受检异常(throws covariance):父类方法声明抛出 Exception,子类可只抛出 IOException,因为 IOException 是 Exception 的子类,调用方按父类契约处理的异常范围仍能满足。这是 LSP 安全的(子类抛出更少、更具体的异常,只会加强契约)。争议在于:抛异常的行为本身是方法契约的一部分,改变异常类型本质上改变了接口语义,可能让调用方对异常的处理逻辑失效;而且非受检异常不受 throws 约束,运行时可能抛出契约外异常。因此有观点认为"异常类型不应作为继承契约的一部分",靠运行时异常和文档约定更稳妥。

异常协变是对 LSP 的柔化:Java 允许收窄受检异常。争议核心是"异常是否属于契约"——若属于,则任何异常类型变化都破坏契约;若不属于,则 throws 协变只是便利。实践中更关注"异常语义"而非"异常类型"。

#

13. LSP 的违反信号中子类抛出父类不支持的行为?

子类抛出父类不支持的行为(如 UnsupportedOperationException)为什么是 LSP 违反信号?

  • 空实现/抛异常的信号
  • 违反 LSP 的判断
  • 重构方式

当子类对父类定义的某个方法抛出异常(如 UnsupportedOperationException)、返回 null、或空操作时,说明子类不支持父类承诺的行为,是强烈的 LSP 违反信号。调用方按父类契约调用该方法,替换成子类后行为不可预期(抛异常、静默失败)。这通常意味着"is-a"关系不成立——不应继承,而应拆开接口(ISP)或改用组合。例如"只读集合继承可变集合"、一个类实现接口却对某些方法抛异常,都是 LSP 违规。

父类的方法就是子类必须兑现的契约。子类抛异常/空实现是在"撕毁契约"。修复方向是重新审视继承关系:如果子类不该支持某方法,说明该方法的父类接口选错了,应拆接口让子类只实现自己有意义的接口。

#

14. LSP 与继承设计中契约式设计(pre/post condition)?

契约式设计(Design by Contract)如何指导继承设计以符合 LSP?

  • 契约式设计的三个要素
  • 用契约约束继承
  • 继承安全的保证

契约式设计(Design by Contract)用前置条件、后置条件和不变量描述方法/对象的行为契约。在继承设计中,遵循 LSP 就要求子类遵守这些契约:前置条件只放宽不缩窄、后置条件只加强不削弱、不变量不破坏。在设计继承层次时,先明确父类方法的契约(输入范围、输出保证、异常约定),再让子类覆写时"只加强不削弱"。这样调用方才能安全地以父类引用使用任何子类。契约式设计等同于把 LSP 从"设计原则"落地为"可检查的约束"。

预/后条件与不变量是 LSP 的精确化。继承安全 = 子类不破坏父类契约。设计时先写契约(或用断言/文档固化),再考虑子类覆写是否满足"只加强不削弱"。这是判断继承是否合理的最可靠方法。

#

15. OCP 的权衡中何时扩展点过度设计?

何时扩展点会变成过度设计?

  • 投机式抽象(speculative generality)
  • 扩展点的成本
  • 判断时机(YAGNI)

扩展点变成过度设计的典型是"投机式抽象"(speculative generality):为"将来可能的变化"预造接口、抽象类、插件,但实际这段代码可能永远不会变。扩展点的成本是:增加抽象层、增加理解成本、增加测试复杂度、限制实现自由度。判断依据是 YAGNI:只有当"当前需求"真正出现了多个变体,或"变化频率"客观存在时,才值得引入扩展点。参考 Rule of Three——至少出现两三次切实需求后再抽象,避免为想象中的未来买单。

OCP 与 YAGNI 需要平衡。过度设计扩展点会损害简单性,违背 KISS。正确做法是"在真实变化出现时抽象,而非提前抽象"。一个很好的判断:如果只有一个实现,接口就是多余的;直到出现第二个真实需求,再考虑抽象。

#

16. LSP 与接口隔离的配合?

LSP 与接口隔离原则(ISP)如何配合?

  • LSP 与 ISP 的互补关系
  • 用细粒度接口避免"不支持的方法"
  • 避免子类空实现

LSP 与 ISP 常常配合使用:LSP 要求子类完整兑现父类/接口契约,而 ISP 要求接口尽量细粒度、客户端不依赖不用的方法。两者配合可以避免"子类对接口某方法抛异常"的 LSP 违规——如果接口过于庞大(胖接口),子类实现时可能被迫对某些方法空实现或抛异常,这违反 LSP;通过 ISP 把胖接口拆成多个细粒度接口,子类只实现与自己相关的接口,就不会出现"不支持的方法"。所以 ISP 是帮助达到 LSP 的手段。

胖接口 → 子类被迫空实现/抛异常 → 违反 LSP。ISP 拆接口 → 子类只实现有意义的接口 → 兑现契约 → 符合 LSP。二者是"接口粒度"与"接口契约兑现"的互补约束。

#

17. OCP 与继承/组合的权衡?

在实现 OCP 时,继承与组合如何权衡?

  • 继承实现 OCP 的适用场景
  • 组合的优势
  • 具体权衡

继承实现 OCP 适用于"层级稳定、变化点明确"的场景,如模板方法复用算法骨架、框架强制要求继承基类。但继承耦合强、易产生脆弱基类、层级深。组合实现 OCP 更灵活:通过接口 + 策略/委托注入,运行时替换行为,新增实现不改既有代码,耦合更松散。趋势是"组合优先"。权衡时:若变化是"行为变体"且需要运行时切换,用组合;若变化是"算法骨架的固定步骤"且层级稳定,用继承(模板方法)。多数情况下组合 + 接口抽象更符合 OCP 且更易测试。

继承与组合非二选一,而是按场景:行为变体走组合,稳定骨架走继承。组合的松散耦合让它更能实现"对修改关闭",而继承若滥用会让基类修改波及所有子类。现代工程实践倾向"组合优于继承"。