這是 「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 告訴我吧。
系列其餘文章: