天天看點

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

作者:SuperOps
編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

一、Python中的代碼複雜性

應用程式和其代碼庫的複雜性取決于其所執行的任務。如果您正在為NASA的噴氣推進實驗室編寫代碼,那麼它肯定會非常複雜。

問題不在于“我的代碼是否複雜?”,而在于“我的代碼是否比必要的更複雜?”。

東京的鐵路網絡是世界上最廣泛和最複雜的之一。這部分原因是東京是一個人口超過300萬的大都市,也因為有三個互相重疊的網絡。

這包括了東京的都營地鐵、東京地鐵以及穿越東京市中心的JR東日本線列車。即使對經驗豐富的旅行者來說,在東京市中心導航也可能非常困難。

以下是東京鐵路網絡的地圖,以供參考:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

東京鐵路網絡地圖

如果您的代碼開始看起來有點像這張地圖,那麼這篇文章就适合您。

首先,我們将介紹4個衡量代碼複雜度的名額,它們可以提供一個相對衡量您代碼進展情況的尺度,進而使您的代碼更簡單:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

在了解這些名額之後,您将學習一種用于自動計算這些名額的工具——wily。

用于衡量複雜性的名額

已經投入了大量時間和研究來分析計算機軟體的複雜性。過于複雜和不可維護的應用程式可能會産生非常實際的成本。

軟體的複雜性與品質相關。易于閱讀和了解的代碼将來更有可能被開發人員更新。

以下是程式設計語言的一些名額。它們适用于許多語言,而不僅僅是Python。

代碼行數

LOC或代碼行是衡量複雜性的最粗略的名額。代碼行與應用程式的複雜性之間是否存在任何直接關聯是有争議的,但間接相關性是明确的。畢竟,一個有5行的程式可能比一個有5萬行的程式更簡單。

在檢視 Python 名額時,我們嘗試忽略空白行和包含注釋的行。

在Linux和Mac OS上可以用wc指令計算代碼行數,其中file.py是你要測量的檔案的名稱:

$ wc -l file.py           

如果你想通過遞歸搜尋所有的.py檔案來添加一個檔案夾中的組合行,你可以将wc和find指令結合起來:

$ find . -name \*.py | xargs wc -l           

對于Windows,PowerShell在Measure-Object中提供了字數統計指令,在Get-ChildItem中提供了遞歸檔案搜尋:

PS C:\> Get-ChildItem -Path *.py -Recurse | Measure-Object –Line            

在響應中,你會看到總行數。

為什麼用代碼行來量化應用程式中的代碼量?我們的假設是,一行代碼大緻相當于一條語句。行數是一個比字元更好的衡量标準,後者包括空白。

在Python中,我們被鼓勵在每一行上放一條語句。這個例子是9行代碼:

1x = 5 
 2value = input("Enter a number: ") 
 3y = int(value) 
 4if x < y: 
 5    print(f"{x} is less than {y}") 
 6elif x == y: 
 7    print(f"{x} is equal to {y}") 
 8else: 
 9    print(f"{x} is more than {y}")           

如果你隻用代碼行數作為衡量複雜程度的标準,可能會鼓勵錯誤的行為。

Python代碼應該易于閱讀和了解。以最後一個例子為例,你可以把代碼行數減少到3行:

1x = 5; y = int(input("Enter a number:")) 
 2equality = "is equal to" if x == y else "is less than" if x < y else "is more than" 
 3print(f"{x} {equality} {y}")           

但結果是難以閱讀,PEP 8有圍繞最大行長和斷行的準則。你可以檢視《如何用PEP 8編寫漂亮的Python代碼》,了解更多關于PEP 8的資訊。

這個代碼塊使用了2個Python語言特性,使代碼更短:

  • 複合語句:使用 ;
  • 鍊式條件或三元組語句:name = value if condition else value if condition2 else value2

我們減少了代碼行數,但違反了Python的一個基本規律:

“可讀性很重要”

——Tim Peters,《Zen of Python》

這種縮短的代碼有可能更難維護,因為代碼維護者是人,而這種短代碼更難讀。我們将探讨一些更進階、更有用的複雜度名額。

循環複雜度

循環複雜性是衡量你的應用程式有多少條獨立的代碼路徑。一個路徑是解釋器可以遵循的語句序列,以達到應用程式的終點。

思考循環複雜性和代碼路徑的一種方法是想象你的代碼就像一個鐵路網。

對于一次旅行來說,你可能需要換車才能到達目的地。葡萄牙的裡斯本大都會鐵路系統很簡單,容易浏覽。任何一次旅行的循環複雜性都等于你需要乘坐的線路數量:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

圖檔: 裡斯本地鐵

如果你需要從Alvalade到Anjos,那麼你需要乘坐5個站的綠線(linha verde):

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

圖檔: 裡斯本地鐵

這次旅行的循環複雜性為1,因為你隻坐了一列火車。這是一次簡單的旅行。這列火車在這個比喻中相當于一個代碼分支。

如果你需要從Aeroporto(機場)到Belém區品嘗食物,那麼這就是一個更複雜的旅程。你必須在阿拉米達(Alameda)和Cais do Sodré換車:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

圖檔: 裡斯本地鐵

這次旅行的循環複雜度為3,因為你坐了3趟火車。你最好還是坐計程車吧!

鑒于你不是在浏覽裡斯本,而是在寫代碼,火車線路的變化成為執行中的一個分支,就像一個if語句。

讓我們來探讨這個例子:

x = 1           

這段代碼隻有一種執行方式,是以它的循環複雜性為1。

如果我們在代碼中加入一個決定,或者作為if語句的分支,就會增加複雜性:

x = 1
if x < 2:
    x += 1           

盡管這段代碼隻有一種執行方式,因為x是一個常量,但它的圈複雜度為2。所有圈複雜度分析器都将if語句視為一個分支。

這也是一個過于複雜的代碼示例。由于x具有固定值,if語句實際上是無用的。你可以簡單地重構這個例子,改為以下形式:

x = 2           

這隻是一個玩具例子,現在我們來探索一些更真實的情況。

main() 函數的圈複雜度為5。我會在代碼中注釋每個分支的位置,這樣你就能看到它們在哪裡了:

cyclomatic_example.py
import sys

def main():
    if len(sys.argv) > 1:  # 1
        filepath = sys.argv[1]
    else:
        print("Provide a file path")
        exit(1)    
    if filepath:  # 2   
        with open(filepath) as fp:  # 3        
            for line in fp.readlines():  # 4             
               if line != "\n":  # 5                
                   print(line, end="")
if name == "__main__":  # Ignored.
    main()           

當然,也有一些方法可以将代碼重構為一個更簡單的替代方案。我們稍後會讨論這個問題。

注:循環複雜度是由Thomas J. McCabe, Sr在1976年開發的。你可能會看到它被稱為McCabe度量或McCabe數。

在下面的例子中,我們将使用PyPI的radon庫來計算度量。你現在就可以安裝它:

$ pip install radon           

要用radon計算循環複雜性,你可以把這個例子儲存到一個叫做cyclomatic_example.py的檔案中,然後在指令行中使用radon。

radon指令需要2個主要參數:

  1. 分析的類型(cc代表循環複雜性)
  2. 要分析的檔案或檔案夾的路徑

對cyclomatic_example.py檔案執行帶有cc分析的radon指令。添加-s将在輸出中給出循環複雜度:

$ radon cc cyclomatic_example.py -s
cyclomatic_example.py
    F 4:0 main - B (6)           

輸出的結果有點令人費解。下面是每個部分的意思:

  • F表示函數,M表示方法,C表示類。
  • main是函數的名稱。
  • 4是函數開始的那一行。
  • B是指從A到F的等級。A是最好的等級,意味着最不複雜。
  • 括号裡的數字,6,是代碼的循環複雜性。

Halstead度量

Halstead複雜度名額與一個程式的代碼庫的大小有關。它們是由Maurice H. Halstead在1977年開發的。在Halstead方程中,有4個度量:

  • Operands是變量的值和名稱。
  • Operators是所有的内置關鍵字,如if、else、for或while。
  • Length (N) 是指操作符的數量加上你程式中操作數的數量。
  • Vocabulary (h) 是指程式中獨特的操作符的數量加上獨特的操作數。

然後有3個額外的名額與這些措施:

  • Volume (V)代表長度和詞彙量的乘積。
  • Difficulty (D) 代表一半的獨特操作數和操作數的重複使用的乘積。
  • Effort (E)是總體名額,是數量和難度的乘積。

所有這些都是非常抽象的,是以讓我們用相對的術語來說:

  • 如果你使用大量的運算符和獨特的操作數,你的應用程式的努力是最高的。
  • 如果你使用少量的運算符和較少的變量,你的應用程式的努力程度就比較低。

對于cyclomatic_complexity.py的例子,運算符和操作數都出現在第一行:

import sys  # import (operator), sys (operand)           

import是一個操作符,而sys是子產品的名字,是以是一個操作符。

在一個稍微複雜的例子中,有許多運算符和操作數:

if len(sys.argv) > 1:
    ...           

在這個例子中,有5個運算符:

  1. if
  2. (
  3. )
  4. >
  5. :

此外,有2個操作數:

  1. sys.argv
  2. 1

請注意,radon隻計算操作數的一個子集。例如,括号在任何計算中都被排除在外。

要計算radon中的Halstead度量,你可以運作以下指令:

$ radon hal cyclomatic_example.py
cyclomatic_example.py:
    h1: 3    
    h2: 6    
    N1: 3    
    N2: 6    
    vocabulary: 9    
    length: 9    
    calculated_length: 20.264662506490406    
    volume: 28.529325012980813    
    difficulty: 1.5    
    effort: 42.793987519471216    
    time: 2.377443751081734    
    bugs: 0.009509775004326938           

為什麼Radon給出了時間和bug的度量?

Halstead 提出了一種估算編碼所需時間的方法,即将代碼的工作量(E)除以18,得到用秒表示的時間。

Halstead 還指出,預期的錯誤數量可以通過将代碼的體積(V)除以3000來估算。需要注意的是,這篇論文是在1977年寫的,甚至在 Python 發明之前!是以不要着急去尋找錯誤。

可維護性指數

可維護性指數将McCabe循環複雜度和Halstead容量的衡量标準大緻在0和100之間。

如果你有興趣,原始方程式如下:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

在這個方程中,V 是 Halstead 體積度量,C 是圈複雜度,L 是代碼行數。

如果你對這個方程和我剛開始看到它時一樣迷惑不解,我來解釋一下:它計算了一個綜合考慮變量數量、操作數量、決策路徑數量和代碼行數的值。

這個名額在許多工具和程式設計語言中被廣泛使用,是以它是比較标準的度量名額之一。但是,對方程式進行了許多修訂,是以具體的數值不能被視為事實。radon、wily 和 Visual Studio 将結果限制在0到100之間。

在可維護性名額的範圍内,你隻需要注意當你的代碼明顯降低(接近0)時。名額認為低于25的值很難維護,而高于75的值易于維護。可維護性名額也被稱為 MI。

可維護性名額可以用作衡量應用程式目前可維護性的名額,并且可以檢視重構過程中是否取得進展。

要從 radon 計算可維護性名額,請執行以下指令:

$ radon mi cyclomatic_example.py -s
cyclomatic_example.py - A (87.42)           

在這個結果中,A 是 radon 給數值 87.42 應用的一個等級。在這個等級中,A 表示最易維護,而 F 表示最不易維護。

使用 wily 來捕獲和跟蹤項目的複雜性

wily 是一個用于收集代碼複雜度名額的開源軟體項目,包括我們之前介紹過的 Halstead、Cyclomatic 和 LOC 等名額。wily 可以與 Git 內建,可以自動收集各個 Git 分支和修訂版本的名額。

wily 的目的是讓你能夠看到代碼複雜度随時間的變化和趨勢。如果你想要調優一輛汽車或者提高自己的健康狀況,你會從測量一個基準開始,然後追蹤改進的情況。

安裝 wily

wily 在 PyPI 上可擷取,并且可以使用 pip 安裝:

$ pip install wily           

一旦安裝了 wily,你就可以在指令行中使用以下一些指令:

  • wily build:周遊 Git 曆史并分析每個檔案的名額
  • wily report:檢視給定檔案或檔案夾的名額曆史趨勢
  • wily graph:将一組名額繪制成 HTML 檔案中的圖表

建構緩存

在使用 wily 之前,你需要對項目進行分析。這可以通過使用 wily build 指令來完成。

在本文的這一部分中,我們将分析非常流行的 requests 包,該包用于與 HTTP API 進行通信。由于這個項目是開源的,并且在GitHub上可擷取,我們可以輕松通路和下載下傳源代碼的副本:

$ git clone https://github.com/requests/requests
$ cd requests
$ ls
AUTHORS.rst        CONTRIBUTING.md    LICENSE            Makefile
Pipfile.lock       _appveyor          docs               pytest.ini
setup.cfg          tests              CODE_OF_CONDUCT.md HISTORY.md
MANIFEST.in        Pipfile            README.md          appveyor.yml
ext                requests           setup.py           tox.ini           

注意:Windows 使用者應該使用 PowerShell 指令提示符來替代傳統的 MS-DOS 指令行執行以下示例。要啟動 PowerShell CLI,請按下 Win+R 鍵,然後輸入 powershell 後按 Enter 鍵。

這裡會顯示一些檔案夾,包括用于測試、文檔和配置的檔案夾。我們隻對 requests Python 包的源代碼感興趣,它位于一個名為 requests 的檔案夾中。

從克隆的源代碼中調用 wily build 指令,并将源代碼檔案夾的名稱作為第一個參數提供:

$ wily build requests           

這将需要幾分鐘的時間來分析,這取決于你的電腦有多少CPU能力:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

在分析 requests 的源代碼之後,您可以查詢任何檔案或檔案夾以檢視關鍵名額。在本教程的早些時候,我們讨論了以下内容:

  • 代碼行數
  • 可維護性指數
  • 圈複雜度

這些是 wily 中的三個預設名額。要檢視特定檔案(比如 requests/api.py)的這些名額,請運作以下指令:

$ wily report requests/api.py           

wily 将按照日期的倒序列印一個包含預設名額的表格報告。最新的送出将顯示在頂部,最舊的送出将顯示在底部:

Revision Author Date MI Lines of Code Cyclomatic Complexity
f37daf2 Nate Prewitt 2019/1/13 100 (0.0) 158 (0) 9 (0)
6dd410f Ofek Lev 2019/1/13 100 (0.0) 158 (0) 9 (0)
5c1f72e Nate Prewitt 2018/12/14 100 (0.0) 158 (0) 9 (0)
c4d7680 Matthieu Moy 2018/12/14 100 (0.0) 158 (0) 9 (0)
c452e3b Nate Prewitt 2018/12/11 100 (0.0) 158 (0) 9 (0)
5a1e738 Nate Prewitt 2018/12/10 100 (0.0) 158 (0) 9 (0)

這告訴我們 requests/api.py 檔案具有:

  • 158 行代碼
  • 完美的可維護性指數為 100
  • 複雜度為 9

要檢視其他名額,您首先需要知道它們的名稱。您可以通過運作以下指令來檢視:

$ wily list-metrics           

您将看到一列運算符、分析代碼的子產品以及它們提供的名額。

要在報告指令中查詢其他名額,請在檔案名後面添加其名稱。您可以添加任意多個名額。以下是一個示例,添加了"可維護性等級"和"源代碼行數":

$ wily report requests/api.py maintainability.rank raw.sloc           

圖形化名額

現在,您将看到報告表格中有兩個不同的列顯示其他名額的結果。

現在您已經知道了名額的名稱以及如何在指令行中查詢它們,您還可以将它們可視化為圖表。wily 支援使用 HTML 和互動式圖表顯示,與報告指令類似的界面:

$ wily graph requests/sessions.py maintainability.mi           

您的預設浏覽器将打開一個互動式圖表,類似于這樣的截圖:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

Wily graph 指令的截圖

您可以懸停在特定的資料點上,它會顯示 Git 送出資訊以及資料。

如果您想儲存 HTML 檔案到檔案夾或存儲庫中,可以添加 -o 标志并指定路徑和檔案名:

$ wily graph requests/sessions.py maintainability.mi -o my_report.html           

現在将有一個名為 my_report.html 的檔案,您可以與他人共享。該指令非常适合團隊儀表盤使用。

wily可以作為預送出鈎子(pre-commit Hook)使用

您可以将 wily 配置為在送出項目更改之前提醒您代碼複雜性的改進或退化。

wily 提供了 wily diff 指令,用于比較最後一次索引資料與目前工作副本檔案的差異。

要運作 wily diff 指令,請提供您已更改的檔案名。例如,如果我對 requests/api.py 進行了一些更改,通過運作帶有檔案路徑的 wily diff 指令,您将看到其對名額的影響:

$ wily diff requests/api.py           

在響應中,您将看到所有更改的名額,以及循環複雜度發生變化的函數或類:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

可以将 diff 指令與 pre-commit 工具配對使用。pre-commit 将一個鈎子插入到您的 Git 配置中,每次運作 git commit 指令時都會調用一個腳本。

要安裝 pre-commit,可以從 PyPI 進行安裝:

$ pip install pre-commit           

在項目根目錄下的 .pre-commit-config.yaml 檔案中添加以下内容:

repos:
-   repo: local
    hooks:
    -   id: wily
        name: wily
        entry: wily diff
        verbose: true
        language: python
        additional_dependencies: [wily]           

完成以上設定後,運作 pre-commit install 指令來進行最後确認:

$ pre-commit install           

每當運作 git commit 指令時,它将調用 wily diff 并附帶您已添加到暫存更改清單中的檔案。

wily 是一個有用的工具,可以為您的代碼建立基準複雜性,并在您開始重構時測量您所做的改進。

二、Python中的重構

重構是一種技術,可以更改應用程式(無論是代碼還是架構),以使其在外部表現相同,但内部有所改進。這些改進可以包括穩定性、性能或減少複雜性。

世界上最古老的地下鐵路之一——倫敦地鐵,始于1863年開通的大都會線。當時它采用瓦斯照明的木制車廂,由蒸汽機車牽引。在鐵路開通時,它是符合目的的。1900年引入了電氣化鐵路的發明。

到1908年,倫敦地鐵擴充到8條鐵路。二戰期間,倫敦地鐵站被關閉以供列車使用,并用作防空洞。現代倫敦地鐵每天運送數百萬乘客,擁有270多個車站:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

聯合倫敦地鐵鐵路地圖,約1908年(圖檔來源:維基百科)

第一次編寫完美的代碼幾乎是不可能的,而且需求經常變化。如果您請原始鐵路設計者為2020年每天1000萬乘客設計一個網絡,他們不會設計出現在今天的網絡。

相反,鐵路經曆了一系列持續的變化,以優化其營運、設計和布局,并适應城市的變化。它已經進行了重構。

在本節中,您将通過利用測試和工具來安全地進行重構。您還将了解如何在Visual Studio Code和PyCharm中使用重構功能:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

利用工具和測試來避免重構風險

如果重構的目的是改進應用程式的内部而不影響外部,那麼如何確定外部沒有發生變化呢?

在進行重大重構項目之前,您需要確定應用程式擁有一個可靠的測試套件。理想情況下,這個測試套件應該是大部分自動化的,這樣當您進行更改時,您可以快速看到對使用者的影響并迅速解決。

如果您想深入了解Python中的測試,可以從《開始使用Python進行測試》開始學習。

對于應用程式來說,并沒有完美的測試數量。但是,測試套件越健壯和全面,您就越能夠積極地重構代碼。

在進行重構時,您将執行兩個最常見的任務:

  • 重命名子產品、函數、類和方法
  • 查找函數、類和方法的使用情況,以了解它們在何處被調用

您可以手動使用搜尋和替換來完成這些操作,但這既耗時又有風險。相反,有一些很好的工具可以執行這些任務。

使用rope進行重構

rope是一個用于重構Python代碼的免費工具。它提供了豐富的API,用于重構和重命名Python代碼庫中的元件。

可以通過兩種方式使用rope:

  1. 使用編輯器插件,如Visual Studio Code、Emacs或Vim。
  2. 直接編寫腳本來重構應用程式。

要使用 rope 作為一個庫,首先通過執行 pip 安裝 rope:

$ pip install rope           

在REPL上用繩索工作是很有用的,這樣你就可以探索項目并實時看到變化。要開始,導入項目類型,并用項目的路徑将其執行個體化:

>>> from rope.base.project import Project

>>> proj = Project('requests')           

現在,proj變量可以執行一系列指令,比如get_files和get_file,以擷取特定的檔案。擷取api.py檔案并将其指派給一個名為api的變量:

>>> [f.name for f in proj.get_files()]
['structures.py', 'status_codes.py',...,'api.py','cookies.py']

>>> api = proj.get_file('api.py')           

如果您想要重命名此檔案,可以直接在檔案系統上對其進行重命名。然而,您項目中導入了舊名稱的任何其他Python檔案現在都會失效。讓我們将api.py重命名為new_api.py:

>>> from rope.refactor.rename import Rename

>>> change = Rename(proj, api).get_changes('new_api')

>>> proj.do(change)           

運作git status指令,您會看到rope對存儲庫進行了一些更改:

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   requests/__init__.py
    deleted:    requests/api.py

Untracked files:
  (use "git add <file>..." to include in what will be committed)

    requests/.ropeproject/
    requests/new_api.py

no changes added to commit (use "git add" and/or "git commit -a")           

由rope進行的三個更改如下:

  • 删除了requests/api.py,并建立了requests/new_api.py
  • 修改了requests/__init__.py,以從new_api而不是api進行導入
  • 建立了一個名為.ropeproject的項目檔案夾

要撤消更改,請運作git reset指令。

使用rope可以進行其他數百種重構操作。

使用Visual Studio Code進行重構

Visual Studio Code通過自己的使用者界面打開了rope中可用的一小部分重構指令。

你可以:

  1. 從語句中提取變量
  2. 從代碼塊中提取方法
  3. 将導入語句按照邏輯順序排序

以下是使用指令面闆中的"Extract methods"指令的示例:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

使用PyCharm進行重構

如果您正在使用或考慮使用PyCharm作為Python編輯器,那麼它強大的重構功能值得注意。

您可以使用Windows和macOS上的Ctrl+T快捷鍵通路所有的重構快捷方式。在Linux中,通路重構的快捷鍵是Ctrl+Shift+Alt+T。

查找函數和類的調用者和使用情況。

在删除方法或類或更改其行為之前,您需要知道哪些代碼依賴于它。PyCharm可以搜尋項目中方法、函數或類的所有使用情況。

要通路此功能,請通過右鍵單擊選擇一個方法、類或變量,然後選擇“Find Usages”:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

符合您搜尋條件的所有代碼都顯示在底部的一個面闆中。您可以輕按兩下任何項目,直接導航到相關行。

使用PyCharm的重構工具

其他一些重構指令包括:

  • 從現有代碼中提取方法、變量和常量
  • 從現有類簽名中提取抽象類,包括指定抽象方法的能力
  • 對變量、方法、檔案、類或子產品進行重命名

以下是使用rope子產品将之前重命名為new_api.py的api.py子產品重命名的示例:

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

重命名指令是根據UI上下文進行的,使得重構變得快速和簡單。它已經自動更新了__init__.py中的導入語句,替換為新的子產品名。

另一個有用的重構是修改簽名指令。它可以用于添加、删除或重命名函數或方法的參數。它将搜尋用法并為您更新它們。

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

你可以設定預設值,也可以決定重構應該如何處理新的參數。

總結

重構是任何開發人員必備的重要技能。正如您在本章中所學到的,您并不孤單。工具和內建開發環境已經配備了強大的重構功能,可以快速進行變更。

三、複雜性反模式

現在您已經了解了如何衡量複雜性、如何測量它以及如何重構代碼,是時候學習5種常見的反模式,這些反模式使代碼比需要的更複雜。

編寫更多 pythonic 代碼(九)——重構Python應用程式以提升簡潔性

如果您能掌握這些模式并知道如何重構它們,您很快就能夠開發更易維護的 Python 應用程式。

  1. 應該變為對象的函數

Python 支援使用函數進行過程化程式設計,還支援可繼承的類。這兩種方法都非常強大,應用于不同的問題上。

以處理圖像的子產品為例,下面的代碼示例已删除了函數中的邏輯部分:

imagelib.py

def load_image(path):
    with open(path, "rb") as file:
        fb = file.load()
    image = img_lib.parse(fb)
    return image

def crop_image(image, width, height):
    ...
    return image

def get_image_thumbnail(image, resolution=100):
    ...
    return image           

該設計存在一些問題:

  1. 無法清楚地判斷 crop_image() 和 get_image_thumbnail() 是否修改原始的 image 變量或者建立新的圖像。如果你想加載一張圖像,然後建立裁剪和縮略圖,是否需要先複制該執行個體?你可以查閱函數的源代碼,但不能指望每個開發人員都會這樣做。
  2. 在每次調用圖像函數時,需要将 image 變量作為參數傳遞。

以下是調用代碼的樣例:

from imagelib import load_image, crop_image, get_image_thumbnail

image = load_image('~/face.jpg')
image = crop_image(image, 400, 500)
thumb = get_image_thumbnail(image)           

這裡有一些使用函數的代碼症狀,可以重構為類:

  • 各個函數的參數相似
  • 更多的Halstead h2唯一操作數
  • 混合了可變和不可變的函數
  • 函數分布在多個Python檔案中

下面是這3個函數的重構版本,其中發生了以下情況:

  • .__init__() 替換了 load_image()。
  • crop() 成為一個類方法。
  • get_image_thumbnail()變成了一個屬性。

縮略圖的分辨率變成了一個類屬性,是以它可以在全局或在那個特定的執行個體上被改變:

imagelib.py

class Image(object):
    thumbnail_resolution = 100
    def __init__(self, path):
        ...

    def crop(self, width, height):
        ...

    @property
    def thumbnail(self):
        ...
        return thumb           

如果代碼中還有更多與圖像相關的函數,将其重構為類可能會帶來巨大的變化。接下來要考慮的是消費該代碼的複雜性。

以下是經過重構的示例代碼:

from imagelib import Image

image = Image('~/face.jpg')
image.crop(400, 500)
thumb = image.thumbnail           

在重構後的代碼中,我們解決了最初的問題:

  • 由于 thumbnail 是一個屬性,它清楚地傳回了縮略圖,并且不會修改執行個體。
  • 代碼不再需要建立用于裁剪操作的新變量。
  1. 應該成為函數的對象

有時候,反過來也是對的。一些面向對象的代碼更适合作為一個或兩個簡單函數。

以下是一些提示錯誤使用類的迹象:

  1. 隻有一個方法(除了.__init__())
  2. 隻包含靜态方法的類

接下來以一個身份驗證類的例子來說明:

# authenticate.py

class Authenticator(object):
    def __init__(self, username, password):
        self.username = username
        self.password = password

    def authenticate(self):
        ...
        return result           

如果将其改寫為一個名為authenticate()的簡單函數,并将使用者名和密碼作為參數傳入會更有意義:

# authenticate.py

def authenticate(username, password):
    ...
    return result           

你不必手動查找符合這些條件的類:pylint提供了一個規則,即類應該至少有2個公共方法。關于PyLint和其他代碼品質工具的更多内容,可以檢視《Python Code Quality and Writing Cleaner Python Code With PyLint》。

要安裝pylint,請在控制台中運作以下指令:

$ pip install pylint           

pylint接受一些可選參數,後跟一個或多個檔案和檔案夾的路徑。如果你使用預設設定運作pylint,它會輸出大量資訊,因為pylint有很多規則。相反,你可以隻運作特定的規則。過少的公共方法規則的辨別符是R0903。你可以在文檔網站上查找這個規則:

$ pylint --disable=all --enable=R0903 requests           

