这是 “Python 工匠”系列的第 13 篇文章。[查看系列全部文章]html
在 上一篇文章 里,我用一个虚拟小项目做为例子,讲解了“SOLID”设计原则中的前两位成员:S*(单一职责原则)与 O(开放-关闭原则)*。java
在这篇文章中,我将继续介绍 SOLID 原则的第三位成员:L(里氏替换原则)。python
在开始前,我以为有必要先提一下 继承(Inheritance)。由于和前面两条很是抽象的原则不一样,“里氏替换原则”是一条很是具体的,和类继承有关的原则。git
在 OOP 世界里,继承算是一个很是特殊的存在,它有点像一把无坚不摧的双刃剑,强大且危险。合理使用继承,能够大大减小类与类之间的重复代码,让程序事半功倍,而不当的继承关系,则会让类与类之间创建起错误的强耦合,带来大片难以理解和维护的代码。github
正是由于这样,对继承的态度也能够大体分为两类。大多数人认为,继承和多态、封装等特性同样,属于面向对象编程的几大核心特征之一。而同时有另外一部分人以为,继承带来的 坏处远比好处多。甚至在 Go 这门相对年轻的编程语言里,设计者直接去掉了继承,提倡彻底使用组合来替代。编程
从我我的的编程经验来看,继承确实极易被误用。要设计出合理的继承关系,是一件须要深思熟虑的困难事儿。不过幸运的是,在这方面,"里氏替换原则"(后简称 L 原则) 为咱们提供了很是好的指导意义。bash
让咱们来看看它的内容。session
同前面的 S 与 O 两个原则的命名方式不一样,里氏替换原则*(Liskov Substitution Principle)*是直接用它的发明者 Barbara Liskov 命名的,原文看起来像一个复杂的数学公式:编程语言
Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.函数
若是把它比较通俗的翻译过来,大概是这样:当你使用继承时,子类(派生类)对象应该能够在程序中替代父类(基类)对象使用,而不破坏程序本来的功能。
光说有点难理解,让咱们用代码来看看一个在 Python 中违反 Liskov 原则的例子。
假设咱们在为一个 Web 站点设计用户模型。这个站点的用户分为两类:普通用户和站点管理员。因此在代码里,咱们定义了两个用户类:普通用户类 User
和管理员类 Admin
。
class User(Model):
"""普通用户模型类 """
def __init__(self, username: str):
self.username = username
def deactivate(self):
"""停用当前用户 """
self.is_active = True
self.save()
class Admin(User):
"""管理员用户类 """
def deactivate(self):
# 管理员用户不容许被停用
raise RuntimeError('admin can not be deactivated!')
复制代码
由于普通用户的绝大多数操做在管理员上都适用,因此咱们把 Admin
类设计成了继承自 User
类的子类。不过在“停用”操做方面,管理员和普通用户之间又有所区别: 普通用户能够被停用,但管理员不行。
因而在 Admin
类里,咱们重写了 deactivate
方法,使其抛出一个 RuntimeError
异常,让管理员对象没法被停用。
子类继承父类,而后重写父类的少许行为,这看上去正是类继承的典型用法。但不幸的是,这段代码违反了“里氏替换原则”。具体是怎么回事呢?让咱们来看看。
如今,假设咱们须要写一个新函数,它能够同时接受多个用户对象做为参数,批量将它们停用。代码以下:
def deactivate_users(users: Iterable[User]):
"""批量停用多个用户 """
for user in users:
user.deactivate()
复制代码
很明显,上面的代码是有问题的。由于 deactivate_users
函数在参数注解里写到,它接受一切 可被迭代的 User 对象,那么管理员 Admin
是否是 User
对象?固然是,由于它是继承自 User
类的子类。
可是,若是你真的把 [User("foo"), Admin("bar_admin")]
这样的用户列表传到 deactivate_users
函数里,程序立马就会抛出 RuntimeError
异常,由于管理员对象 Admin("bar_admin")
压根不支持停用操做。
在 deactivate_users
函数看来,子类 Admin
没法随意替换父类 User
使用,因此如今的代码是不符合 L 原则的。
要修复上面的函数,最直接的办法就是在函数内部增长一个额外的类型判断:
def deactivate_users(users: Iterable[User]):
"""批量停用多个用户 """
for user in users:
# 管理员用户不支持 deactivate 方法,跳过
if isinstance(user, Admin):
logger.info(f'skip deactivating admin user {user.username}')
continue
user.deactivate()
复制代码
在修改版的 deactivate_users
函数里,若是它在循环时刚好发现某个用户是 Admin
类,就跳过此次操做。这样它就能正确处理那些混合了管理员的用户列表了。
可是,这样修改的缺点是显而易见的。由于虽然到目前为止,只有 Admin
类型的用户不容许被停用。可是,**谁能保证将来不会出现其余不能被停用的用户类型呢?**好比:
而当这些新需求在将来不断出现时,咱们就须要重复的修改 deactivate_users
函数,来不断适配这些没法被停用的新用户类型。
def deactivate_users(users: Iterable[User]):
for user in users:
# 在类型判断语句不断追加新用户类型
if isinstance(user, (Admin, VIPUser, Staff)):
... ...
复制代码
如今,让咱们再回忆一下前面的 SOLID 第二原则:“开放-关闭原则”。这条原则认为:好的代码应该对扩展开发,对修改关闭。而上面的函数很明显不符合这条原则。
到这里你会发现,**SOLID 里的每条原则并不是彻底独立的个体,它们之间其实互有联系。**好比,在这个例子里,咱们先是违反了“里氏替换原则”,而后咱们使用了错误的修复方式:增长类型判断。以后发现,这样的代码一样也没法符合“开放-关闭原则”。
既然为函数增长类型判断没法让代码变得更好,那咱们就应该从别的方面入手。
“里氏替换原则”提到,*子类(Admin)应该能够随意替换它的父类(User),而不破坏程序(deactivate_users)*自己的功能。**咱们试过直接修改类的使用者来遵照这条原则,可是失败了。因此此次,让咱们试着从源头上解决问题:从新设计类之间的继承关系。
具体点来讲,子类不能只是简单经过抛出异常的方式对某个类方法进行“退化”。若是 “对象不能支持某种操做” 自己就是这个类型的 核心特征 之一,那咱们在进行父类设计时,就应该把这个 核心特征 设计进去。
拿用户类型举例,“用户可能没法被停用” 就是 User
类的核心特征之一,因此在设计父类时,咱们就应该把它做为类方法*(或属性)*写进去。
让咱们看看调整后的代码:
class User(Model):
"""普通用户模型类 """
def __init__(self, username: str):
self.username = username
def allow_deactivate(self) -> bool:
"""是否容许被停用 """
return True
def deactivate(self):
"""将当前用户停用 """
self.is_active = True
self.save()
class Admin(User):
"""管理员用户类 """
def allow_deactivate(self) -> bool:
# 管理员用户不容许被停用
return False
def deactivate_users(users: Iterable[User]):
"""批量停用多个用户 """
for user in users:
if not user.allow_deactivate():
logger.info(f'user {user.username} does not allow deactivating, skip.')
continue
user.deactivate()
复制代码
在新代码里,咱们在父类中增长了 allow_deactivate
方法,由它来决定当前的用户类型是否容许被停用。而在 deactivate_users
函数中,也再也不须要经过脆弱的类型判断,来断定某类用户是否能够被停用。咱们只须要调用 user.allow_deactivate()
方法,程序便能自动跳过那些不支持停用操做的用户对象。
在这样的设计中,User
类的子类 Admin
作到了能够彻底替代父类使用,而不会破坏程序 deactivate_users
的功能。
因此咱们能够说,修改后的类继承结构是符合里氏替换原则的。
除了上面的例子外,还有一种常见的违反里氏替换原则的状况。让咱们看看下面这段代码:
class User(Model):
"""普通用户模型类 """
def __init__(self, username: str):
self.username = username
def list_related_posts(self) -> List[int]:
"""查询全部与之相关的帖子 ID """
return [post.id for post in session.query(Post).filter(username=self.username)]
class Admin(User):
"""管理员用户类 """
def list_related_posts(self) -> Iterable[int]:
# 管理员与全部的帖子都有关,为了节约内存,使用生成器返回帖子 ID
for post in session.query(Post).all():
yield post.id
复制代码
在这段代码里,我给用户类增长了一个新方法:list_related_posts
,调用它能够拿到全部和当前用户有关的帖子 ID。对于普通用户,方法返回的是本身发布过的全部帖子,而管理员则是站点里的全部帖子。
如今,假设我须要写一个函数,来获取和用户有关的全部帖子标题:
def list_user_post_titles(user: User) -> Iterable[str]:
"""获取与用户有关的全部帖子标题 """
for post_id in user.list_related_posts():
yield session.query(Post).get(post_id).title
复制代码
对于上面的 list_user_post_titles
函数来讲,不管传入的 user
参数是 User
仍是 Admin
类型,它都能正常工做。由于,虽然普通用户和管理员类型的 list_related_posts
方法返回结果略有区别,但它们都是**“可迭代的帖子 ID”**,因此函数里的循环在碰到不一样的用户类型时都能正常进行。
既然如此,那上面的代码符合“里氏替换原则”吗?答案是否认的。由于虽然在当前 list_user_post_titles
函数的视角看来,子类 Admin
能够任意替代父类 User
使用,但这只是特殊用例下的一个巧合,并无通用性。请看看下面这个场景。
有一位新成员最近加入了项目开发,她须要实现一个新函数来获取与用户有关的全部帖子数量。当她读到 User
类代码时,发现 list_related_posts
方法返回一个包含全部帖子 ID 的列表,因而她就此写下了统计帖子数量的代码:
def get_user_posts_count(user: User) -> int:
"""获取与用户相关的帖子个数 """
return len(user.list_related_posts())
复制代码
在大多数状况下,当 user
参数只是普通用户类时,上面的函数是能够正常执行的。
不过有一天,有其余人偶然使用了一个管理员用户调用了上面的函数,立刻就碰到了异常:TypeError: object of type 'generator' has no len()
。这时由于 Admin
虽然是 User
类型的子类,但它的 list_related_posts
方法返回倒是一个可迭代的生成器,并非列表对象。而生成器是不支持 len()
操做的。
因此,对于新的 get_user_posts_count
函数来讲,如今的用户类继承结构仍然违反了 L 原则。
在咱们的代码里,User
类和 Admin
类的 list_related_posts
返回的是两类不一样的结果:
User 类
:返回一个包含帖子 ID 的列表对象Admin 类
:返回一个产生帖子 ID 的生成器很明显,两者之间存在共通点:它们都是可被迭代的 int 对象(Iterable[int]
)。这也是为何对于第一个获取用户帖子标题的函数来讲,两个用户类能够互相交换使用的缘由。
不过,针对某个特定函数,子类能够替代父类使用,并不等同于代码就符合“里氏替换原则”。要符合 L 原则,咱们必定得让子类方法和父类返回同一类型的结果,支持一样的操做。或者更进一步,返回支持更多种操做的子类型结果也是能够接受的。
而如今的设计没作到这点,如今的子类返回值所支持的操做,只是父类的一个子集。Admin
子类的 list_related_posts
方法所返回的生成器,只支持父类 User
返回列表里的“迭代操做”,而不支持其余行为(好比 len()
)。因此咱们没办法随意的用子类替换父类,天然也就没法符合里氏替换原则。
**注意:**此处说“生成器”支持的操做是“列表”的子集其实不是特别严谨,由于生成器还支持
.send()
等其余操做。不过在这里,咱们能够只关注它的可迭代特性。
为了让代码符合“里氏替换原则”。咱们须要让子类和父类的同名方法,返回同一类结果。
class User(Model):
"""普通用户模型类 """
def __init__(self, username: str):
self.username = username
def list_related_posts(self) -> Iterable[int]:
"""查询全部与之相关的帖子 ID """
for post in session.query(Post).filter(username=self.username):
yield post.id
def get_related_posts_count(self) -> int:
"""获取与用户有关的帖子总数 """
value = 0
for _ in self.list_related_posts():
value += 1
return value
class Admin(User):
"""管理员用户类 """
def list_related_posts(self) -> Iterable[int]:
# 管理员与全部的帖子都有关,为了节约内存,使用生成器返回
for post in session.query(Post).all():
yield post.id
复制代码
而对于“获取与用户有关的帖子总数”这个需求,咱们能够直接在父类 User
中定义一个 get_related_posts_count
方法,遍历帖子 ID,统计数量后返回。
除了子类方法返回不一致的类型之外,子类对父类方法参数的变动也容易致使违反 L 原则。拿下面这段代码为例:
class User(Model):
def list_related_posts(self, include_hidden: bool = False) -> List[int]:
# ... ...
class Admin(User):
def list_related_posts(self) -> List[int]:
# ... ...
复制代码
若是父类 User
的 list_related_posts
方法接收一个可选的 include_hidden
参数,那它的子类就不该该去掉这个参数。不然当某个函数调用依赖了 include_hidden
参数,但用户对象倒是子类 Admin
类型时,程序就会报错。
为了让代码符合 L 原则,咱们必须作到 让子类的方法参数签名和父类彻底一致,或者更宽松。这样才能作到在任何使用参数调用父类方法的地方,随意用子类替换。
好比下面这样就是符合 L 原则的:
class User(Model):
def list_related_posts(self, include_hidden: bool = False) -> List[int]:
# ... ...
class Admin(User):
def list_related_posts(self, include_hidden: bool = False, active_only = True) -> List[int]:
# 子类能够为方法增长额外的可选参数:active_only
# ... ...
复制代码
在这篇文章里,我经过两个具体场景,向你描述了 “SOLID” 设计原则中的第三位成员:里氏替换原则。
“里氏替换原则”是一个很是具体的原则,它专门为 OOP 里的继承场景服务。当你设计类继承关系,尤为是编写子类代码时,请常常性的问本身这个问题:“若是我把项目里全部使用父类的地方换成这个子类,程序是否还能正常运行?”
若是答案是否认的,那么你就应该考虑调整一下如今的类设计了。调整方式有不少种,有时候你得把大类拆分为更小的类,有时候你得调换类之间的继承关系,有时候你得为父类添加新的方法和属性,就像文章里的第一个场景同样。只要开动脑筋,总会找到合适的办法。
让咱们最后再总结一下吧:
看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。
系列其余文章: