这是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原则还不了解,可以在单一职责中查看其相关介绍。
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,这些原则是关联的。
现在有一个类 Rectangle
,Rectangle
的子类为 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
类有两个属性 width
和 height
,一个计算面积的方法 area()
。还创建了一个子类 Square
,Square
重写了属性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。这里就违背了里氏替换原则,子类会有不同于父类的行为表现。
上述示例可能有些难以理解,你或许会认为square
对象的类型是已知的,宽高应是相等的。下面将进一步说明为什么在实践中这会带来问题。
当我们想要对事物进行抽象时,通常会利用多态性(polymorphism)。例如,有一个购物app,app内列出了用户可以购买的商品。在实现这样的app时,我们会利用抽象对商品进行分组。
多态可以简化API解析数据,抽象可以让app脱离服务器发送的响应类型。这样,服务端可以更改数据,而app仍然可以正常响应。即:我们拿到item后,改变item属性,调用item方法时,不需要知道item具体类型,可以直接调用。
回到前面的示例,我们不知道rectangle
具体类型,其可能是Rectangle
、Square
,或者其它类型。我们只知道它是rectangle
,但其行为却不是rectangle
类型。
另一个破坏里氏替换原则会来带问题的场景是开发、使用framework。当使用framework时,我们无需、也不想了解其私有结构。当使用其公开结构时,应有一致的行为表现,而不依赖对其私有结构的了解。
上述代码修改为遵守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())
有一个Shape
父类,Shape
类是抽象类,两个子类SquareShape
、CircleShape
。
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语句,以便能够进行绘制。
这个示例中违反LSP带来的问题与上一示例类似。作为开发者,我们不想知道其它模块实现部分,模块之间只应关注接口。
遵守LSP的第一步就是私有子类与基类没有任何不同的公共行为。方法、类、framework和API都应遵守此规则。上述两个示例都违反了这一点。
创建一个协议,协议声明一个公共方法draw()
,让SquareShape
和CircleShape
类遵守协议,这样子类与父类就不会有不同的行为,进而遵守了里氏替换原则。
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
参考资料: