Python 工匠:寫好面向對象代碼的原則(中)

前言

這是 「Python 工匠」系列的第 13 篇文章。[查看系列全部文章]html

上一篇文章 裏,我用一個虛擬小項目做爲例子,講解了「SOLID」設計原則中的前兩位成員:S*(單一職責原則)與 O(開放-關閉原則)*。java

在這篇文章中,我將繼續介紹 SOLID 原則的第三位成員:L(里氏替換原則)python

里氏替換原則與繼承

在開始前,我以爲有必要先提一下 繼承(Inheritance)。由於和前面兩條很是抽象的原則不一樣,「里氏替換原則」是一條很是具體的,和類繼承有關的原則。git

在 OOP 世界裏,繼承算是一個很是特殊的存在,它有點像一把無堅不摧的雙刃劍,強大且危險。合理使用繼承,能夠大大減小類與類之間的重複代碼,讓程序事半功倍,而不當的繼承關係,則會讓類與類之間創建起錯誤的強耦合,帶來大片難以理解和維護的代碼。github

正是由於這樣,對繼承的態度也能夠大體分爲兩類。大多數人認爲,繼承和多態、封裝等特性同樣,屬於面向對象編程的幾大核心特徵之一。而同時有另外一部分人以爲,繼承帶來的 壞處遠比好處多。甚至在 Go 這門相對年輕的編程語言裏,設計者直接去掉了繼承,提倡徹底使用組合來替代。編程

從我我的的編程經驗來看,繼承確實極易被誤用。要設計出合理的繼承關係,是一件須要深思熟慮的困難事兒。不過幸運的是,在這方面,"里氏替換原則"(後簡稱 L 原則) 爲咱們提供了很是好的指導意義。bash

讓咱們來看看它的內容。session

L:里氏替換原則

同前面的 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 原則的例子。

一個違反 L 原則的樣例

假設咱們在爲一個 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 異常,讓管理員對象沒法被停用。

子類繼承父類,而後重寫父類的少許行爲,這看上去正是類繼承的典型用法。但不幸的是,這段代碼違反了「里氏替換原則」。具體是怎麼回事呢?讓咱們來看看。

不當繼承關係如何違反 L 原則

如今,假設咱們須要寫一個新函數,它能夠同時接受多個用戶對象做爲參數,批量將它們停用。代碼以下:

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 類型的用戶不容許被停用。可是,**誰能保證將來不會出現其餘不能被停用的用戶類型呢?**好比:

  • 公司員工不容許被停用
  • VIP 用戶不容許被停用
  • 等等(... ...)

而當這些新需求在將來不斷出現時,咱們就須要重複的修改 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 原則

除了子類方法返回不一致的類型之外,子類對父類方法參數的變動也容易致使違反 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]:
        # ... ...
複製代碼

若是父類 Userlist_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 裏的繼承場景服務。當你設計類繼承關係,尤爲是編寫子類代碼時,請常常性的問本身這個問題:「若是我把項目裏全部使用父類的地方換成這個子類,程序是否還能正常運行?」

若是答案是否認的,那麼你就應該考慮調整一下如今的類設計了。調整方式有不少種,有時候你得把大類拆分爲更小的類,有時候你得調換類之間的繼承關係,有時候你得爲父類添加新的方法和屬性,就像文章裏的第一個場景同樣。只要開動腦筋,總會找到合適的辦法。

讓咱們最後再總結一下吧:

  • **「L:里氏替換原則」**認爲子類應該能夠任意替換父類被使用
  • 在類的使用方增長具體的類型判斷(isinstance),一般不是最佳解決方案
  • 違反里氏替換原則,一般也會致使違反「開放-關閉」原則
  • 考慮什麼是類的核心特徵,而後爲父類增長新的方法和屬性能夠幫到你
  • 子類方法應該和父類同名方法返回同一類型,或者返回支持更多操做的子類型也行
  • 子類的方法參數應該和父類同名方法徹底一致,或者更爲寬鬆

看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。

<<<上一篇【12.寫好面向對象代碼的原則(上)】

附錄

系列其餘文章:

相關文章
相關標籤/搜索