Skip to content

Latest commit

 

History

History
253 lines (180 loc) · 9.27 KB

里氏替换原则.md

File metadata and controls

253 lines (180 loc) · 9.27 KB

这是SOLID五大原则的第三篇学习笔记: 里氏替换原则 Liskov Substitution Principle。

SOLID原则每个字母对应一种原则。

  • S是Single Responsibility Principle的缩写,即单一职责
  • O是Open-Closed Principle的缩写,即开闭原则
  • L是Liskov Substitution Principle的缩写,即里氏替换原则。
  • I是Interface Segregation Principle的缩写,即接口隔离原则。
  • D是Dependency Inversion Principle的缩写,即依赖反转原则。

如果你对SOLID原则还不了解,可以在单一职责中查看其相关介绍。

1. 里氏替换原则

Barbara Liskov对里氏替换原则的定义是:

"If for each object o1 of type S there is an object o2 of type T, such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T."

有S类型的对象o1,T类型的对象o2,S是T的子类,程序P接受输入T。当使用S替换T后,程序行为不会发生变化。

如果不看其具体定义,可能更容易理解,特别是第一次学习里氏替换原则。下面是一种更容易理解的描述:

程序引用了一个基类,也必须能够使用该基类的派生类,而其行为不能有变化,程序也不应知道派生类的存在。

上一篇文章开闭原则介绍了软件实体应对扩展开放,对修改关闭,这样代码更容易维护、重用。此外,还应使用抽象,例如继承、接口。

这篇文章将聚焦继承,以展示遵守LSP的优势,也会演示违背LSP原则带来的问题。当违背了LSP原则的时候,一般也会违背OCP,这些原则是关联的。

2. 示例一

现在有一个类 RectangleRectangle的子类为 Square。其继承关系很简单,用来演示如何会违背LSP,以及违背LSP带来的问题。

class Rectangle {
    var width: Int
    var height: Int
  
    init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
  
    func area() -> Int {
        return width * height
    }
}

class Square: Rectangle {
    override var width: Int {
        didSet {
            super.height = width
        }
    }
  
    override var height: Int {
        didSet {
            super.width = height
        }
    }
}

Rectangle类有两个属性 widthheight,一个计算面积的方法 area()。还创建了一个子类 SquareSquare重写了属性set方法,以便设置一个边长的时候另一个边也同样的长度,即满足正方形四边等长。

使用上述类的场景如下:

let square = Square(width: 10, height: 10)
let rectangle: Rectangle = square

rectangle.height = 7
rectangle.width = 5

print(rectangle.area()) 

首先定义一个边长10的正方形,然后利用多态创建对该对象的引用,但类型为其父类Rectangle。此时,有一个类型为Square的对象,但会被当作Rectangle类型处理。

设置rectangle对象高为7、宽为5,因为我们不知道其真实类型为Square,这里预期面积为7*5 = 35。但运行得到面积为25。这里就违背了里氏替换原则,子类会有不同于父类的行为表现。

2.1 违背里氏替换原则带来的问题

上述示例可能有些难以理解,你或许会认为square对象的类型是已知的,宽高应是相等的。下面将进一步说明为什么在实践中这会带来问题。

当我们想要对事物进行抽象时,通常会利用多态性(polymorphism)。例如,有一个购物app,app内列出了用户可以购买的商品。在实现这样的app时,我们会利用抽象对商品进行分组。

多态可以简化API解析数据,抽象可以让app脱离服务器发送的响应类型。这样,服务端可以更改数据,而app仍然可以正常响应。即:我们拿到item后,改变item属性,调用item方法时,不需要知道item具体类型,可以直接调用。

回到前面的示例,我们不知道rectangle具体类型,其可能是RectangleSquare,或者其它类型。我们只知道它是rectangle,但其行为却不是rectangle类型。

另一个破坏里氏替换原则会来带问题的场景是开发、使用framework。当使用framework时,我们无需、也不想了解其私有结构。当使用其公开结构时,应有一致的行为表现,而不依赖对其私有结构的了解。

