1.3 里氏替换原则 (Liskov Substitution Principle, LSP)

166 阅读8分钟

1.3 里氏替换原则 (Liskov Substitution Principle, LSP)

核心定义

里氏替换原则(Liskov Substitution Principle, LSP)由Barbara Liskov在1987年提出,其核心思想是:所有引用基类(父类)的地方必须能够透明地使用其子类的对象,而程序行为不发生改变。 简单来说,子类必须能够替换掉它们的父类,并且替换后程序的逻辑行为保持一致。

更形式化的定义是:如果对于每个类型S的对象o1,都有类型T的对象o2,使得对于所有用T定义的操作P,当o1取代o2时,P的行为没有变化,那么S是T的子类型。

深层解读与目的

LSP是保证继承体系正确性的重要原则,它强调了子类与父类之间行为上的一致性,是实现开闭原则的重要方式之一。

  • 行为一致性:子类在继承父类时,不应该改变父类已有的行为和契约。子类可以增加新的行为,但不能修改或废弃父类的行为,尤其是那些被客户端依赖的行为。
  • “IS-A”关系的真正含义:LSP强调继承表达的是一种“IS-A”的行为关系,而不仅仅是结构上的相似。子类“是”一个父类,意味着子类对象在任何父类对象出现的地方都应该能正常工作。
  • 防止继承滥用:如果子类不能完全替换父类,那么这种继承关系可能是不恰当的,可能会导致程序在运行时出现意外错误。

遵循LSP的好处:

  1. 增强代码的健壮性:客户端代码可以安全地使用父类引用指向子类对象,而不必担心子类会破坏原有的逻辑。
  2. 提高代码的可复用性:父类和子类都可以被更广泛地复用。
  3. 促进多态的正确使用:LSP是多态能够正确发挥作用的基础。
  4. 使继承体系更合理:避免了不符合“IS-A”行为关系的继承。

生活化类比

  1. 鸟类与企鹅:如果有一个Bird类,它有一个fly()方法。Penguin类继承自Bird。但企鹅不会飞。如果Penguinfly()方法实现为抛出异常或什么都不做,那么当客户端代码期望一只“鸟”能飞时,传入一个企鹅对象就会出问题。这违反了LSP。正确的做法可能是将fly()方法从Bird基类移到更具体的FlyingBird子类中,或者让Bird有一个更通用的move()方法。
  2. 长方形与正方形:经典的例子。如果Square类继承自Rectangle类。RectanglesetWidth()setHeight()方法。如果Square为了保持四边相等,在setWidth()时同时修改了height(反之亦然),那么当客户端代码期望设置一个长方形的宽度而不改变其高度时,传入一个正方形对象就会导致行为不一致。这也违反了LSP。
  3. 遥控器与电视/空调:一个通用遥控器(父类接口)设计用来控制多种设备。如果一个电视遥控器(子类)实现了这个通用接口,那么用户在使用通用遥控器控制电视时,其行为(如开关、换台、调音量)应该与预期一致。如果电视遥控器在“换台”按钮上实现了“切换输入源”的功能,就违反了LSP。

实际应用场景

  • 集合类的继承:Java中的Properties类继承自HashtableHashtable允许存储任何类型的键和值,而Properties主要用于存储字符串类型的键值对。虽然Properties可以存储非字符串,但其设计意图和常用API(如getProperty, setProperty)都是针对字符串的。如果一个方法期望接收一个Hashtable并存入非字符串键值,而传入的是一个Properties实例,后续使用Properties的特定方法时可能会出现问题,这在某种程度上是LSP的一个警示。
  • 覆盖父类方法时的约束
    • 子类方法的前置条件(preconditions)必须与父类方法的前置条件相同或更宽松。
    • 子类方法的后置条件(postconditions)必须与父类方法的后置条件相同或更严格。
    • 子类方法抛出的异常类型必须与父类方法抛出的异常类型相同或是其子类型。
    • 子类方法的参数类型可以是父类方法参数类型的父类型(逆变)。
    • 子类方法的返回类型可以是父类方法返回类型的子类型(协变)。
  • 避免在子类中重写父类方法并使其抛出新的、父类未声明的受检异常。

作用与价值

作用维度具体表现
保证继承的正确性确保子类在行为上真正是父类的一种特殊化,而不是破坏父类的契约。
增强代码健壮性客户端可以放心地使用父类引用操作子类对象,减少运行时错误。
提高可维护性代码行为更可预测,修改和调试更容易。
促进多态的有效性是多态能够按预期工作的基石。
指导继承设计帮助开发者判断何时应该使用继承,何时应该考虑组合或其他关系。

代码示例 (Kotlin)

场景:一个计算面积的程序。

违反LSP的例子 (经典的正方形继承长方形问题):

open class Rectangle_LSP_Bad {
    open var width: Double = 0.0
    open var height: Double = 0.0

    open fun setDimensions(w: Double, h: Double) {
        this.width = w
        this.height = h
    }

    open fun getArea(): Double {
        return width * height
    }
}

class Square_LSP_Bad : Rectangle_LSP_Bad() {
    // 为了保持正方形的特性,重写setter
    override var width: Double
        get() = super.width
        set(value) {
            super.width = value
            super.height = value // 改变了height的行为
        }

    override var height: Double
        get() = super.height
        set(value) {
            super.height = value
            super.width = value // 改变了width的行为
        }
    
