1. 程式人生 > 其它 >(轉)面向物件設計原則(上)

(轉)面向物件設計原則(上)

原文:https://www.zlovezl.cn/book/ch10_solid_p1.html

面向物件作為一種流行的程式設計模式,功能強大,但同時也很難被掌握。一位剛接觸面向物件的初學者,從能寫一些簡單的類,到能獨自完成優秀的面向物件設計,整個過程往往要花費數月,乃至數年的時間。

為了讓面向物件程式設計變得更容易,許多前輩們將自己的寶貴經驗,整理成了大量圖書和資料。而這些書中最為有名的一本,當屬 1994 年出版的《設計模式:可複用面向物件軟體的基礎》。

在《設計模式》中,4 位作者從各自的經驗出發,提煉總結了共 23 種經典設計模式。這些設計模式涵蓋面向物件程式設計的各個環節——比如物件建立、行為包裝等,具備極大的參考價值和實用性。

但奇怪的是,雖然《設計模式》中的 23 種設計模式非常經典,我們卻很少聽到 Python 開發者們討論這些模式,也很少在專案程式碼裡見到它們的身影。為什麼會這樣呢?這和 Python 語言的動態特性有關。

《設計模式》中的大部分設計模式,都是作者用靜態程式語言,在一個有著諸多限制的面向物件環境裡創造出來的。但 Python 不同,Python 是一門動態到骨子裡的程式語言,它有著一等函式物件、“鴨子型別”、可自定義的資料模型等各種靈活特性。因此,我們極少會用 Python 來一比一還原經典設計模式。取而代之的是,我們幾乎總是會為每種設計模式找到更適合 Python 的表現形式。

比如,在 9.3.4 節就有一個與“單例模式”有關的例子。在示例程式碼裡,我先是用__new__

方法實現了經典的單例設計模式。但隨後,一個模組級全域性物件用更少的程式碼,滿足了同樣的需求。

# 1:單例模式

class AppConfig:

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            inst = super().__new__(cls)
            cls._instance = inst
        return cls._instance

# 2:全域性物件

class AppConfig:
    ...

_config = AppConfig()

既然設計模式在 Python 裡,無法像在其他語言裡一樣,帶給我們太多實用價值。那我們還能如何學習面向物件設計?當我們編寫面向物件程式碼時,怎樣判斷不同方案的優劣?怎樣打磨出更好的設計?

SOLID 設計原則可以回答上面的問題。

在面向物件領域,除了 23 條經典的設計模式外,還有許多經典的設計原則。同具體的設計模式相比,原則通常更抽象、適用性更廣,更適合融入到 Python 程式設計中。而在所有的設計原則中,SOLID 最為有名。

SOLID 原則的雛形最早出現在 Robert C. Martin(Bob 大叔)2000 年發表的一篇文章中,在這篇名為 “Design Principles and Design Patterns” 的文章裡,Bob 大叔創造與整理了多條面向物件設計原則。在隨後出版的《敏捷軟體開發:原則、模式與實踐》一書中, Bob 大叔提取了這些原則的首字母,組成了單詞 SOLID 來幫助記憶。

SOLID 單詞裡的 5 個字母,分別代表 5 條不同設計原則。

  • S:Single responsibility principle(單一職責原則)

  • O:Open–closed principle(開放——關閉原則)

  • L:Liskov substitution principle(裡式替換原則)

  • I:Interface segregation principle(介面隔離原則)

  • D:Dependency inversion principle(依賴倒置原則)

在編寫面向物件程式碼時,遵循這些設計原則可以幫你避開常見的設計陷阱,讓你更容易寫出易於擴充套件的好程式碼。反之,如果你的程式碼違反了原則中某幾條,那麼你的設計可能有著相當大的改進空間。

接下來,我們將學習這 5 條設計原則的具體內容,我們會通過一些真實案例將原則實際應用到 Python 程式碼中。

由於 SOLID 原則內容較多,我將其拆分成了兩章。在本章,我們將學習這 5 條原則中的前兩條:

  • S:Single responsibility principle(單一職責原則)

  • O:Open–closed principle(開放——關閉原則)

讓我們開始吧!

10.1 型別註解基礎

為了讓程式碼更具說明性,更好地描述出每條 SOLID 原則的特點,本章及下一章的所有程式碼將會使用 Python 的型別註解特性。

在第 1 章中,我曾簡單介紹過 Python 的型別註解(type hint)功能。簡而言之,型別註解是一種給函式引數、返回值,以及任何變數增加型別描述的技術,規範的註解可以大大提升程式碼可讀性。

舉個例子,下面的程式碼沒有任何型別註解:

class Duck:
    """鴨子類

    :param color: 鴨子顏色
    """

    def __init__(self, color):
        self.color = color

    def quack(self):
        print(f"Hi, I'm a {self.color} duck!")


def create_random_ducks(number):
    """建立一批隨機顏色鴨子

    :param number: 需要建立的鴨子數量
    """
    ducks = []
    for _ in number:
        color = random.choice(['yellow', 'white', 'gray'])
        ducks.append(Duck(color=color))
    return ducks

下面是給程式碼添加了型別註解後的樣子:

from typing import List


class Duck:
    def __init__(self, color: str): 
        self.color = color

    def quack(self) -> None: 
        print(f"Hi, I'm a {self.color} duck!")


def create_random_ducks(number: int) -> List[Duck]: 
    ducks: List[Duck] = [] 
    for _ in number:
        color = random.choice(['yellow', 'white', 'gray']) 
        ducks.append(Duck(color=color))
    return ducks
給函式引數加上型別註解
通過給返回值加上型別註解
你可以用typing模組的特殊物件List來標註列表成員的具體型別,注意,這裡用的是[]符號,而不是()
宣告變數時,也可以為其加上型別註解
型別註解是可選的,非常自由,比如這裡的color變數就沒加型別註解

typing是型別註解用到的主要模組,除了List以外,該模組內還有許多與型別有關的特殊物件,舉例如下。

  • Dict:字典型別,例如Dict[str, int]代表鍵為字串,值對整型的字典。

  • Callable:可呼叫物件,例如Callable[[str, str], List[str]]表示接受兩個字串作為引數,返回字串列表的可呼叫物件。

  • TextIO:使用文字協議的類檔案型別,對應還有二進位制型別:BinaryIO

  • Any:代表任何型別。

預設情況下,你可以把 Python 裡的型別註解,當成一種增加程式碼可讀性的特殊註釋,因為它就像註釋一樣,只提升程式碼的說明性,不對程式的執行過程產生任何實際影響。

但是,如果引入靜態型別檢查工具,型別註解就不再僅僅是註解了。它在增加可讀性之餘,還能對程式正確性產生積極的影響。在的 13.1.5 節,我會介紹如何用 mypy 來做到這一點。

對型別註解的簡介就先到這裡,如果你想了解更多內容,可以檢視 Python 官方文件的“型別註解”部分,裡面的內容相當詳細。

10.2 SRP:單一職責原則

本章將通過一個具體案例,來說明 SOLID 原則的前兩條:SRP(單一職責原則)和 OCP(開放——關閉原則)。

10.2.1 案例:一個簡單的 Hacker News 爬蟲

Hacker News(後簡稱 HN)是一個知名的國外科技類資訊站點,在程式設計師圈子內很受歡迎。在 HN 的首頁上,你可以閱讀當前流行的文章,參與文章討論。同時,你也可以向首頁提交新的文章連結,系統會根據評分演算法對文章進行排序,最受關注的熱門文章會被排在最前面,HN首頁截圖如圖 10-1 所示。

圖 10-1 Hacker News 首頁截圖

我平時挺愛逛 HN 的,常會去上面找一些熱門文章看。但每次逛 HN,我都需要開啟瀏覽器,在收藏夾找到網站書籤,步驟還是挺繁瑣的——程式設計師嘛,都“懶”!

為了讓瀏覽 HN 變得更方便,我想要寫個程式,自動獲取 HN 首頁裡最熱門的條目標題和連結,把它們儲存到普通檔案裡。這樣我就能直接在命令列裡瀏覽熱門文章,豈不美哉?

作為 Python 程式設計師,寫個小指令碼自然不在話下。利用requestslxml等模組提供的強大功能,不到半小時,我就把程式寫好了。

程式碼樣例 10-1 Hacker News 新聞抓取指令碼 news_digester.py
import io
import sys
from typing import Iterable, TextIO

import requests
from lxml import etree


class Post:
    """HackerNew 上的條目

    :param title: 標題
    :param link: 連結
    :param points: 當前得分
    :param comments_cnt: 評論數
    """

    def __init__(self, title: str, link: str, points: str, comments_cnt: str):
        self.title = title
        self.link = link
        self.points = int(points)
        self.comments_cnt = int(comments_cnt)


class HNTopPostsSpider:
    """抓取 Hacker News Top 內容條目

    :param fp: 儲存抓取結果的目標檔案物件
    :param limit: 限制條目數,預設為 5
    """

    items_url = 'https://news.ycombinator.com/'
    file_title = 'Top news on HN'

    def __init__(self, fp: TextIO, limit: int = 5):
        self.fp = fp
        self.limit = limit

    def write_to_file(self):
        """以純文字格式將 Top 內容寫入檔案"""
        self.fp.write(f'# {self.file_title}\n\n')
        for i, post in enumerate(self.fetch(), 1): 
            self.fp.write(f'> TOP {i}: {post.title}\n')
            self.fp.write(f'> 分數:{post.points} 評論數:{post.comments_cnt}\n')
            self.fp.write(f'> 地址:{post.link}\n')
            self.fp.write('------\n')

    def fetch(self) -> Iterable[Post]:
        """從 HN 抓取 Top 內容

        :return: 可迭代的 Post 物件
        """
        resp = requests.get(self.items_url)

        # 使用 XPath 可以方便的從頁面解析出你需要的內容,以下均為頁面解析程式碼
        # 如果你對 xpath 不熟悉,可以忽略這些程式碼,直接跳到 yield Post() 部分
        html = etree.HTML(resp.text)
        items = html.xpath('//table[@class="itemlist"]/tr[@class="athing"]')
        for item in items[: self.limit]:
            node_title = item.xpath('./td[@class="title"]/a')[0]
            node_detail = item.getnext()
            points_text = node_detail.xpath('.//span[@class="score"]/text()')
            comments_text = node_detail.xpath('.//td/a[last()]/text()')[0]

            yield Post(
                title=node_title.text,
                link=node_title.get('href'),
                # 條目可能會沒有評分
                points=points_text[0].split()[0] if points_text else '0',
                comments_cnt=comments_text.split()[0],
            )


def main():
    # with open('/tmp/hn_top5.txt') as fp:
    #     crawler = HNTopPostsSpider(fp)
    #     crawler.write_to_file()

    # 因為 HNTopPostsSpider 接收任何 file-like 的物件,所以我們可以把 sys.stdout 傳進去
    # 實現往控制檯標準輸出列印的功能
    crawler = HNTopPostsSpider(sys.stdout)
    crawler.write_to_file()


if __name__ == '__main__':
    main()
enumerate()接收第二個引數,表示從這個數開始計數(預設為 0)

執行這個指令碼,我就能在命令列裡看到 HN 站點上的 Top5 條目:

$ python news_digester.py
# Top news on HN

> TOP 1: The auction that set off the race for AI supremacy
> 分數:72 評論數:10
> 地址:https://www.wired.com/story/secret-auction-race-ai-supremacy-google-microsoft-baidu/
------
> TOP 2: Introducing the Wikimedia Enterprise API
> 分數:47 評論數:12
> 地址:https://diff.wikimedia.org/2021/03/16/introducing-the-wikimedia-enterprise-api/
------
...

你可以明顯看出來,上面的程式碼是符合面向物件風格的。因為在程式碼裡,我定義瞭如下所示的兩個類:

  1. Post:代表一個 HN 內容條目,包含標題、連結等欄位,是一個典型的“資料類”,主要用來銜接程式的“資料抓取”與“檔案寫入”行為。

  2. HNTopPostsSpider:抓取 HN 內容的爬蟲類,包含抓取頁面、解析、寫入結果等行為,是完成主要工作的類。

雖然這個指令碼基於面向物件風格編寫(換句話說,也就是定義了幾個 class 而已),可以滿足我的需求。但從設計角度看,它卻違反了 SOLID 原則中的第一條:“Single responsibility principle”(單一職責原則,後簡稱 SRP),讓我們來看看這是為什麼吧。

單一職責原則是 SOLID 原則裡的第一條,該原則認為:一個類應該僅僅只有一個被修改的理由。換句話說,每個類都應該只承擔一種職責。

要理解 SRP 原則,最重要的是理解原則裡所說的“修改的理由”代表什麼。顯而易見,軟體本身是沒有生命的,修改的理由不會來自軟體自身。你的程式不會突然跳起來說“我覺得我執行起來有點慢,需要優化一下”這種話。

所有修改軟體的理由,都來自與軟體相關的人,人是導致程式被修改的“罪魁禍首”。

舉個例子,在上面的爬蟲腳本里,你可以輕易找到兩個需要修改HNTopPostsSpider類的理由。

  • 理由 1:HN 網站的程式設計師突然更新了頁面樣式,舊 xpath 解析演算法沒法正常解析新頁面,因此fetch()方法裡的解析邏輯需要被修改。

  • 理由 2:程式的使用者(也就是我)覺得純文字格式不好看,想要改成 Markdown 樣式,因此write_to_file()方法裡的輸出邏輯需要被修改。

從這兩條理由看來,HNTopPostsSpider明顯違反了 SRP 原則,它同時承擔了“抓取帖子列表” 和 “將帖子列表寫入檔案” 這兩種完全不同的職責。

10.2.2 違反 SRP 的壞處

假如某個類違反了 SRP 原則,我們就會經常出於不同的原因去修改它,這很可能會導致不同功能之間互相影響。比如,某天我為了適配 Hacker News 站點的新樣式,調整了頁面解析邏輯,卻發現輸出的檔案內容也全被破壞了。

另外,單個類承擔的職責越多,意味著這個類就越複雜,越難維護。在面向物件領域,有一種臭名昭著的類:God Class,God Class 專指那些包含了太多職責,程式碼特別多,什麼事情都能做的類。God Class 是所有程式設計師的噩夢,每個理智尚存的程式設計師在碰到 God Class 後,第一個想法總是逃跑,逃得越遠越好。

最後,違反 SRP 原則的類也很難被複用。假如我現在要寫另一個和 HN 有關的指令碼,需要複用HNTopPostsSpider類的抓取和解析邏輯。我會發現這事根本做不到,因為我必須得提供一個莫名其妙的檔案物件給HNTopPostsSpider類才行。

違反 SRP 原則的壞處說了一籮筐,那麼,究竟怎麼修改指令碼才能讓它符合 SRP 原則呢?辦法有很多,其中最傳統的就是:把大類拆分為小類。

10.2.3 大類拆小類

為了讓HNTopPostsSpider類的職責變得更純粹,我把其中與“寫入檔案”相關的內容拆了出去,成為了一個新的類:PostsWriter

class PostsWriter:
    """負責將帖子列表寫入到檔案"""

    def __init__(self, fp: io.TextIOBase, title: str):
        self.fp = fp
        self.title = title

    def write(self, posts: List[Post]):
        self.fp.write(f'# {self.title}\n\n')
        for i, post in enumerate(posts, 1):
            self.fp.write(f'> TOP {i}: {post.title}\n')
            self.fp.write(f'> 分數:{post.points} 評論數:{post.comments_cnt}\n')
            self.fp.write(f'> 地址:{post.link}\n')
            self.fp.write('------\n')

然後,對於HNTopPostsSpider類,我直接把write_to_file()方法刪掉,讓它只保留fetch()方法。

class HNTopPostsSpider:
    """抓取 Hacker News Top 內容條目"""

    def __init__(self, limit: int = 5):
        ...

    def fetch(self) -> Iterable[Post]:
        ...

這樣修改以後,HNTopPostsSpiderPostsWriter類都各自符合了單一職責原則。只有當解析邏輯變化時,我才會去修改HNTopPostsSpider類,同樣,修改PostsWriter類的理由也只有調整輸出格式一種。

這兩個類各自的修改可以單獨進行而不會相互影響。

最後,由於現在兩個類都只各自負責一件事,我需要一個新角色把它們的工作串聯起來,因此,我實現了一個新的函式get_hn_top_posts()

def get_hn_top_posts(fp: Optional[TextIO] = None):
    """獲取 Hacker News 的 Top 內容,並將其寫入檔案中

    :param fp: 需要寫入的檔案,如未提供,將往標準輸出列印
    """
    dest_fp = fp or sys.stdout
    crawler = HNTopPostsSpider()
    writer = PostsWriter(dest_fp, title='Top news on HN')
    writer.write(list(crawler.fetch()))

新函式通過組合HNTopPostsSpiderPostsWriter類,完成了主要工作。

函式同樣可以“單一職責”

雖然單一職責是面向物件領域的設計原則,通常被用來形容類。但在 Python 中,單一職責的適用範圍完全可以不限於類——通過定義函式,我們同樣能讓上面的程式碼符合單一職責原則。

在下面的程式碼裡,“寫入檔案”的邏輯就被拆分成了一個函式,它專門負責將帖子列表寫入檔案裡:

def write_posts_to_file(posts: List[Post], fp: TextIO, title: str):
    """負責將帖子列表寫入檔案"""
    fp.write(f'# {title}\n\n')
    for i, post in enumerate(posts, 1):
        fp.write(f'> TOP {i}: {post.title}\n')
        fp.write(f'> 分數:{post.points} 評論數:{post.comments_cnt}\n')
        fp.write(f'> 地址:{post.link}\n')
        fp.write('------\n')

這個函式只做一件事,同樣符合 SRP 原則。

將某個職責拆分為新函式是一個具有 Python 特色的解決方案,它雖然沒有那麼“面向物件”,但卻非常實用,甚至在許多場景下比編寫類更簡單、更高效。

10.3 OCP:開放——關閉原則

SOLID 原則的第二條是“Open–closed principle”(開放-關閉原則),簡稱 OCP 原則。OCP 原則認為:類應該對擴充套件開放,對修改封閉。換句話說:你應該可以在不修改某個類的前提下,擴充套件它的行為。

這是一個看上去自相矛盾,讓人一頭霧水的設計原則。不修改程式碼的話,又怎麼能改變行為呢?難道用超能力嗎?

其實,OCP 原則沒你想的那麼神祕,你身邊就有一個符合 OCP 原則的例子:內建排序函式sorted()sorted()是一個對可迭代物件進行排序的內建函式,它的使用方法如下:

>>> l = [5, 3, 2, 4, 1]
>>> sorted(l)
[1, 2, 3, 4, 5]

預設情況下,sorted()的排序策略是遞增的,小的在前,大的在後。

現在,假如我想改變sorted的排序邏輯,比如,讓它使用所有元素對 3 取模後的結果來排序。我是不是得去修改sorted()函式的原始碼呢?當然不用,我只要在呼叫函式時,傳入自定義的key引數就行了。

>>> l = [8, 1, 9]
>>> sorted(l, key=lambda i: i % 3) 
[9, 1, 8]
按照元素對 3 取模的結果排序,能被 3 整除的 9 排在了最前面,隨後是 1 和 8

通過上面的例子,你可以發現,sorted()函式是一個符合 OCP 原則的絕佳例子,因為它:

  • 對擴充套件開放:你可以通過傳入自定義key函式來擴充套件它的行為

  • 對修改關閉:你無須修改 sort 函式本身[1]

接下來,讓我們回到我的 Hacker News 爬蟲指令碼,看看 OCP 原則對它會產生什麼影響。

10.3.1 接受 OCP 原則的考驗

距離上次用“單一職責”改造完 Hacker News 爬蟲指令碼後,已經過去了三天。在這三天裡,我發現雖然指令碼可以快速把內容抓回來,用起來很方便,但在多數情況下,指令碼抓回來的那些內容,都不是我想看的。

當前版本的指令碼,會不分來源的把熱門條目都抓取回來,但其實,我只對那些來自特定站點——比如 GitHub——的內容感興趣。

因此,我需要對指令碼做點小小的改造。我需要修改HNTopPostsSpider類的程式碼來對結果進行過濾。

很快,程式碼就被修改完畢:

from urllib import parse


class HNTopPostsSpider:
    ...

    def fetch(self) -> Iterable[Post]:
        """從 HN 抓取 Top 內容"""
        # ...
        counter = 0
        for item in items:
            if counter >= self.limit:
                break
            # ...
            link = node_title.get('href')

            # 只關注來自 github.com 的內容
            parsed_link = parse.urlparse(link) 
            if parsed_link.netloc == 'github.com':
                counter += 1
                yield Post(...)
呼叫urlparse()會返回某個 URL 地址的解析結果——一個ParsedResult物件,該結果物件包含多個屬性,其中netloc代表主機地址(域名)

接下來,讓我簡單測試一下修改後的效果:

$ python news_digester_O_before.py
# Top news on HN

> TOP 1: Mimalloc – A compact general-purpose allocator
> 分數:291 評論數:40
> 地址:https://github.com/microsoft/mimalloc
------
...

看起來,新寫的過濾程式碼起了作用,現在只有當內容條目來自github.com時,才會被寫入到結果中。

不過,正如希臘哲學家赫拉克利特所言:這世間唯一不變的,只有變化本身。沒過幾天,我的興趣就發生了變化,我突然覺得,除了 GitHub 以外,來自 Bloomberg[2]的內容也都很有意思。因此,我得給指令碼的篩選邏輯加個新域名:bloomberg.com。

這時我發現,為了增加bloomberg.com,我必須得修改現有的HNTopPostsSpider類程式碼,調整那行if parsed_link.netloc == 'github.com'判斷語句,才能達到我的目的。

還記得 OCP 原則說什麼嗎?“類應該通過擴充套件而不是修改的方式改變自己的行為”,按照這個定義,現在的程式碼明顯違反了 OCP 原則,因為我必須得修改類程式碼,才能調整域名過濾條件。

那麼,怎樣才能讓類符合 OCP 原則,達到不改程式碼就能調整行為的狀態呢?第一個辦法是使用繼承。

10.3.2 通過繼承改造程式碼

繼承是面向物件程式設計裡的一個重要概念,它提供了強大的程式碼複用能力。

繼承與 OCP 原則之間有著重要的聯絡。繼承允許我們用一種新增子類,而不是修改原有類的方式來擴充套件程式的行為,這恰好符合 OCP 原則。而要做到有效地擴充套件,關鍵點在於:先找到父類中不穩定、會變動的內容。只有將這部分變化封裝成方法(或屬性),子類才能通過繼承重寫這部分行為。

話題回到我的爬蟲指令碼。在目前的需求場景下,HNTopPostsSpider類裡會變動的不穩定邏輯,其實就是“使用者對條目是否感興趣”部分(誰讓我一天一個想法呢?)。

因此,我可以將這部分邏輯抽出來,提煉成一個新的方法:

class HNTopPostsSpider:
    ...

    def fetch(self) -> Iterable[Post]:
        # ...
        for item in items:
            # ...
            post = Post(...)
            # 使用測試方法來判斷是否返回該帖子
            if self.interested_in_post(post):
                counter += 1
                yield post

    def interested_in_post(self, post: Post) -> bool:
        """判斷是否應該將帖子加入結果中"""
        return True

有了這樣的結構後,假如我只關心來自github.com的帖子,那麼我只要定義一個繼承HNTopPostsSpider的子類,然後重寫父類的interested_in_post()方法即可。

