寫好 Python 程式碼的幾條重要技巧
程式設計的好與壞,早在我們青蔥歲月時就接觸過了,只是那是並不知道這竟如此重要。能夠立即改善程式設計、寫出“好”程式碼的知識有以下幾點:
•面向物件五個基本原則;
•常見的三種架構;
•繪圖;
•起一個好名字;
•優化巢狀的 if else 程式碼;
當然,其他技術知識的豐富程度也決定了程式設計的好壞。例如通過引入訊息佇列解決雙端效能差異問題、通過增加快取層提高查詢效率等。下面我們一起來看看,上面列出的知識點包含哪些內容,這些內容對程式碼和程式設計的改善有何幫助。
面向物件五個基本原則
本書作者是 2010 級學生,面向物件是作者青蔥時期發展火熱的程式設計正規化。它的五個基本原則是:
• 單一職責原則;
• 開放封閉原則;
• 依賴倒置原則;
• 介面隔離原則;
• 合成複用原則;
下面我們將通過對比和場景假設的方式瞭解五個基本原則對程式碼質量的影響。
立竿見影的單一職責原則
沒錯,立竿見影、效果卓越。對於我們這些自學程式設計無師自通的人來說,能把功能實現就可以了,根本沒有時間考慮程式碼優化和維護成本問題。時光流逝,竟在接觸程式設計很長一段時間後才發現它竟如此重要。
俗話說只要程式碼寫的夠爛,提升就足夠明顯。以一個從檔案內容中匹配關鍵資料並根據匹配結果發出網路請求的案例,看看大部分程式設計師的寫法:
import re import requests FILE = "./information.fet" def extract(file): fil = open(file, "r") content = fil.read() fil.close() find_object = re.search(r"url=\d+", content) find = find_object.group(1) text = requests.get(find) return text if __name__ == "__main__": text = extract(FILE) print(text)
需求已經實現,這點毋庸置疑,但是問題來了:
• 如果讀取檔案的時候發生異常了怎麼辦?
• 如果資料來源發生變化該如何處理?
• 如果網路請求返回的資料不符合最終要求怎麼辦?
如果你心裡的第一個反應是改程式碼,那你就要注意了。完成一件事中間的某個環節發生變化,改程式碼是在所難免的,但是如果按照上面這種寫法,不僅程式碼越改越亂,連邏輯也會越來越亂。單一職責原則表達的是讓一個函式儘量只做一件事,不要將多件事混雜在一個函式中。
上面的程式碼如果重新設計,我認為至少應該是這樣的:
def get_source(): """獲取資料來源""" return def extract_(val): """匹配關鍵資料""" return def fetch(val): """發出網路請求""" return def trim(val): """修剪資料""" return def extract(file): """提取目標資料""" source = get_source() content = extract_(source) text = trim(fetch(content)) return text if __name__ == "__main__": text = extract(FILE) print(text)
把原來放在一個函式中實現的多個步驟拆分成為多個更小的函式,每個函式只做一件事。當資料來源發生變化時,只需要改動 get_source 相關的程式碼即可;如果網路請求返回的資料不符合最終要求,我們可以在 trim 函式中對它進行修剪。這樣一來,程式碼應對變化的能力提高了許多,整個流程也變得更清晰易懂。改動前後的變化如下圖所示:
單一職責原則的核心是解耦和增強內聚力,如果一個函式承擔的職責過多,等於把這些職責耦合在一起,這種耦合會導致脆弱的設計。當發生變化時,原本的設計會遭受到意想不到的破壞。單一職責原則實際上是把一件事拆分成多個步驟,程式碼修改造成的影響範圍很小。
讓程式碼穩定性飛昇的開放封閉原則和依賴倒置原則
開放封閉原則中的開放指的是對擴充套件開放,封閉指的是對修改封閉。需求總是變化的,業務方這個月讓你把資料儲存到 MySQL 資料庫中,下個月就有可能讓你匯出到 Excel 表格裡,這時候你就得改程式碼了。這個場景和上面的單一職責原則很相似,同樣面臨程式碼改動,單一職責原則示例主要表達的是通過解耦降低改動的影響,這裡主要表達的是通過對擴充套件開放、對修改封閉提高程式應對變化的能力和提高程式穩定性。
穩定這個詞如何理解呢?
較少的改動或者不改動即視為穩定,穩定意味著呼叫這個物件的其它程式碼拿到的結果是可以確定的,整體是穩定的。
按照一般程式設計師的寫法,資料儲存的程式碼大概是這樣的:
class MySQLSave: def __init__(self): pass def insert(self): pass def update(self): pass class Business: def __init__(self): pass def save(self): saver = MySQLSave() saver.insert()
功能是能夠實現的,這點毋庸置疑。來看看它如何應對變化,如果要更換儲存,那麼就意味著需要改程式碼。按照上面的程式碼示例,有兩個選擇:
• 重新寫一個儲存到 ExcelSave 的類;
• 對 MySQLSave 類進行改動;
上面的兩種選擇,無論怎麼選都會改動 2 個類。因為不僅儲存的類需要改動,呼叫處的程式碼也需要更改。這樣一來,它們整體都是不穩定的。如果換一種實現方式,根據依賴倒置的設計指導可以輕鬆應對這個問題。邊看程式碼邊理解:
import abc class Save(metaclass=abc.ABCMeta): @abc.abstractmethod def insert(self): pass @abc.abstractmethod def update(self): pass class MySQLSave(Save): def __init__(self): self.classify = "mysql" pass def insert(self): pass def update(self): pass class Excel(Save): def __init__(self): self.classify = "excel" def insert(self): pass def update(self): pass class Business: def __init__(self, saver): self.saver = saver def insert(self): self.saver.insert() def update(self): self.saver.update() if __name__ == "__main__": mysql_saver = MySQLSave() excel_saver = Excel() business = Business(mysql_saver)
這裡通過內建的 abc 實現了一個抽象基類,這個基類的目的是強制子類實現要求的方法,以達到子類功能統一。子類功能統一後,無論呼叫它的哪個子類,都是穩定的,不會出現呼叫方還需要修改方法名或者修改傳入引數的情況。
依賴倒置中的倒置,指的是依賴關係的倒置。之前的程式碼是呼叫方 Business 依賴物件 MySQLSave,一旦物件 MySQLSave 需要被替換, Business 就需要改動。依賴倒置中的依賴指的是物件的依賴關係,之前依賴的是實體,如果改為後面這種依賴抽象的方式,情況就會扭轉過來:
實體 Business 依賴抽象有一個好處:抽象穩定。相對於多變的實體來說,抽象更穩定。程式碼改動前後的依賴關係發生了重大變化,之前呼叫方 Business 直接依賴於實體 MySQLSave,通過依賴倒置改造後 Busines 和 ExcelSave、 MySQLSave 全都依賴抽象。
這樣做的好處是如果需要更換儲存,只需要建立一個新的儲存實體,然後呼叫 Business 時傳遞進去即可,這樣可以不用改動 Business 的程式碼,符合面向修改封閉、面向擴充套件開放的開放封閉原則;
依賴倒置的具體實現方式使用了一種叫做依賴注入的手段,實際上單純使用依賴注入、不使用依賴倒置也可以滿足開閉原則要求,感興趣的讀者不妨試一試。
挑肥揀瘦的介面隔離原則
介面隔離原則中的介面指的是Interface,而不是 Web 應用裡面的 Restful 介面,但是在實際應用中可以將其抽象理解為相同的物件。介面隔離原則在設計層面看,跟單一職責原則的目的是一致的。介面隔離原則的指導思想是:
• 呼叫方不應該依賴它不需要的介面;
• 依賴關係應當建立在最小介面上;
這實際上是告訴我們要給介面減肥,過多功能的介面可以選用拆分的方式優化。舉個例子,現在為圖書館設計一個圖書的抽象類:
import abc class Book(metaclass=abc.ABCMeta): @abc.abstractmethod def buy(self): pass @abc.abstractmethod def borrow(self): pass @abc.abstractmethod def shelf_off(self): pass @abc.abstractmethod def shelf_on(self): pass
這樣是不是一下就看懂了。這個指導思想很重要,不僅能夠指導我們設計抽象介面,也能夠指導我們設計 Restful 介面,還能夠幫助我們發現現有介面存在的問題,從而設計出更合理的程式。
輕裝上陣的合成複用原則
合成複用原則的指導思想是:儘量使用物件組合,而不是繼承來達到複用的目的。合成複用的作用是降低物件之間的依賴,因為繼承是強依賴關係,無論子類使用到父類的哪幾個屬性,子類都需要完全擁有父類。合成採用另一種方式實現物件之間的關聯,降低依賴關係。
為什麼推薦優先使用合成複用,而後考慮繼承呢?
因為繼承的強依賴關係,一旦被依賴的物件(父類)發生改變,那麼依賴者(子類)也需要改變,合成複用則可以避免這樣的情況出現。要注意的是,推薦優先使用複用,但並不是拒絕使用繼承,該用的地方還得用。我們以一段程式碼為例,說明合成複用和繼承的差異:
import abc class Car: def move(self): pass def engine(self): pass class KateCar(Car): def move(self): pass def engine(self): pass class FluentCar(Car): def move(self): pass def engine(self): pass
這裡的 Car 作為父類,擁有 move 和 engine 2 個重要屬性,這時候如果需要給汽車塗裝顏色,那麼就要新增一個 color 屬性,3 個類都要增加。如果使用合成複用的方式,可以這麼寫:
class Color: pass class KateCar: color = Color() def move(self): pass def engine(self): pass class FluentCar: color = Color() def move(self): pass def engine(self): pass
類物件合成複用的具體操作是在類中例項化一個類物件,然後在需要的時候呼叫它。程式碼可能沒有那麼直觀,我們看圖:
這個例子主要用於說明繼承和合成複用的具體實現方式和前後變化,對於 Car 的繼承無需深究,因為如果你執著地討論為什麼右圖中的 2 個 Car 不用繼承,就會陷入牛角尖。
這裡的合成複用選用的實現方式是在 2 個 Car 裡面例項化另一個類 Color,其實也可以用依賴注入的手段在外部例項化 Color,然後把例項物件傳遞給 2 個 Car。
常見的三種架構
瞭解多種不同的架構可以使我們的知識面更寬廣,面對一類問題的時候可以提出其它解決辦法。同時,瞭解多種架構可以讓我們在設計階段做好規劃,避免後續頻繁的重構。常見的三種架構分別是:
• 單體架構;
• 分散式架構;
• 微服務架構;
單體架構
單體架構是我們平時接觸較多的架構,也是相對容易理解的架構。單體架構把所有功能都聚合在一個應用裡,我們可以簡單地將這種架構視作:
這種架構簡單、容易部署和測試,大部分應用的初期都採用單體架構。單體架構也有幾個明顯缺點:
• 複雜性高,所有功能糅合在一個應用裡,模組多、容易出現邊界模糊,而且隨著時間的推移和業務的發展,專案越來越大、程式碼越來越多,整體服務效率逐漸下降;
• 釋出/部署頻率低,牽一髮而動全身,新功能或問題修復的釋出上線需要多方協調,釋出時間一拖再拖。專案大則構建時間長、構建失敗的機率也會有所增加;
• 效能瓶頸明顯,一頭牛再厲害也抵不過多頭牛合力的效果,隨著資料量、請求併發的增加,讀效能的不足最先暴露出來,接著你就會發現其它方面也跟不上了;
• 影響技術創新:單體架構通常選用一類語言或一類框架統一開發,想要引入新技術或者接入現代化的服務是很困難的;
• 可靠性低,一旦服務出現問題,影響是巨大的。
分散式架構
分散式架構相對於單體架構而言,通過拆分解決了單體架構面臨的大部分問題,例如效能瓶頸。假如單體架構是一頭牛,那麼分散式架構就是多頭牛:
當單體架構出現效能瓶頸時,團隊可以考慮將單體架構轉換為分散式架構,以增強服務能力。當然,分散式並不是萬能的,它解決了單體架構效能瓶頸、可靠性低的問題,但複雜性問題、技術創新問題和釋出頻率低依然存在,這時候可以考慮微服務。
微服務架構
微服務架構的關鍵字是拆,將原本糅合在一個應用中的多個功能拆成多個小應用,這些小應用串聯起來組成一個與之前單體架構功能相同的完整應用。具體示意如下:
每個微服務可以獨立執行,它們之間通過網路協議進行互動。每個微服務可以部署多個例項,這樣一來就具備了跟分散式架構相同的效能。單個服務的釋出/部署對其它服務的影響較小,在程式碼上沒有關聯,因此可以頻繁的釋出新版本。複雜性的問題迎刃而解,拆分之後架構邏輯清晰、功能模組職責單一,功能的增加和程式碼的增加也不會影響到整體效率。服務獨立之後,專案就變得語言無關,評價服務可以用 Java 語言來實現也可以用 Golang 語言實現,不再受到語言或者框架的制約,技術創新問題得以緩解。
這是不是很像單一職責原則和介面隔離原則?
分散式和微服務並不是銀彈
從上面的對比來看,似乎分散式架構比單體架構好,微服務架構比分散式架構好,這麼說來微服務架構>分散式架構>單體架構?
這麼理解是不對的,架構需要根據場景和需求選擇,微服務架構和分散式架構看上去很美,但也衍生出了許多新問題。以微服務架構為例:
• 運維成本高,在單體架構時,運維只需要保證 1 個應用正常執行即可,關注的可能只是硬體資源消耗問題。但如果換成微服務架構,應用的數量成百上千,當應用出現問題或者多應用之間協調異常時,運維人員的頭就會變大;
• 分散式系統固有的複雜性,網路分割槽、分散式事務、流量均衡對開發者和運維進行了敲打;
• 介面調整成本高,一個介面的呼叫方可能有很多個,如果設計時沒有遵循開放封閉原則和介面隔離原則,那麼調整的工作量會是非常大的;
• 介面效能受限,原本通過函式呼叫的方式互動,在記憶體中很快就完成了,換成介面後通過網路進行互動,效能明顯下降;
• 重複勞動,雖然有公共模組,但如果語言無關且又要考慮效能(不用介面互動)就需要自己實現一份相同功能的程式碼;
到底用哪種架構,需要根據具體的場景來選擇。如果你的系統複雜度並沒有那麼高、效能追求也沒有那麼高,例如一個日資料量只有幾萬的爬蟲應用,單體架構就足以解決問題,不需要強行做成分散式或者微服務,因為這樣只會增加自己的工作量。
畫好圖
在需要表達關係和邏輯梳理的場景裡,圖永遠比程式碼好。業內流行這麼一句話“程式開發,設計先行”,說的是在開發前,需要對程式進行構思和設計。試想,如果連物件關係和邏輯都說不清楚,寫出的
程式碼會是好程式碼嗎
在構思專案時可以使用用例圖挖掘需求和功能模組;在架構設計時可以使用協作圖梳理模組關係;在設計介面或者類物件時可以使用類圖做好互動計劃;在功能設計時可以使用狀態圖幫助我們挖掘功能
屬性……
瞭解繪圖的重要性之後,具體的繪圖方法和技巧可翻閱本書(《Python 程式設計參考》)的工程師繪圖指南章節展開學習。
起一個好名字
你還記得自己曾經起過的那些名字嗎:
• reversalList
• get_translation
• get_data
• do_trim•CarAbstract
起一個好的、合適的名字能夠讓程式碼風格更統一,看上去清晰瞭然。起一個好名字不單單是單詞語法的問題,還會涉及風格選擇和用途。具體的命名方法和技巧可翻閱本書(《Python 程式設計參考》)的命名選擇與風格指南章節展開學習。
優化巢狀的 if else 程式碼
寫程式碼的時候用一些控制語句是很正常的事,但是如果 if else 巢狀太多,也是非常頭疼的,程式碼看上去就像下面這樣。
這種結構的產生是因為使用了 if 語句來進行先決條件的檢查,如果負責條件則進入下一行程式碼,如果不符合則停止。既然這樣,那我們在先決條件的檢查上進行取反即可,程式碼改動過後看起來像這樣:
if "http" not in url: return if "www" not in url: return
這是我平時常用的優化辦法,後來在張曄老師的付費專欄中看到這種手段的名稱——衛語句。
當然,這種簡單的邏輯處理和 if else 控制流用衛語句進行處理是很有效的,但如果邏輯再複雜一些,用衛語句的效果就不見的那麼好了。假設汽車 4S 店有折扣許可權限制,普通銷售有權對 30 萬以內金額的汽車授予一定折扣,超過 30 萬但在 80 萬以內需要精英銷售進行授權,更高價格的車折扣需要店長授權。這個功能可以歸納為根據金額的大小來確定授權者,對應程式碼如下:
def buying_car(price): if price < 300000: print("普通銷售") elif price < 800000: print("精英銷售") elif price < 1500000: print("店長")
程式碼思路清晰,但存在的問題也明顯。如果以後擴充套件價格和定級,會增加更多的 if else 語句,程式碼將變得臃腫。控制語句的順序是固定在程式碼中的,如果想要調整順序,只能修改控制語句。
那麼問題來了,有比 if else 更合適的辦法嗎?
這時候可以考慮一種叫做責任鏈的設計模式,責任鏈設計模式的定義為:為了避免請求傳送者與多個請求處理者耦合在一起,於是將所有請求的處理者通過前一物件記住其下一個物件的引用而連成一條鏈;當有請求發生時,可將請求沿著這條鏈傳遞,直到有物件處理它為止。
看起來有點繞,我們通過程式碼圖加深理解:
處理類執行前根據先決條件判斷自身的 handler 是否能夠處理,如果不能則交給 next_handler,也就是責任鏈的下一個節點。上面的用責任鏈實現為:
class Manager: def __init__(self,): self.obj = None def next_handler(self, obj): self.obj = obj def handler(self, price): pass class General(Manager): def handler(self, price): if price < 300000: print("{} 普通銷售".format(price)) else: self.obj.handler(price) class Elite(Manager): def handler(self, price): if 300000 <= price < 800000: print("{} 精英銷售".format(price)) else: self.obj.handler(price) class BOSS(Manager): def handler(self, price): if price >= 800000: print("{} 店長".format(price))
建立好抽象類和具體的處理類之後,它們還沒有關聯關係。我們需要將它們掛載到一起,成為一個鏈條:
general = General() elite = Elite() boss = BOSS() general.next_handler(elite) elite.next_handler(boss)
這裡建立的責任鏈順序為 General -> Elite -> BOSS,呼叫方只需要傳遞價格給 General,如果它沒有折扣的授予權它會交給 Elite 處理,如果 Elite 沒有折扣授予權則會交給 BOSS 處理。對應程式碼如下:
prices = [550000, 220000, 1500000, 200000, 330000] for price in prices: general.handler(price)
這跟我們去 4S 店購車是一樣的,作為客戶,我們確定好要買的車即可,至於 4S 店如何申請折扣,誰來授權與我無關,我能拿到相應的折扣即可。
至此,if else 優化知識學習完畢。
做到以上幾點,相信你的程式碼質量和程式設計水平會有一個不錯的提升,加油!