LSP:本质、Barbara Liskov替换原则在开发中的应用

作者: IT Sectr 发布日期: 2026-05-12 阅读时间: 9 分钟

LSP(Liskov Substitution Principle)——SOLID的第三个原则,它定义了面向对象编程中正确继承的条件。该原则由Barbara Liskov于1987年提出,形式化表述为:如果S是T的子类型,那么T的对象可以被S的对象替换,而不会改变程序的属性。正如Robert Martin的著作Clean Architecture(2017)中指出的,替换原则要求子类不能削弱基类的契约。

要点

  • LSP——Liskov替换原则,SOLID的第三个原则,关于正确继承
  • 子类必须保持基类的契约——前置条件和后置条件
  • 违反LSP表现为正方形-矩形问题和抛出异常
  • 组合通常比继承更有利于遵守LSP
  • 契约式设计(Design by Contract)——检查LSP的正式方法

什么是LSP(Liskov Substitution Principle)?

LSP(Liskov Substitution Principle)——由Barbara Liskov在1987年OOPSLA会议上提出的替换原则。形式化定义:设q(x)是类型T的x对象的一个可证明属性。那么q(y)对于类型S的y对象也应该是可证明的,其中S是T的子类型。简单来说:子类的对象应该表现如此,使得与基类一起工作的代码能够继续与子类正确工作。

在实践中,LSP意味着子类不能违反基类的契约。契约包括前置条件(调用方法所需的条件)、后置条件(调用后保证的条件)和不变量(对象整个生命周期中保持的条件)。子类可能加强前置条件或削弱后置条件——这就是违反LSP。

违反LSP的经典例子——从矩形继承的正方形。setWidth方法在矩形中设置宽度,在正方形中同时设置宽度和高度。期望矩形行为(更改一条边不影响另一条边)的客户端得到了意外结果。正方形不是矩形的正确子类型

LSP的正式条件

LSP为正确继承设定了三个条件:子类的前置条件不能强于基类的前置条件(子类不要求更多),子类的后置条件不能弱于基类的后置条件(子类不保证更少),基类的不变量必须在子类中保持。这些条件被称为Bertrand Meyer的契约式设计规则

如果至少有一个条件被违反——使用多态的代码可能会出现故障。编译器不检查语义契约,只检查语法契约。因此LSP是架构纪律的问题,而不是静态类型的问题。

Liskov替换原则如何工作

LSP机制基于类型的行为了相容性。如果类S继承自类T,客户端代码应该能够在任何期望T的地方使用S,而不改变其行为。这不仅包括方法签名,还包括它们的语义。

LSP不禁止子类添加新行为。禁止违反为基类编写的代码的期望。如果基类保证save方法不抛出异常,子类就不应该抛出异常。如果基类返回非负值,子类就不应该返回负值。

在实际项目中,LSP最常通过在子类方法中添加条件逻辑来违反:「如果条件——抛出异常」,「如果条件——返回null」。每一个这样的「意外」都会破坏多态性,迫使客户端代码在调用之前检查对象的类型——这与面向对象设计的理念相悖。

在移动项目中,典型的LSP违反发生在创建基础ViewModel时。如果BaseViewModel保证onCleared方法释放所有资源,而子类将其重写为空——任何依赖通过多态调用onCleared释放资源的代码都将无法正确工作。LSP要求子类要么调用super.onCleared(),要么自己执行相同的工作。通过LifecycleObserver的组合——在生命周期管理中排除LSP违反的替代方案。

代码中违反LSP的迹象

违反LSP的主要指标包括:在调用方法前通过instanceof或is检查对象类型,方法的空实现(存根),抛出NotImplementedError或UnsupportedOperationException异常,返回null代替值。这些模式中的每一个都表明子类不是正确的子类型。

另一个常见迹象——继承的目的是重用代码,而不是为了建模「是」(is-a)关系。Bird类有fly()方法。Penguin类继承自Bird并将fly()重写为空或抛出异常。这是违反LSP:企鹅不是鸟的正确子类型

在移动开发中,LSP在创建带有存根方法的基础ViewHolder、Fragment或ViewController时被违反。如果子类不使用基类的一半方法——继承选择不正确。组合或接口隔离更正确地解决了问题。

LSP测试

检查LSP的简单测试:为基类编写一个单元测试,检查其契约(返回值、异常、副作用)。对每个子类运行此测试。如果测试失败——LSP被违反。这种方法称为「通过基类契约进行测试」。

在Android项目中,这样的测试对ViewModel和Repository很有用。如果BaseViewModel保证在错误之前有Loading状态,而子类在没有Loading的情况下抛出错误——测试将在CI阶段捕获LSP违反。

