(轉)面向物件設計原則(上)
原文: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 程式設計師,寫個小指令碼自然不在話下。利用requests
、lxml
等模組提供的強大功能,不到半小時,我就把程式寫好了。
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/
------
...
你可以明顯看出來,上面的程式碼是符合面向物件風格的。因為在程式碼裡,我定義瞭如下所示的兩個類:
-
Post
:代表一個 HN 內容條目,包含標題、連結等欄位,是一個典型的“資料類”,主要用來銜接程式的“資料抓取”與“檔案寫入”行為。 -
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]:
...
這樣修改以後,HNTopPostsSpider
和PostsWriter
類都各自符合了單一職責原則。只有當解析邏輯變化時,我才會去修改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()))
新函式通過組合HNTopPostsSpider
與PostsWriter
類,完成了主要工作。
雖然單一職責是面向物件領域的設計原則,通常被用來形容類。但在 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 所示。
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 站點 |
通過抽象與提煉過濾器演算法,並結合多型與依賴注入技術,我同樣讓程式碼符合了 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 原則的後三條,在此之前,先讓我們回顧一下前兩條原則的要點。
要點
-
單一職責原則(SRP)
-
SRP 原則認為:一個類只應該有一種被修改的原因
-
編寫更小的類通常更不容易違反 SRP 原則
-
SRP 原則同樣適用於函式,你可以讓函式和類協同工作
-
-
開放——關閉原則(OCP)
-
OCP 原則認為:類應該對改動關閉,對擴充套件開放
-
通過分析需求,找到程式碼中易變的部分,是讓類符合 OCP 原則的關鍵
-
使用子類繼承的方式可以讓類符合 OCP 原則
-
通過演算法類與依賴注入,也可以讓類符合 OCP 原則
-
將資料與邏輯分離,使用資料驅動的方式也是符合 OCP 原則的好辦法
-