    // 或者重写setDimensions
    // override fun setDimensions(w: Double, h: Double) {
    //     if (w != h) throw IllegalArgumentException("Square sides must be equal")
    //     super.setDimensions(w, h)
    // }
}

fun printArea_LSP_Bad(rectangle: Rectangle_LSP_Bad) {
    rectangle.setDimensions(5.0, 4.0) // 期望设置宽度为5,高度为4
    // 对于Rectangle实例,面积是20
    // 对于Square实例,如果setDimensions(5,4)时,width和height都变成4(或5),或者抛异常,行为与Rectangle不一致
    println("Area: ${rectangle.getArea()}") 
    // 如果传入Square, width=4, height=4, area=16 (假设以最后一个参数为准)
    // 或者 width=5, height=5, area=25 (假设以第一个参数为准)
    // 这与客户端对Rectangle行为的期望(width=5, height=4, area=20)不符
}

// 使用
// val rect = Rectangle_LSP_Bad()
// val square = Square_LSP_Bad()
// printArea_LSP_Bad(rect)   // 输出: Area: 20.0 (符合预期)
// printArea_LSP_Bad(square) // 输出可能不是20.0,或者抛异常,违反LSP

在这个例子中,Square_LSP_Bad继承了Rectangle_LSP_Bad。客户端代码printArea_LSP_Bad期望通过setDimensions(5.0, 4.0)设置一个宽度为5、高度为4的矩形。但如果传入的是Square_LSP_Bad实例,由于其内部逻辑要保持宽高相等,setDimensions的行为会与父类不一致(例如,它可能将宽高都设为4,或都设为5,或抛出异常),导致getArea()的结果也与预期不符。

遵循LSP的例子 (通常通过不使用继承,或更抽象的基类来解决):

一种常见的解决方案是不让Square继承Rectangle,或者它们都继承自一个更抽象的Shape类。

interface Shape_LSP_Good {
    fun getArea(): Double
}

class Rectangle_LSP_Good(private var width: Double, private var height: Double) : Shape_LSP_Good {
    fun setWidth(w: Double) { this.width = w }
    fun setHeight(h: Double) { this.height = h }
    override fun getArea(): Double = width * height
}

class Square_LSP_Good(private var side: Double) : Shape_LSP_Good {
    fun setSide(s: Double) { this.side = s }
    override fun getArea(): Double = side * side
}

fun printShapeArea_LSP_Good(shape: Shape_LSP_Good) {
    // 这里不能假设shape有setWidth或setHeight,因为Shape_LSP_Good接口没有定义它们
    // 客户端只能依赖Shape_LSP_Good接口中定义的方法
    println("Shape Area: ${shape.getArea()}")
}

// 使用
// val rectGood = Rectangle_LSP_Good(5.0, 4.0)
// val squareGood = Square_LSP_Good(5.0)
// printShapeArea_LSP_Good(rectGood)     // 输出: Shape Area: 20.0
// printShapeArea_LSP_Good(squareGood)   // 输出: Shape Area: 25.0
// 这种情况下,客户端与Shape_LSP_Good交互,行为是一致的(都能获取面积)

如果确实需要一个可以设置宽高的共同基类,并且要包含正方形,那么设计需要更小心,确保子类行为不违反父类契约。通常,如果子类对父类方法的行为施加了比父类更强的约束(如正方形要求宽高相等),就很容易违反LSP。

优缺点

优点缺点
保证了继承体系的正确性和健壮性。可能限制继承的灵活性:过于严格地遵循LSP,有时可能会使得一些看似合理的继承关系变得不可能,迫使开发者寻找其他设计方案。
提高了代码的可复用性和可维护性。判断行为一致性可能复杂:在复杂场景下,准确判断子类行为是否完全符合父类契约可能比较困难。
是实现开闭原则的重要保证。可能导致继承层次更深或更复杂:为了满足LSP,可能需要引入更多的抽象层或中间类。
避免了因不当继承引入的运行时错误。

最佳实践与应用指南

  1. 子类必须实现父类的所有抽象方法,但不得重写(覆盖)父类的非抽象(具体)方法,除非是为了扩展而非改变原有行为。 (这条比较严格,实际中重写具体方法很常见,关键是行为要一致)。
  2. 子类可以增加自己特有的方法。
  3. 当子类的方法重写父类的方法时,方法的前置条件(即方法的输入/参数)要比父类方法的输入参数更宽松或相同。
  4. 当子类的方法重写父类的方法时,方法的后置条件(即方法的输出/返回值)要比父类方法的输出更严格或相同。
  5. 子类不应该抛出比父类方法声明的异常更多或更宽泛的异常类型。
  6. 仔细思考“IS-A”关系:在决定使用继承之前,问自己子类是否真的“是”一个父类,并且在所有行为上都能表现得像父类。
  7. 优先使用组合和委托而非继承:如果LSP难以满足,通常表明继承可能不是最佳选择,可以考虑使用对象组合来复用功能。
  8. 进行充分的单元测试:确保子类在替换父类后,原有的测试用例依然能够通过。
  9. 避免在子类中覆盖父类方法并使其实现为空或抛出UnsupportedOperationException,除非父类本身就明确声明了该方法是可选的(例如通过接口的默认方法或抽象类的空实现)。

里氏替换原则是面向对象设计中一个深刻且关键的原则。它要求我们从行为的角度去审视继承关系,确保子类能够真正地替代父类,从而构建出更加稳健和可靠的软件系统。


添加公众号第一时间了解最新内容。

欢迎入群交流QQ:276097690