移动开发中的LSP示例

我们来看一个Android示例,关于ClickListener的处理。当基础实现保证某些内容而子类违反时,LSP违反就发生了。

kotlin
// 有保证的基类:onClick将被调用
open class BaseClickListener {
    open fun onClick(view: View) {
        // 基本处理
    }
}

// 违反LSP:子类添加了抛出异常的条件
class RestrictedClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (!isLoggedIn) {
            throw IllegalStateException("Not logged in")
        }
        super.onClick(view)
    }
}

// 正确的解决方案:契约未被违反
class ConditionalClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (isLoggedIn) {
            super.onClick(view)
        }
    }
}

使用DataSource协议的iOS示例演示了通过返回nil代替数据来违反LSP:

swift
// 带契约的协议:返回数据或错误
protocol DataProvider {
    func fetchData() async throws -> [String]
}

// 违反LSP:返回nil而没有错误
class SilentFailProvider: DataProvider {
    func fetchData() async throws -> [String] {
        return [] // 用空数组代替错误
    }
}

// 正确遵守LSP
class NetworkProvider: DataProvider {
    func fetchData() async throws -> [String] {
        throw NetworkError.timeout
    }
}

实用规则:如果子类不能履行基类的契约——它就不应该是子类。替代方案——提取一个具有最小契约的接口,并在每个类型中以自己的方式实现它。

LSP与继承:何时选择组合

组合在「是」(is-a)关系不明确或有条件的情况下优于继承。经典例子:Manager是Employee吗?是。但Square是正确的Rectangle吗?LSP说「不」。如果您对继承的正确性有疑问——选择组合。

在移动开发中,组合通常通过依赖注入使用:类不是从基类继承行为,而是通过构造函数获取它。ViewModel不继承自Repository,而是将其作为依赖项接受。这从定义上排除了LSP违反——没有继承,就没有契约违反。

继承应该被组合替代的迹象:子类不使用基类的部分方法,子类用空存根重写方法,客户端代码通过instanceof检查对象类型。在这些情况下,继承被错误地选择,LSP被违反。

通过接口的解决方案

接口无需继承就能解决LSP问题:每个类型只实现它需要的方法。代替具有fly()方法的通用Bird基类(Penguin不会飞)——Flyable接口,只有会飞的鸟才实现它。Penguin实现Bird而没有fly方法——LSP没有被违反。

在Android架构中,这种方法通过隔离接口UseCase来应用:代替一个拥有getAll、getById、save、delete方法的大UseCase——单独的GetItemsUseCase、SaveItemUseCase接口。客户端只依赖于所需的接口,任何实现该接口的类从LSP的角度来看都是正确的。

常见问题

LSP与普通继承有何不同?

继承——语言机制,LSP——正确使用该机制的规则。继承保证签名的兼容性(语法),LSP要求行为的兼容性(语义)。没有LSP的继承会产生在运行时崩溃的多态性。

子类中的null是否总是违反LSP?

如果基类保证非null返回——是。如果契约允许null(可选值)——否。LSP不禁止null,它禁止削弱契约。研究基类的文档并检查子类的契约是否兼容。

LSP如何应用于Swift中的协议?

对于协议,LSP的应用方式与类相同。协议的实现必须遵守语义契约:如果协议将方法定义为non-throwing,实现就不应该抛出错误。Swift在编译级别不检查这一点——责任在于开发者。

使用sealed class时LSP会被违反吗?

Kotlin中的Sealed class——特殊情况,因为层次结构是封闭的并且对编译器已知。LSP在较小程度上适用于sealed class,因为所有子类型都在when表达式中显式列出。sealed子类的错误将是局部的,而不是隐藏的多态错误。

如何在项目中测试LSP的遵守情况?

为基类编写一个参数化测试,该测试对其所有子类运行。测试检查关键的行文契约:返回值、异常、状态。如果测试在其中一个子类上失败——LSP被违反。在CI中,这样的测试防止多态代码的回归。

总结

  • LSP(Liskov Substitution Principle)——替换原则,SOLID中的第三个,关于继承的语义兼容性
  • 子类必须保持基类的契约:前置条件、后置条件和不变量
  • instanceof检查和方法的空重写——违反LSP的主要迹象
  • 组合和接口在继承不正确的地方解决LSP问题
  • 正方形和矩形问题——子类型不兼容的经典例子
  • 契约测试为基类编写,对所有子类运行,在CI中检测LSP违反
  • Kotlin中的Sealed class通过封闭的、编译器已知的层次结构降低了LSP风险

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读