LSP(Liskov Substitution Principle)——SOLID的第三个原则,它定义了面向对象编程中正确继承的条件。该原则由Barbara Liskov于1987年提出,形式化表述为:如果S是T的子类型,那么T的对象可以被S的对象替换,而不会改变程序的属性。正如Robert Martin的著作Clean Architecture(2017)中指出的,替换原则要求子类不能削弱基类的契约。
要点
LSP(Liskov Substitution Principle)——由Barbara Liskov在1987年OOPSLA会议上提出的替换原则。形式化定义:设q(x)是类型T的x对象的一个可证明属性。那么q(y)对于类型S的y对象也应该是可证明的,其中S是T的子类型。简单来说:子类的对象应该表现如此,使得与基类一起工作的代码能够继续与子类正确工作。
在实践中,LSP意味着子类不能违反基类的契约。契约包括前置条件(调用方法所需的条件)、后置条件(调用后保证的条件)和不变量(对象整个生命周期中保持的条件)。子类可能加强前置条件或削弱后置条件——这就是违反LSP。
违反LSP的经典例子——从矩形继承的正方形。setWidth方法在矩形中设置宽度,在正方形中同时设置宽度和高度。期望矩形行为(更改一条边不影响另一条边)的客户端得到了意外结果。正方形不是矩形的正确子类型。
LSP为正确继承设定了三个条件:子类的前置条件不能强于基类的前置条件(子类不要求更多),子类的后置条件不能弱于基类的后置条件(子类不保证更少),基类的不变量必须在子类中保持。这些条件被称为Bertrand Meyer的契约式设计规则。
如果至少有一个条件被违反——使用多态的代码可能会出现故障。编译器不检查语义契约,只检查语法契约。因此LSP是架构纪律的问题,而不是静态类型的问题。
LSP机制基于类型的行为了相容性。如果类S继承自类T,客户端代码应该能够在任何期望T的地方使用S,而不改变其行为。这不仅包括方法签名,还包括它们的语义。
LSP不禁止子类添加新行为。禁止违反为基类编写的代码的期望。如果基类保证save方法不抛出异常,子类就不应该抛出异常。如果基类返回非负值,子类就不应该返回负值。
在实际项目中,LSP最常通过在子类方法中添加条件逻辑来违反:「如果条件——抛出异常」,「如果条件——返回null」。每一个这样的「意外」都会破坏多态性,迫使客户端代码在调用之前检查对象的类型——这与面向对象设计的理念相悖。
在移动项目中,典型的LSP违反发生在创建基础ViewModel时。如果BaseViewModel保证onCleared方法释放所有资源,而子类将其重写为空——任何依赖通过多态调用onCleared释放资源的代码都将无法正确工作。LSP要求子类要么调用super.onCleared(),要么自己执行相同的工作。通过LifecycleObserver的组合——在生命周期管理中排除LSP违反的替代方案。
违反LSP的主要指标包括:在调用方法前通过instanceof或is检查对象类型,方法的空实现(存根),抛出NotImplementedError或UnsupportedOperationException异常,返回null代替值。这些模式中的每一个都表明子类不是正确的子类型。
另一个常见迹象——继承的目的是重用代码,而不是为了建模「是」(is-a)关系。Bird类有fly()方法。Penguin类继承自Bird并将fly()重写为空或抛出异常。这是违反LSP:企鹅不是鸟的正确子类型。
在移动开发中,LSP在创建带有存根方法的基础ViewHolder、Fragment或ViewController时被违反。如果子类不使用基类的一半方法——继承选择不正确。组合或接口隔离更正确地解决了问题。
检查LSP的简单测试:为基类编写一个单元测试,检查其契约(返回值、异常、副作用)。对每个子类运行此测试。如果测试失败——LSP被违反。这种方法称为「通过基类契约进行测试」。
在Android项目中,这样的测试对ViewModel和Repository很有用。如果BaseViewModel保证在错误之前有Loading状态,而子类在没有Loading的情况下抛出错误——测试将在CI阶段捕获LSP违反。
我们来看一个Android示例,关于ClickListener的处理。当基础实现保证某些内容而子类违反时,LSP违反就发生了。
// 有保证的基类: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:
// 带契约的协议:返回数据或错误
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
}
}
实用规则:如果子类不能履行基类的契约——它就不应该是子类。替代方案——提取一个具有最小契约的接口,并在每个类型中以自己的方式实现它。
组合在「是」(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的继承会产生在运行时崩溃的多态性。
如果基类保证非null返回——是。如果契约允许null(可选值)——否。LSP不禁止null,它禁止削弱契约。研究基类的文档并检查子类的契约是否兼容。
对于协议,LSP的应用方式与类相同。协议的实现必须遵守语义契约:如果协议将方法定义为non-throwing,实现就不应该抛出错误。Swift在编译级别不检查这一点——责任在于开发者。
Kotlin中的Sealed class——特殊情况,因为层次结构是封闭的并且对编译器已知。LSP在较小程度上适用于sealed class,因为所有子类型都在when表达式中显式列出。sealed子类的错误将是局部的,而不是隐藏的多态错误。
为基类编写一个参数化测试,该测试对其所有子类运行。测试检查关键的行文契约:返回值、异常、状态。如果测试在其中一个子类上失败——LSP被违反。在CI中,这样的测试防止多态代码的回归。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。