輸出告訴我們,auth.py包含兩個隻有一個公共方法的類。這兩個類分别位于72行和100行。models.py的第60行也有一個隻有一個公共方法的類。

  1. 轉換“三角形”代碼為扁平化代碼

如果你放大源代碼并将頭向右旋轉90度,空白是否像荷蘭一樣扁平還是像喜馬拉雅山一樣多崎岖?崎岖的代碼表明你的代碼包含了很多嵌套。

以下是Python之禅中的一個原則:

“扁平優于嵌套”

— Tim Peters,《Python之禅》

為什麼扁平化的代碼比嵌套的代碼更好呢?因為嵌套的代碼使得閱讀和了解發生變得更困難。讀者在通過分支時必須了解和記住各種條件。

以下是高度嵌套代碼的症狀:

  1. 由于代碼分支數目的增加,導緻圈複雜度較高。
  2. 由于圈複雜度相對于代碼行數較高,導緻可維護性指數較低。

以下面這個例子來研究比對單詞"error"的字元串參數的函數。它首先檢查資料參數是否為清單,然後周遊每個元素并檢查其是否為字元串。如果是字元串且值為"error",則傳回True,否則傳回False:

def contains_errors(data):
    if isinstance(data, list):
        for item in data:
            if isinstance(item, str):
                if item == "error":
                    return True
    return False           

這個函數的可維護性指數較低,因為它很簡小,但圈複雜度卻很高。

相反,我們可以通過提前傳回("return early")來重構此函數,以消除一層嵌套,并在data的值不是清單時傳回False。然後,可以在清單對象上使用.count()方法來計算"error"出現的次數。然後傳回一個判斷條件,即.count()大于零:

def contains_errors(data):
    if not isinstance(data, list):
        return False
    return data.count("error") > 0           

另一種減少嵌套的技術是利用清單推導式。清單推導式是一種常見模式,用于建立一個新清單,周遊清單中每個元素以檢視是否滿足某個條件,然後将所有符合條件的元素添加到新清單中:

results = []
for item in iterable:
    if item == match:
        results.append(item)           

可以用更快、更高效的清單推導式來替換這段代碼:

results = [item for item in iterable if item == match]           

這個新例子更加簡潔,複雜度更低,性能更好。

如果你的資料不是一維清單,則可以利用标準庫中的itertools包,其中包含從資料結建構立疊代器的函數。你可以用它來串聯疊代器,映射結構,在現有疊代器上進行循環或重複。

itertools還包含用于過濾資料的函數,例如filterfalse()。有關itertools的更多資訊,請查閱《Python 3中的itertools》,示例解析。

  1. 使用查詢工具處理複雜的字典

Python中最強大且廣泛使用的核心類型之一就是字典。它快速、高效、可伸縮且非常靈活。

如果你對字典不熟悉,或者覺得自己可以更好地利用它們,你可以閱讀有關Python中的字典的更多資訊。

然而,字典有一個主要的副作用:當字典嵌套層級很深時,查詢字典的代碼也變得複雜起來。

以前面你看到的東京地鐵線路示例資料為例:

data = {
    "network": {
        "lines": [
            {
                "name.en": "Ginza",
                "name.jp": "銀座線",
                "color": "orange",
                "number": 3,
                "sign": "G"
            },
            {
                "name.en": "Marunouchi",
                "name.jp": "丸ノ内線",
                "color": "red",
                "number": 4,
                "sign": "M"
            }
        ]
    }
}           

如果你想擷取與特定編号比對的線路,可以建立一個小的函數來實作:

def find_line_by_number(data, number):
    matches = [line for line in data if line['number'] == number]
    if len(matches) > 0:
        return matches[0]
    else:
        raise ValueError(f"Line {number} does not exist.")           

盡管這個函數本身很小,但由于資料嵌套得太深,調用該函數變得複雜起來:

>>> find_line_by_number(data["network"]["lines"], 3)           

有些第三方工具可以用于在Python中查詢字典,其中一些最受歡迎的是JMESPath、glom、asq和flupy。

JMESPath可以幫助我們處理火車網絡。JMESPath是為JSON設計的查詢語言,有一個可用于Python的插件,可以與Python字典一起使用。要安裝JMESPath,請執行以下操作:

$ pip install jmespath           

然後打開Python REPL,導入jmespath并使用查詢字元串作為第一個參數,資料作為第二個參數調用search()函數探索JMESPath API。查詢字元串"network.lines"表示傳回data['network']['lines']:

>>> import jmespath

>>> jmespath.search("network.lines", data)
[{'name.en': 'Ginza', 'name.jp': '銀座線',  
'color': 'orange', 'number': 3, 'sign': 'G'}, 
{'name.en': 'Marunouchi', 'name.jp': '丸ノ内線',  
'color': 'red', 'number': 4, 'sign': 'M'}]           

