【翻譯】SOLID原則:提升Python中的面向?qū)ο笤O(shè)計(jì)

原文鏈接:https://realpython.com/solid-principles-python/
by
May 01, 2023
目錄
Python中的面向?qū)ο笤O(shè)計(jì):SOLID原則
單一職責(zé)原則(SRP)
開放封閉原則(OCP)
接口隔離原則(ISP)
依賴倒置原則(DIP)
結(jié)語
當(dāng)你使用面向?qū)ο缶幊蹋∣OP)建立起來一個(gè)Python項(xiàng)目,規(guī)劃好不同的類、對(duì)象之間的交互以解決特定問題是工作的重要部分。這種規(guī)劃就被稱作 (面向?qū)ο笤O(shè)計(jì)),把它做好可是個(gè)不小的挑戰(zhàn)。如果你在設(shè)計(jì)Python類時(shí)卡住了,那么SOLID原則會(huì)幫你走出困境。
SOLID是一套共計(jì)5個(gè)的面向?qū)ο蟮脑O(shè)計(jì)原則,能幫助你設(shè)計(jì)出優(yōu)秀的、結(jié)構(gòu)清晰的類,進(jìn)而寫出更容易維護(hù)、更靈活、伸縮性強(qiáng)的代碼。這些教程是面向?qū)ο笤O(shè)計(jì)最佳實(shí)踐的根本部分。
在本篇教程中,你將
理解每一條SOLID原則的意義和目的
識(shí)別違反了部分SOLID原則的Python代碼
運(yùn)用SOLID原則來重塑你的Python代碼并提升它的設(shè)計(jì)
在這次學(xué)習(xí)之旅中,你會(huì)編寫實(shí)際的案例,來探索如何在SOLID原則的指導(dǎo)下寫出條理性強(qiáng)、靈活、維護(hù)性強(qiáng)以及伸縮性強(qiáng)的代碼。
為了在最大程度上受益,你必須對(duì)Python的
(面向?qū)ο缶幊蹋└拍钣休^好的了解,例如 , ,和 ? 。Python中的面向?qū)ο笤O(shè)計(jì):SOLID原則
當(dāng)在Python里編寫類和設(shè)計(jì)它們的交互時(shí),你可以遵循一系列幫你編寫更好的面向?qū)ο蟠a的原則。這其中最受歡迎、最廣泛接受的
(面向?qū)ο笤O(shè)計(jì))標(biāo)準(zhǔn)之一就是 原則。如果你是從 yes 。如果你在編寫面向?qū)ο蟠a,那就應(yīng)該把這些原則考慮進(jìn)去。
或者 轉(zhuǎn)過來的,你可能已經(jīng)對(duì)這些原則很熟悉了。也許你在想SOLID原則是否對(duì)Python代碼也適用。這個(gè)問題的答案,當(dāng)然是一個(gè)響亮的但SOLID原則有哪些呢?SOLID是五個(gè)應(yīng)用在面向?qū)ο笤O(shè)計(jì)的核心原則的縮寫。這些原則如下:
(單一職責(zé)原則)
(開放封閉原則)
(里氏替換原則)
(接口隔離原則)
(依賴倒置原則)
你將詳細(xì)探索這些原則,并編寫一些在Python中應(yīng)用它們的真實(shí)代碼。在這個(gè)過程中,通過應(yīng)用SOLID原則,你將對(duì)如何編寫更直觀、有條理、伸縮性強(qiáng)、復(fù)用性強(qiáng)的面向?qū)ο蟠a有深入的理解。閑話少說,你要開始學(xué)清單上的第一條原則啦。
單一職責(zé)原則 (SRP)
單一職責(zé)原則 (SRP) 由 提出,他的昵稱 Uncle Bob 更廣為人知。他在軟件工程界廣受尊敬,還是 (敏捷宣言)的簽署者之一。事實(shí)上,他創(chuàng)造了SOLID這個(gè)術(shù)語。
單一職責(zé)原則規(guī)定了這點(diǎn):
一個(gè)類應(yīng)該只有一個(gè)發(fā)生變化的原因
這就是說一個(gè)類應(yīng)該只有一個(gè)職責(zé),它的方法也應(yīng)該表現(xiàn)成這樣。如果一個(gè)類關(guān)注了多個(gè)任務(wù),那么你應(yīng)該把這些任務(wù)拆成單個(gè)單個(gè)的類。
注意: 你會(huì)發(fā)現(xiàn)SOLID原則的表述形式會(huì)有差別。本篇教程談的是 Uncle Bob 在他的書 里用的表述。所以,所有的直接引用都來自他的書。
如果你想看看這些,以及相關(guān)原則的快速概述的其他表述,可以看看 Uncle Bob’s
。
這個(gè)原則和
(責(zé)任分離)很相近,責(zé)任分離建議把程序拆成不同部分。每個(gè)部分得強(qiáng)調(diào)單獨(dú)的責(zé)任。為了說明單一職責(zé)原則以及它如何提升你的面向?qū)ο笤O(shè)計(jì),就比如說有一個(gè)這樣的 FileManager
類:
在這個(gè)例子中,你的 FileManager
類有兩個(gè)不同的職責(zé)。它用 .read()
和 .write()
方法來管理文件。也通過提供 .compress()
和 .decompress()
方法處理 (ZIP文檔)。
這個(gè)類違反了單一職責(zé)原則因?yàn)樗袃蓚€(gè)改變內(nèi)部實(shí)現(xiàn)的原因。為了修正這個(gè)問題,讓你的設(shè)計(jì)魯棒性更好,你可以把這個(gè)類拆成兩個(gè)更小、更專一的類,每個(gè)只有它自己的特定職責(zé):
現(xiàn)在你有了兩個(gè)更小的類,每個(gè)都僅有一個(gè)職責(zé)。 FileManager
關(guān)心的是管理文件,而 ZipFileManager
處理ZIF格式文件的 (壓縮)和 (解壓)。 這兩個(gè)類更小,所以更容易管理。它們也更容易分析、測(cè)試以及debug。
此處的職責(zé)可能是相當(dāng)主觀的。具有單一職責(zé)并不一定意味著具有單個(gè)方法。職責(zé)不是直接跟方法數(shù)綁定的,而是跟你的類負(fù)責(zé)的核心任務(wù)綁定,取決于這個(gè)類在你的代碼里代表什么。然而,主觀性并不應(yīng)該阻止你力求SRP。
開放封閉原則(OCP)
面向?qū)ο笤O(shè)計(jì)里的開放封閉原則(OCP)最初由 在1988年提出,意思是:
軟件實(shí)體(類、模塊、函數(shù)等等)應(yīng)該對(duì)拓展開放,對(duì)修改封閉
要理解什么是開放封閉原則,考慮下面的 Shape
類:
Shape
的構(gòu)造器接收一個(gè)在 "rectangle"
和 "circle"
二選一的 shape_type
參數(shù)。還用 語法接收特定的一套關(guān)鍵字參數(shù)。如果你把形狀設(shè)成 "rectangle"
,那么你應(yīng)該傳入 width
和 height
關(guān)鍵字參數(shù)以便構(gòu)造一個(gè)合適的矩形。
相反,如果你把形狀設(shè)成 "circle"
,那么你必須也傳入 radius
參數(shù)來構(gòu)造一個(gè)圓形。
注意: 這個(gè)例子可能看起來有點(diǎn)極端。這是為了清晰地展示開放封閉原則的核心觀點(diǎn)。
Shape
還有一個(gè) .calculate_area()
方法,能根據(jù)當(dāng)前形狀的 .shape_type
計(jì)算面積:
這個(gè)類能運(yùn)行。你可以創(chuàng)建圓和矩形,計(jì)算面積,等等。然而,這個(gè)類看起來很糟糕,有些事似乎第一眼就不對(duì)。
想象你需要加入一個(gè)新的形狀,也許是正方形。你會(huì)怎么做?好吧,其中一個(gè)選擇是再加個(gè)elif
子句到 和 .calculate_area()
里,這樣就能解決正方形的需求了。
必須做這些改變來創(chuàng)建新的形狀就說明你的類對(duì)修改是開放的。這違反了開放封閉原則。你該如何修改類使得它對(duì)拓展開放但對(duì)修改封閉呢?這有一個(gè)可能的解決:
在這段代碼里,你完全 Shape
類,把它變成了一個(gè) (抽象基類)。這個(gè)類提供的 (接口)讓你能定義任何想要的形狀。這個(gè) (接口)由 .shape_type
屬性和必須在子類重寫的 .calculate_area()
方法組成。
注意:上面的這個(gè)例子以及后續(xù)章節(jié)的一些例子使用了Python的ABC來提供接口繼承。在這種繼承里,子類繼承了接口而不是功能。反之,如果類繼承了功能,就是實(shí)現(xiàn)繼承。
這種更新讓類對(duì)修改封閉?,F(xiàn)在你無需修改 Shape
就能往類里加入新的形狀啦。在每種情況下,你都必須實(shí)現(xiàn)所需接口,這也使得你的類具有 (多態(tài)性)。
里氏替換原則(LSP)
里氏替換原則(LSP)由 在1987年的 (OOPSLA會(huì)議)提出。從此,這個(gè)原則成了面向?qū)ο缶幊痰幕静糠?。這條原則規(guī)定了:
子類必須能替代它們的基類
舉個(gè)例子,如果你有一段用 Shape
類運(yùn)行的代碼,那么也應(yīng)該能把這個(gè)類替換成它的任意一個(gè)子類,例如 Circle
或 Rectangle
,而不會(huì)中途報(bào)錯(cuò)。
注意:你可以閱讀 (會(huì)議議程),了解 Barbara Liskov 首次分享這一原則的主題演講內(nèi)容;或者還可以觀看她的一段簡短 (訪談)片段,以獲取更多背景信息。
實(shí)際上,這個(gè)原則是說當(dāng)任何人調(diào)用子類和基類的同一個(gè)方法時(shí),它們的行為應(yīng)該如預(yù)期般一致。繼續(xù)形狀的例子,比如說你有一個(gè)下面這樣的 Rectangle
類:
在 Rectangle
里,你提供了 .calculate_area()
方法,該方法使用了 .width
和 .height
(實(shí)例屬性)。
由于一個(gè)正方形是等邊的特殊矩形,為了復(fù)用相同代碼,你考慮從 Rectangle
派生出 Square
。然后,你為 .width
和 .height
屬性重寫了 ?,這樣當(dāng)一條邊改變,另一條邊也會(huì)改變:
在這一小段代碼中,你定義 Rectangle
的子類 Square
。作為用戶可能會(huì)希望,類構(gòu)造器 只接收正方形變成作為參數(shù)。 在內(nèi)部, .__init__()
方法使用 side
參數(shù)初始化了父類的 .width
和 .height
屬性。
你可能也會(huì)定義 .width
或 .height
屬性 (設(shè)定)新值時(shí)攔截。 特別地,當(dāng)設(shè)定其中一個(gè)屬性,另外一個(gè)屬性也會(huì)被設(shè)成同樣的值:
現(xiàn)在你確保了 Square
對(duì)象永遠(yuǎn)是一個(gè)有效的矩形,雖然浪費(fèi)了一點(diǎn)內(nèi)存,但感覺舒服多了。不幸的是,這違反了里氏替換原則因?yàn)槟悴荒軐?Rectangle
的實(shí)例替換成對(duì)應(yīng)的 Square
。
當(dāng)別人想在代碼里用矩形對(duì)象,他們可能假定這個(gè)對(duì)象表現(xiàn)得像一個(gè)矩形,即會(huì)暴露出兩個(gè)獨(dú)立的 .width
和 .height
屬性。 與此同時(shí),你的 Square
類更改了由對(duì)象接口所保證的行為,從而打斷了這種假設(shè)。這可能導(dǎo)致意料之外的結(jié)果,很難去 。
雖然正方形在數(shù)學(xué)上是一類特殊的矩形,但如果你想遵守里氏替換原則,它們的類不應(yīng)該是父子代關(guān)系。其中一個(gè)解決方案是給 Rectangle
和 Square
創(chuàng)建一個(gè)可拓展的基類:
現(xiàn)在你可以通過多態(tài)性用 Rectangle
或 Square
替換 Shape
,現(xiàn)在它們是兄弟姐妹而不是父子代關(guān)系。注意兩個(gè)具體的形狀都有不同的一套屬性、不同的初始化方法,并可能實(shí)現(xiàn)更多不相干的行為。他們唯一的共同點(diǎn)就是都能計(jì)算面積。
有了這個(gè)實(shí)現(xiàn),當(dāng)你只關(guān)心它們的共同行為時(shí),就可以用 Square
和 Rectangle
子類交換 Shape
:
這里,你給計(jì)算總面積的函數(shù)傳入了一個(gè)矩形和正方形。由于這個(gè)函數(shù)只關(guān)心 .calculate_area()
方法,形狀不同也沒關(guān)系。這就是里氏替換原則的精華。
接口隔離原則(ISP)
接口隔離原則(ISP)和單一職責(zé)原則出發(fā)點(diǎn)相同。 是的,這又是 的另一項(xiàng)成就。這個(gè)原則的中心思想是:
clients 不應(yīng)該被強(qiáng)迫依賴它們用不上的方法。Interfaces 屬于clients,而不是 hierarchy (層次結(jié)構(gòu))。
在這個(gè)情景里,clients 指的是類和子類,而 interfaces 包括了方法和屬性。換句話說,如果一個(gè)類用不上某些方法或?qū)傩?,那么這些方法和屬性就該拆分到更多專門的類里。
考慮下面這個(gè)類的
(層次結(jié)構(gòu)),用于模仿打印機(jī)設(shè)備:
在這個(gè)例子中,基類 Printer
,提供了子類必須實(shí)現(xiàn)的接口。繼承自 Printer
的 OldPrinter
必須實(shí)現(xiàn)相同的接口。然而, OldPrinter
不使用 .fax()
和 .scan()
方法因?yàn)檫@種打印機(jī)不支持這些功能。
這個(gè)實(shí)現(xiàn)違反了 ISP 因?yàn)樗鼜?qiáng)迫 OldPrinter
暴露沒實(shí)現(xiàn)也用不上的接口。要修正這點(diǎn),你應(yīng)該把接口分成更小、更專門的類。然后你就可以按照需要繼承多個(gè)接口類來創(chuàng)建具體類:
現(xiàn)在 Printer
, Fax
,和 Scanner
是提供特定接口的基類。要?jiǎng)?chuàng)建 OldPrinter
,你只需要繼承 Printer
接口。這樣,這個(gè)類就不會(huì)有用不上的方法了。要?jiǎng)?chuàng)建 ModernPrinter
類,你需要繼承所有的接口。簡單地說,你隔離了 Printer
接口。
這種類設(shè)計(jì)允許你創(chuàng)建具有不同功能的各種機(jī)器,使得你的設(shè)計(jì)更靈活、拓展性好。
依賴倒置原則(DIP)
依賴倒置原則(DIP)是SOLID里的最后一個(gè)。這個(gè)原則規(guī)定:
抽象不該依賴細(xì)節(jié)。細(xì)節(jié)應(yīng)該依賴抽象。
聽起來很復(fù)雜。這兒有個(gè)例子幫你明白這點(diǎn)。比如說你正在創(chuàng)建一個(gè)應(yīng)用程序,有一個(gè) FrontEnd
類,用來把數(shù)據(jù)友好地展示給用戶。這個(gè)應(yīng)用程序目前從數(shù)據(jù)庫獲取數(shù)據(jù),所以你最終寫出了下面代碼:
在這個(gè)例子中, FrontEnd
類依賴于 BackEnd
類和它的具體實(shí)現(xiàn)。你可以說這兩個(gè)類緊密耦合在了一起。這種耦合會(huì)導(dǎo)致伸縮性上的問題。比如說你的應(yīng)用程序發(fā)展很快,你希望它能從 獲取數(shù)據(jù)。你會(huì)怎么做呢?
你可能想到給 BackEnd
加個(gè)新方法來從 REST API 獲取數(shù)據(jù)。然而,這又使得你去修改 FrontEnd
,而根據(jù) (開放封閉原則),它本應(yīng)對(duì)修改封閉。
要修正這個(gè)問題,你可以應(yīng)用依賴倒置原則,使你的類依賴抽象而不是像 BackEnd
這樣的具體實(shí)現(xiàn)。在這個(gè)特定例子中,你可以引入一個(gè) DataSource
類來提供在具體類中使用的接口:
在這個(gè)重新設(shè)計(jì)的版本里,你加入了 DataSource
類作為提供所需接口或者說 .get_data()
方法的抽象類。注意 FrontEnd
現(xiàn)在依賴于 DataSource
提供的抽象接口。
然后你定義了 Database
類,作為從數(shù)據(jù)庫獲取數(shù)據(jù)的情景的具體實(shí)現(xiàn)。這個(gè)類通過繼承依賴于 DataSource
抽象類。最后,你定義了 API
類以支持從 REST API 獲取數(shù)據(jù)。這個(gè)類也依賴于 DataSource
抽象類。
現(xiàn)在你可以在代碼里這么用 FrontEnd
類:
這里,你先使用一個(gè)Database
對(duì)象初始化 FrontEnd
,然后又換了一個(gè) API
對(duì)象。每次你調(diào)用 .display_data()
,結(jié)果都依賴于你使用的具體數(shù)據(jù)源。注意你可也以通過給 FrontEnd
實(shí)例重新賦值 .data_source
屬性來動(dòng)態(tài)改變數(shù)據(jù)源。
結(jié)語
關(guān)于五條SOLID原則,你已經(jīng)學(xué)到了很多,包括如何分辨違反它們的代碼以及如何重塑代碼來遵守最佳設(shè)計(jì)實(shí)踐。你見識(shí)到了有關(guān)每條原則的正面和反面案例,也學(xué)到了在Python里應(yīng)用SOLID原則能提升你的面向?qū)ο笤O(shè)計(jì)。
在本篇教程中,你學(xué)到了:
理解每一條SOLID原則的意義和目的
識(shí)別違反了部分SOLID原則的Python代碼
運(yùn)用SOLID原則來重塑你的Python代碼并提升它的設(shè)計(jì)