2.2 遵守LSP

上述代码修改为遵守LSP,如下所示:

protocol Geometrics {
    func area() -> Int
}

public class Rectangle {
    public var width: Int
    public var height: Int
    
    public init(width: Int, height: Int) {
        self.width = width
        self.height = height
    }
}

extension Rectangle: Geometrics {
    public func area() -> Int {
        return width * height
    }
}

public class Square {
    public var edge: Int
    
    public init(edge: Int) {
        self.edge = edge
    }
}

extension Square: Geometrics {
    public func area() -> Int {
        return edge * edge
    }
}

通常情况下,优先使用组合(composition)而非继承(inheritance),可以解决违背里氏替换原则问题。创建一个协议,让不同的实体有相同的行为,不会调用到不应使用的属性或方法。

let rectangle: Geometrics = Rectangle(width: 10, height: 10)
print(rectangle.area())

let rectangle2: Geometrics = Square(edge: 5)
print(rectangle2.area())

3. 示例二 LSP和OCP

有一个Shape父类,Shape类是抽象类,两个子类SquareShapeCircleShape

public class Shape {
    public init() {
        
    }
    
    public func doSomething() {
        // do something relate to shape that is irrelevant to this example, actually
    }
}

public class SquareShape: Shape {
    public func drawSquare() {
        // draw the square
    }
}

public class CircleShape: Shape {
    public func drawCircle() {
        // draw the circle
    }
}

另外,还有一个draw(shape:)方法,参数为Shape类型。在该方法中,尝试将行参转变为子类:

func draw(shape: Shape) {
    if let square = shape as? SquareShape {
        square.drawSquare()
    } else if let circle = shape as? CircleShape {
        circle.drawCircle()
    }
}

let squareShape: Shape = SquareShape()
draw(shape: squareShape)

上述示例也违反了里氏替换原则,因为子类与父类行为不同。如果入参为子类,则会绘制图形;如果入参为父类,则什么也不执行。

上述示例违反LSP的同时,也违反了OCP,其对于增加类型没有关闭。例如,想要增加Triangle图形,就需要增加if语句,以便能够进行绘制。

3.1 违反LSP带来的问题

这个示例中违反LSP带来的问题与上一示例类似。作为开发者,我们不想知道其它模块实现部分,模块之间只应关注接口。

遵守LSP的第一步就是私有子类与基类没有任何不同的公共行为。方法、类、framework和API都应遵守此规则。上述两个示例都违反了这一点。

3.2 遵守LSP

创建一个协议,协议声明一个公共方法draw(),让SquareShapeCircleShape类遵守协议,这样子类与父类就不会有不同的行为,进而遵守了里氏替换原则。

public protocol Shape {
    func draw()
}

public class SquareShape: Shape {
    public init() {
        
    }
    
    public func draw() {
        // draw the square
    }
}

public class CircleShape: Shape {
    public func draw() {
        // draw the circle
    }
}

func draw(shape: Shape) {
    shape.draw()
}

这样也可以让draw(shape:)方法对于修改关闭。当增加了新图案类型,其必须遵守Shape协议,进而实现draw()方法。

public class TriangleShape: Shape {
    public func draw() {
        // draw the triangle
    }
}

总结

里氏替换原则要求引用基类的程序,必须也能够使用其子类,且行为不会发生变化,程序也无需知道子类的存在。其会带来以下收益:

  • 使用framework、API,或其它软件实体时,无需知道其私有结构。
  • 避免子类有不同行为带来的bug。
  • 代码更易维护。

里氏替换原则与开闭原则相关。当打破了LSP时,一般也违背了OCP。因此,上一篇文章开闭原则提到的问题LSP也都会存在。为了避免违反里氏替换原则,抽象时更推荐组合,而非继承。虽然组合不能解决所有问题,但可以解决大部分问题。

Demo名称:LiskovSubstitutionPrinciple
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/LiskovSubstitutionPrinciple

参考资料:

  1. Liskov Substitution Principle in Swift

  2. The Liskov Substitution Principle Explained