class GithubOnlyHNTopPostsSpider(HNTopPostsSpider):
    """只關心來自 GitHub 的內容"""

    def interested_in_post(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc == 'github.com'


def get_hn_top_posts(fp: Optional[TextIO] = None):
    # crawler = HNTopPostsSpider()
    # 使用新的子類
    crawler = GithubOnlyHNTopPostsSpider()
    ...

假如某天,我的興趣發生了變化?沒關係,不用修改舊程式碼,只要增加新子類就行:

class GithubNBloomBergHNTopPostsSpider(HNTopPostsSpider):
    """只關心來自 GitHub/BloomBerg 的內容"""

    def interested_in_post(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc in ('github.com', 'bloomberg.com')

在這個框架下,只要需求變化和“是否對條目感興趣”有關,我都不需要修改原本的HNTopPostsSpider父類,我只要不斷在它的基礎上,建立新的子類就行。通過繼承,我最終實現了 OCP 原則所說的:對擴充套件開放,對改變關閉,如圖 10-2 所示。

Figure 1. 圖 10-2 通過繼承實現 OCP 原則

10.3.3 使用組合與依賴注入

雖然繼承功能強大,但它並非通往 OCP 原則的唯一途徑。除了繼承外,我們還可以使用另一種思路:組合(composition),更具體一點,使用基於組合思想的依賴注入(dependency injection)技術。

與繼承不同,依賴注入允許我們在建立物件時,將業務邏輯中易變的部分(也常被稱為“演算法”)通過初始化引數注入到物件裡,最終利用多型特性達到“不改程式碼來擴充套件類”的效果。

如之前所分析的,在這個腳本里,“條目過濾演算法”是業務邏輯裡的易變部分。要實現依賴注入,我們需要先給過濾演算法建模。

先定義了一個名為PostFilter的抽象類:

from abc import ABC, abstractmethod

class PostFilter(ABC):
    """抽象類:定義如何過濾帖子結果"""

    @abstractmethod
    def validate(self, post: Post) -> bool:
        """判斷帖子是否應該被保留"""

隨後,為了實現指令碼的原始邏輯:不過濾任何條目。我們建立了一個繼承該抽象類的預設演算法類:DefaultPostFilter,它的過濾邏輯是保留所有結果。

要實現依賴注入,HNTopPostsSpider類也需要做一些調整,它必須在初始化時,接收一個名為post_filter的結果過濾器物件:

class DefaultPostFilter(PostFilter):
    """保留所有帖子"""

    def validate(self, post: Post) -> bool:
        return True


class HNTopPostsSpider:
    """抓取 Hacker News Top 內容條目

    :param limit: 限制條目數,預設為 5
    :param post_filter: 過濾結果條目的演算法,預設為保留所有
    """

    items_url = 'https://news.ycombinator.com/'

    def __init__(self, limit: int = 5, post_filter: Optional[PostFilter] = None):
        self.limit = limit
        self.post_filter = post_filter or DefaultPostFilter() 

    def fetch(self) -> Iterable[Post]:
        # ...
        counter = 0
        for item in items:
            # ...
            post = Post(...)
            # 使用測試方法來判斷是否返回該帖子
            if self.post_filter.validate(post):
                counter += 1
                yield post
因為HNTopPostsSpider類所依賴的過濾器,是通過初始化引數被注入進來的,所以這個技術被稱為“依賴注入”。

如程式碼所表示的,當我不提供post_filter引數時,HNTopPostsSpider.fetch()會保留所有的結果,不做任何過濾。假如需求發生了變化,當前的過濾邏輯需要被修改。那麼我只要建立一個新的PostFilter類即可。

下面就是分別過濾 GitHub 與 Bloomberg 的兩個PostFilter類:

class GithubPostFilter(PostFilter):
    def validate(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc == 'github.com'


class GithubNBloomPostFilter(PostFilter):
    def validate(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc in ('github.com', 'bloomberg.com')

在建立HNTopPostsSpider物件時,我可以選擇傳入不同的過濾器物件,以此滿足不同的過濾需求:

crawler = HNTopPostsSpider() 
crawler = HNTopPostsSpider(post_filter=GithubPostFilter()) 
crawler = HNTopPostsSpider(post_filter=GithubNBloomPostFilter()) 
不過濾任何內容
過濾僅 GitHub 站點內容
過濾 GitHub 與 Bloomberg 站點
Figure 2. 圖 10-3 通過依賴注入實現 OCP 原則

通過抽象與提煉過濾器演算法,並結合多型與依賴注入技術,我同樣讓程式碼符合了 OCP 原則。

抽象類不是必須的

你可以發現,我編寫的過濾器演算法類,其實沒有共享抽象類裡的任何程式碼,也沒有任何通過繼承來複用程式碼的需求。因此,我其實可以完全不定義PostFilter抽象類,直接編寫後面的過濾器類。

這樣做對於程式的執行效果不會有任何影響。因為 Python 是一門“鴨子型別”語言,它在呼叫不同演算法類的.validate()(也就是“多型”)前,不會做任何型別檢查工作。

但是,如果少了PostFilter抽象類,當我在編寫HNTopPostsSpider類的__init__方式時,我就沒法給post_filter增加型別註解了——post_filter: Optional[這裡寫什麼?],因為我根本找不到一個具體的型別。

所以我必須編寫一個抽象類,以此來滿足型別註解的需求。

這件事情告訴我們:型別註解是一種讓 Python 更接近靜態語言的東西。啟用型別註解,你就必須給任何東西找到一個能作為註解的實體型別。型別註解會強制我們把大腦裡的隱式“介面”和“協議”,顯式的表達出來。

10.3.4 使用資料驅動

在實現 OCP 原則的眾多手法中,除了繼承與依賴注入外,還有另一種常被用到的方式:資料驅動。資料驅動的核心思想在於:將經常變動的部分以資料的方式抽離出來,當需求變化時,只改動資料,程式碼邏輯可以保持不動。

聽上去,資料驅動和依賴注入有點像,它們都是把變化的東西抽離到類外部。二者的不同點在於:依賴注入抽離的通常是類,而資料驅動抽離的則是純粹的資料。

下面,讓我們在指令碼中嘗試一下資料驅動方案。

改造成資料驅動的第一步是定義資料的格式。在這個需求中,變動的部分是“我感興趣的站點地址”,因此我可以簡單地用一個字串列表filter_by_hosts: [List[str]]來指代這個地址。

下面是修改過的HNTopPostsSpider類程式碼:

class HNTopPostsSpider:
    """抓取 Hacker News Top 內容條目

    :param limit: 限制條目數,預設為 5
    :param filter_by_hosts: 過濾結果的站點列表,預設為 None,代表不過濾
    """

    def __init__(self, limit: int = 5, filter_by_hosts: Optional[List[str]] = None):
        self.limit = limit
        self.filter_by_hosts = filter_by_hosts

    def fetch(self) -> Iterable[Post]:
        counter = 0
        for item in items:
            # ...
            post = Post(...)
            # 判斷連結是否符合過濾條件
            if self._check_link_from_hosts(post.link):
                counter += 1
                yield post

    def _check_link_from_hosts(self, link: str) -> True:
        """檢查某連結是否屬於所定義的站點"""
        if self.filter_by_hosts is None:
            return True
        parsed_link = parse.urlparse(link)
        return parsed_link.netloc in self.filter_by_hosts

修改完HNTopPostsSpider類後,它的呼叫方也要進行調整。在建立HNTopPostsSpider例項時,我得把想要過濾的站點列表傳進去:

hosts = None 
hosts = ['github.com', 'bloomberg.com'] 
crawler = HNTopPostsSpider(filter_by_hosts=hosts)
不過濾任何內容
過濾來自 github.com 和 bloomberg.com 的內容

之後,每當我需要調整過濾站點,只要修改hosts列表即可,無須調整HNTopPostsSpider類的任意一行程式碼。這種資料驅動的方式,同樣滿足了 OCP 原則的要求。

同前面的繼承與依賴注入相比,使用資料驅動的程式碼明顯更簡潔,因為它不需要定義任何額外的類。

但資料驅動也有一個缺點:它的可定製性不如其他兩種方式。舉個例子,假如我現在想要以“連結是否以某個字串結尾”來做過濾,現在的資料驅動程式碼就做不到。

影響每種方案可定製性的根本原因,在於各方案的所處的抽象級別不一樣。比如,在依賴注入方案裡,我選擇抽象的內容是“條目過濾行為”,而在資料驅動方案下,抽象內容則是“條目過濾行為的有效站點地址”。很明顯,後者的層級更低,關注的內容更具體,所以靈活性自然不如前者。

在日常工作中,如果你想寫出符合 OCP 原則的程式碼,除了使用這裡演示的繼承、依賴注入和資料驅動外,還有許多不同的處理方式。每種方式各有優劣,你需要深入分析具體的需求場景,才能判斷出哪種方式最為適合。這是一個無法一蹴而就、需要大量練習才能掌握的過程。

10.4 總結

在本章,我通過一個具體的案例,向你描述了 SOLID 設計原則中的前兩位成員:單一職責原則與開放——關閉原則。

這兩條原則看似簡單,背後其實蘊藏了許多從好程式碼中提煉而來的智慧,它們的適用範圍也不僅僅侷限於面向物件程式設計。一旦你深入理解這兩條原則後,你會在許多設計模式與框架中驚奇地發現它們的影子。

在下一章,我將向你繼續介紹 SOLID 原則的後三條,在此之前,先讓我們回顧一下前兩條原則的要點。

要點

  1. 單一職責原則(SRP)

    • SRP 原則認為:一個類只應該有一種被修改的原因

    • 編寫更小的類通常更不容易違反 SRP 原則

    • SRP 原則同樣適用於函式,你可以讓函式和類協同工作

  2. 開放——關閉原則(OCP)

    • OCP 原則認為:類應該對改動關閉,對擴充套件開放

    • 通過分析需求,找到程式碼中易變的部分,是讓類符合 OCP 原則的關鍵

    • 使用子類繼承的方式可以讓類符合 OCP 原則

    • 通過演算法類與依賴注入,也可以讓類符合 OCP 原則

    • 將資料與邏輯分離,使用資料驅動的方式也是符合 OCP 原則的好辦法

技術連結