當使用清單時,你可以使用方括号并提供内部查詢。通配符查詢是 * 。然後你可以在每個比對的項中添加屬性名稱來傳回結果。如果你想擷取每條線路的線路編号,可以這樣做:

>>> jmespath.search("network.lines[*].number", data)
[3, 4]           

您可以提供更複雜的查詢,比如使用 a == 或 <。這種文法對于Python開發者來說有點不尋常,是以在需要時請随手查閱文檔作為參考。

如果我們想要找到編号為3的線路,可以通過一個單一的查詢完成:

>>> jmespath.search("network.lines[?number==`3`]", data)
[{'name.en': 'Ginza', 'name.jp': '銀座線', 'color': 'orange', 'number': 3, 'sign': 'G'}]           

如果我們想要擷取該線路的顔色,可以在查詢的末尾添加屬性:

>>> jmespath.search("network.lines[?number==`3`].color", data)
['orange']           

JMESPath可用于簡化處理複雜字典的查詢和搜尋的代碼。

  1. 使用attrs和dataclasses簡化代碼

在重構時,另一個目标是盡可能減少代碼庫中的代碼量,同時實作相同的功能。到目前為止展示的技術可以幫助将代碼重構為更小、更簡單的子產品。

其他一些技術需要對标準庫和第三方庫有一定的了解。

什麼是樣闆代碼?

樣闆代碼是在許多地方使用并且幾乎沒有或沒有任何修改的代碼。

以我們的火車網絡為例,如果我們要使用Python類和Python 3類型提示将其轉換為類型化的代碼,可能會像這樣:

from typing import List

class Line(object):
    def __init__(self, name_en: str, name_jp: str, color: str, number: int, sign: str):
        self.name_en = name_en
        self.name_jp = name_jp
        self.color = color
        self.number = number
        self.sign = sign

    def __repr__(self):
        return f"<Line {self.name_en} color='{self.color}' number={self.number} sign='{self.sign}'>"

    def __str__(self):
        return f"The {self.name_en} line"

class Network(object):
    def __init__(self, lines: List[Line]):
        self._lines = lines

    @property
    def lines(self) -> List[Line]:
        return self._lines           

現在,您可能還想添加其他魔術方法,比如.__eq__()。這段代碼是樣闆代碼。這裡沒有業務邏輯或任何其他功能:我們隻是将資料從一個地方複制到另一個地方。

一個資料類的案例

在Python 3.7中引入了dataclasses子產品,并且在PyPI上提供了Python 3.6的後向相容包,dataclasses子產品可以幫助消除許多樣闆代碼,尤其是那些僅用于存儲資料的類。

要将上面的Line類轉換為資料類,隻需将所有字段轉換為類屬性,并確定它們具有類型注解:

from dataclasses import dataclass

@dataclass
class Line(object):
    name_en: str
    name_jp: str
    color: str
    number: int
    sign: str           

然後,您可以像之前一樣使用相同的參數和字段建立Line類型的執行個體,甚至還實作了. __str__(),.__repr__()和.__eq__():

>>> line = Line('Marunouchi', "丸ノ内線", "red", 4, "M")

>>> line.color
'red'

>>> line2 = Line('Marunouchi', "丸ノ内線", "red", 4, "M")

>>> line == line2
True           

資料類是通過單個導入就可以減少代碼量的絕佳方法,并且已經包含在标準庫中。如果希望進行全面的詳細介紹,您可以查閱《Python 3.7資料類的終極指南》。

一些attrs的使用案例

attrs是一個比dataclasses更久的第三方包。attrs具有更多的功能,并且可以在Python 2.7和3.4+上使用。

如果您正在使用Python 3.5或更低版本,attrs是dataclasses的一個很好的替代方案。而且,它提供了許多其他功能。

在attrs中,與dataclasses相當的示例看起來類似。不同之處在于,類屬性被指派為attrib()函數傳回的值,而不是使用類型注解。attrib()函數可以接受其他參數,例如預設值和用于驗證輸入的回調函數:

from attr import attrs, attrib

@attrs
class Line(object):
    name_en = attrib()
    name_jp = attrib()
    color = attrib()
    number = attrib()
    sign = attrib()           

attrs可以是一個有用的軟體包,用于消除資料類中的樣闆代碼并進行輸入驗證。

四、結論

現在,您已經學會了如何識别和解決複雜的代碼問題,請回顧一下以下步驟,使您的應用程式更易于修改和管理:

首先,使用像wily這樣的工具建立項目的基準線。

檢視一些名額,并從具有最低可維護性指數的子產品開始。

利用測試提供的安全性和像PyCharm和rope這樣的工具的知識對該子產品進行重構。

一旦按照本文中的步驟和最佳實踐進行操作,您可以對應用程式進行其他令人興奮的事情,比如添加新功能和提高性能。