定义
里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。
里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。
里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
简单来说
一个软件实体如果使用的是一个父类,那么一定适用于其子类,而且它察觉不出父类对象和子类对象的区别。也就是说,软件里面,把父类都替换成它的子类,程序的行为没有变化。
如果软件开发中使用的是其子类的话,由于子类含有一些基类所没有的特征,所以基类不能代替其基类。相反,子类一定含有基类的公开的方法,那么子类一定可以代替基类,也就是使用基类的地方都可以使用子类。
如何规范地遵从里氏替换原则:
- 子类必须完全实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 子类可以实现自己特有的方法
- 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
- 子类的实例可以替代任何父类的实例,但反之不成立
Python案例1
以下是一个正确履行里氏代换原则的案例
from abc import ABC, abstractmethod
class Pizza(ABC):
# Pizza继承了ABC(Abstract Class),成为了抽象类,任何继承该类的子类都应该实现对应的方法
def __init__(self):
pass
@abstractmethod # 抽象类修饰,子类必须要实现这个方法
def get_name(self) -> str:
# 这里就简单点,任何披萨都应该有一个名字,将其返回
pass
class CheesePizza(Pizza):
def __init__(self):
super().__init__()
self.name = '芝士披萨'
print(f'做出了个 {self.name}')
def get_name(self) -> str:
return self.name
def eat_cheese(self):
# 给芝士披萨定义一个特种方法好了,就是只吃披萨料(笑)
print(f'你吃掉了{self.name}的芝士,但是没动面皮...')
class SeafoodPizza(Pizza):
def __init__(self):
super().__init__()
self.name = '海鲜披萨'
print(f'做出了个 {self.name}')
def get_name(self) -> str:
return self.name
def eat_pizza(pizza: Pizza):
# eat_pizza接受一个Pizza类,由于接受的参数是基类Pizza,因此所有的披萨都可以用这个函数
print(f'吃掉了{pizza.get_name()}')
def eat_cheese_pizza(pizza: CheesePizza):
# 这个函数则只供芝士披萨独有,对应原则中的 “子类的实例可以替代任何父类的实例,但反之不成立”
pizza.eat_cheese()
if __name__ == '__main__':
eat_pizza(CheesePizza())
eat_pizza(SeafoodPizza())
eat_cheese_pizza(CheesePizza())
# 这行会报错,因为并没有实现海鲜披萨的吃芝士方法,同理也不能换成基类披萨
# eat_cheese_pizza(SeafoodPizza())
Python案例2
这好像是一个很经典的案例,我看到很多文章都拿这个当例子
首先,需要确定的是在数学定理中,正方形是特殊的长方形
长方形的定义是有一个角是直角的平行四边形,因此正方形完全符合长方形的定义。
那么按照道理来讲,可以实现一个矩形作为基类,正方形继承长方形即可,我们将其代入代码中实现一下。
class Rectangle:
# 一个长方形类,初始化后会设定自身长和宽为None
def __init__(self):
self.width = None
self.height = None
def set_width(self, x): # 设定宽
self.width = x
def set_height(self, x): # 设定高
self.height = x
class Square(Rectangle):
def __init__(self):
super().__init__()
def set_width(self, x):
# 由于是正方形,因此设定任何一条边的时候,另外一条边必须同时被设定为相等值
self.width, self.height = x, x
def set_height(self, x): # 设定高
self.width, self.height = x, x
上述代码看上去没什么问题,符合数学逻辑,但是如果遇到这么一个函数呢:
def area(r: Rectangle):
r.set_height(4)
r.set_width(5)
assert r.width * r.height == 20
显然,将Square类放入这个对矩形完全成立的函数中进行运算,会报断言错误。这违背了LSP中的:“子类的实例可以替代任何父类的实例”。
归根结底是因为Square类再在写父类Rectangle的set_width
等方法时,并没有遵守 “方法的后置条件(即方法的返回值)要比父类更严格。” 。
子类的set_width
方法会同时影响实例的两个属性,这两个属性都是父类所有。父类对其进行了管控,仅使用set_height
与set_width
对其中之一进行修改,子类并没有遵守这些限制,导致出错。
这有些反直觉,因为单看Square 和 Rectangle,我们发现它们是自洽的并且是有效的。而且现实中的逻辑也承认,一个正方形可以是一个长方形。
网上对此有非常合理的解释:
一个 Square 对象绝对不是一个 Rectangle 对象。为什么呢?因为一个 Square 对象的行为与一个 Rectangle 对象的行为是不一致的。从行为的角度来看,一个 Square 不是一个 Rectangle !而软件设计真正关注的就是行为(behavior)。
对于area函数来说,设计者会先有这么一个观点:Rectangle 的 Width 和 Height 彼此之间的变化是无依赖关系的,这是基类提供并且说明了的。
更深入的挖掘
Bertrand Meyer 在 1988 年阐述了 LSP 原则与契约式设计之间的关系。使用契约式设计,类中的方法需要声明前置条件和后置条件。前置条件为真,则方法才能被执行。而在方法调用完成之前,方法本身将确保后置条件也成立。
所以我们回过头来看Rectangle 的set_width
函数,它有一层隐含的后置条件。
- self.width = x ( 实例的属性将会被修改为你设定的)
- self.height= old_value (实例的高属性不会变)
换句话说,当通过基类接口使用对象时,客户类仅知道基类的前置条件和后置条件。因此,衍生类对象不能期待客户类服从强于基类中的前置条件。也就是说,它们必须接受任何基类可以接受的条件。
而且,衍生类必须符合基类中所定义的后置条件。也就是说,它们的行为和输出不能违背任何已经与基类建立的限制。基类的客户类绝不能对衍生类的输出产生任何疑惑。
参考
里氏替换原则(Liskov Substitution Principle) - sangmado - 博客园
文章评论