返回文章列表

Python 特殊方法與可呼叫物件設計

本文探討 Python 特殊方法與可呼叫物件的設計技巧,包含 `__getattr__` 和 `__call__` 的應用場景與注意事項,並解析可變預設引數的陷阱及正確用法。同時,文章也介紹了非同步程式設計的基礎概念、協程語法以及 asyncio

Python 軟體設計

Python 的特殊方法允許開發者自定義物件行為,例如使用 __getattr__ 動態存取屬性,以及使用 __call__ 建立可呼叫物件。然而,使用可變物件作為函式預設引數時,需要留意潛在的陷阱,建議使用 None 作為預設值,並在函式內初始化。非同步程式設計在 I/O 密集型應用中至關重要,Python 的 asyncawait 語法簡化了協程的定義和使用,asyncio 模組提供了事件迴圈管理功能。軟體設計應遵循契約式設計和防禦式程式設計原則,前者透過前置條件和後置條件確保元件間的互動符合預期,後者則預防潛在的錯誤和異常,提升軟體的可靠性和安全性。

Python 中的特殊方法與可呼叫物件

在 Python 中,特殊方法(Magic Methods)是用於實作特定功能的特殊函式,例如 __getattr____call__ 等。這些方法可以讓我們自定義物件的行為,使其更符合 Python 的慣用語法。

動態屬性存取:__getattr__

__getattr__ 方法用於在物件中動態存取屬性。當我們嘗試存取一個不存在的屬性時,Python 就會呼叫這個方法。在某些情況下,這可以避免撰寫大量的重複程式碼。

使用 __getattr__ 的時機

當我們發現有機會避免撰寫大量重複程式碼時,可以使用 __getattr__。然而,過度使用這個方法可能會使程式碼變得難以理解和維護。

可呼叫物件:__call__

__call__ 方法允許我們將物件當作函式一樣呼叫。當我們呼叫一個物件時,Python 就會執行這個方法。可呼叫物件可以用於建立具有內部狀態的函式,這些函式可以在多次呼叫之間保持狀態。

使用 __call__ 的範例

以下是一個使用 __call__ 方法的範例:

from collections import defaultdict

class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

內容解密:

  1. 類別定義:定義了一個名為 CallCount 的類別,用於計算每個引數被呼叫的次數。
  2. __init__ 方法:初始化 _counts 字典,用於儲存每個引數的計數。
  3. __call__ 方法:當物件被呼叫時,更新 _counts 字典中對應引數的計數,並傳回該計數。
  4. 使用範例:建立 CallCount 物件,並多次呼叫它,觀察不同引數的計數變化。

Python 中的注意事項

雖然 Python 提供了許多強大的功能,但也存在一些需要注意的事項。瞭解這些注意事項可以幫助我們避免潛在的問題,寫出更好的程式碼。

避免反模式

在某些情況下,某些 Python 的慣用語法可能會導致問題。因此,瞭解如何避免這些反模式對於寫出好的程式碼至關重要。

Python 函式與可變預設引數的陷阱

在 Python 中,函式是一種非常重要的程式結構,能夠封裝程式碼、提高重用性並簡化程式設計。然而,在使用函式時,有一些細節需要特別注意,尤其是關於可變預設引數的使用。

可變預設引數的問題

在 Python 中,函式的預設引數是在函式定義時被評估的,而不是在函式被呼叫時。這意味著,如果使用可變物件(如列表、字典等)作為預設引數,那麼這個物件只會被建立一次,並且在後續的函式呼叫中被重複使用。

考慮以下錯誤的函式定義:

def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")
    return f"{name} ({age})"

內容解密:

  1. 函式定義時建立預設字典:當 Python 直譯器解析這個函式定義時,會建立一個字典 {"name": "John", "age": 30} 並將其指定給 user_metadata 引數的預設值。
  2. 函式內部修改字典:在函式內部,user_metadata.pop("name")user_metadata.pop("age") 修改了這個字典,導致鍵值被移除。
  3. 重複呼叫導致錯誤:第一次呼叫 wrong_user_display() 時,函式正確執行並傳回 'John (30)'。然而,當再次呼叫 wrong_user_display() 時,由於 user_metadata 仍然指向第一次呼叫時被修改的字典(即已經沒有 "name""age" 鍵),因此會引發 KeyError

正確的做法

為瞭解決這個問題,我們可以使用 None 作為預設值,並在函式內部檢查是否需要初始化 user_metadata

def user_display(user_metadata: dict = None):
    user_metadata = user_metadata or {"name": "John", "age": 30}
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")
    return f"{name} ({age})"

內容解密:

  1. 使用 None 作為預設值:這樣可以避免在函式定義時就建立可變物件。
  2. 函式內部初始化:如果 user_metadataNone,則指定為預設字典,確保每次呼叫函式時都有一個新的字典可以使用。

擴充套件內建型別的注意事項

另一個常見的陷阱是直接從內建型別(如 listdict)繼承。雖然這樣做看似方便,但由於 CPython 的實作細節,某些方法可能不會按預期相互呼叫。例如,直接繼承 list 並重寫 __getitem__ 方法後,迭代該物件時不會呼叫這個方法。

BadList 範例

class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

內容解密:

  1. __getitem__ 方法被正確呼叫:當使用索引存取 BadList 物件的元素時,重寫的 __getitem__ 方法能夠正確執行。
  2. 迭代時出現問題:然而,當嘗試對 BadList 物件進行迭代(如使用 join 方法)時,重寫的 __getitem__ 方法並未被呼叫,導致型別錯誤。

正確擴充套件內建型別的方法

為了避免上述問題,應該使用 collections 模組中的類別,如 UserListUserDictUserString。這些類別提供了更穩健和可移植的介面。

GoodList 範例

from collections import UserList

class GoodList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

內容解密:

  1. 正確繼承 UserList:透過繼承 UserList,我們確保了自訂列表類別能夠正確地處理索引存取和迭代。
  2. __getitem__ 方法正確應用於迭代:現在,當對 GoodList 物件進行迭代時,重寫的 __getitem__ 方法能夠正確地被呼叫並傳回預期的字串。

非同步程式設計簡介

隨著非同步程式碼的日益流行,我們必須能夠讀懂並理解它,因為能夠閱讀非同步程式碼非常重要。

非同步程式碼簡介

非同步程式設計與乾淨程式碼無直接關係,因此本文介紹的Python特性不會使程式碼函式庫更容易維護。本文主要介紹Python中與協程(coroutines)相關的語法,因為它們可能對讀者有所幫助,並且在後續章節中可能會出現協程的例子。

非同步程式設計的核心思想是讓程式碼的一部分能夠暫停,以便其他部分能夠執行。通常,當我們執行I/O操作時,我們希望保持程式碼執行,並在這段時間內利用CPU執行其他任務。

非同步程式設計模型

這改變了程式設計模型。我們不再同步進行呼叫,而是以事件迴圈(event loop)呼叫我們的程式碼的方式來編寫程式碼,事件迴圈負責排程協程在同一個程式和執行緒中執行。

我們的想法是建立一系列協程,並將它們新增到事件迴圈中。當事件迴圈啟動時,它會從已有的協程中挑選並排程它們執行。當我們的某個協程需要執行I/O操作時,我們可以觸發它並向事件迴圈發出訊號,以便它接管控制權並排程另一個協程,同時保持該操作的執行。事件迴圈會在某個時候從上次停止的地方還原我們的協程,並從那裡繼續執行。

非同步程式設計的優勢

請記住,非同步程式設計的優勢在於不阻塞I/O操作。這意味著程式碼可以在I/O操作進行時跳轉到其他任務,然後再傳回,但這並不意味著有多個程式同時執行。執行模型仍然是單執行緒的。

Python中的非同步程式設計

為了在Python中實作這一點,曾經有(並且仍然有)很多可用的框架。但在舊版本的Python中,沒有特定的語法允許這樣做,因此框架的工作方式有點複雜,或者乍一看並不明顯。從Python 3.5開始,為宣告協程增加了特定的語法,這改變了我們在Python中編寫非同步程式碼的方式。在此之前不久,標準函式庫中引入了一個預設的事件迴圈模組asyncio。有了這兩個Python里程碑,進行非同步程式設計就變得更加容易了。

使用asyncio進行非同步處理

雖然本文使用asyncio作為非同步處理的模組,但這並不是唯一的一個。你可以使用任何函式庫(除了標準函式庫之外還有很多可用的函式庫,例如triocurio)編寫非同步程式碼。Python提供的用於編寫協程的語法可以被視為一個API。只要你選擇的函式庫符合該API,你就應該能夠使用它,而無需更改你的協程宣告方式。

協程語法

與非同步程式設計相比,語法上的差異在於協程就像函式,但它們是用async def在其名稱前定義的。當在協程內部並且想要呼叫另一個協程(可以是我們自己的,也可以是在第三方函式庫中定義的)時,通常會在呼叫前使用await關鍵字。當呼叫await時,這會向事件迴圈發出訊號以重新獲得控制權。此時,事件迴圈將還原其執行,而協程將留在那裡等待其非阻塞操作繼續進行,同時另一部分程式碼將執行(事件迴圈將呼叫另一個協程)。在某個時候,事件迴圈將再次呼叫我們的原始協程,而這個協程將從它離開的地方(即await陳述式之後的行)還原執行。

定義協程

我們可能在程式碼中定義的一個典型協程具有以下結構:

async def mycoro(*args, **kwargs):
    # ... 邏輯
    await third_party.coroutine(...)
    # ... 更多邏輯

內容解密:

  1. async def mycoro(*args, **kwargs): 定義了一個名為 mycoro 的協程函式,接受任意數量的引數。
  2. mycoro 內部,可以編寫邏輯來執行某些操作。
  3. await third_party.coroutine(...) 呼叫了另一個協程,並等待其完成。在此期間,控制權被交還給事件迴圈。
  4. 事件迴圈會排程其他協程執行,直到 third_party.coroutine(...) 完成。
  5. 一旦 third_party.coroutine(...) 完成,mycoro 將從 await 之後繼續執行。

如前所述,定義協程有新的語法。與常規函式不同,當我們呼叫這個定義時,它不會執行其中的程式碼。相反,它會建立一個協程物件。這個物件將被包含在事件迴圈中,並且在某個時候必須被等待(否則定義內的程式碼永遠不會執行):

result = await mycoro(...)  # 正確的使用方式
# result = mycoro()  # 這樣是不正確的,因為它不會等待協程完成

內容解密:

  1. result = await mycoro(...) 正確地等待了 mycoro 協程的完成,並將結果指定給 result
  2. 如果僅僅呼叫 mycoro() 而不使用 await,則不會執行其中的程式碼。

不要忘記等待你的協程,否則它們的程式碼永遠不會被執行。注意asyncio給出的警告。

如前所述,Python中有多個用於非同步程式設計的函式庫,它們具有可以執行如上定義的協程的事件迴圈。特別地,對於asyncio,有一個內建函式可以執行一個協程直到其完成:

import asyncio
asyncio.run(mycoro(...))

內容解密:

  1. asyncio.run(mycoro(...)) 執行 mycoro 協程直到其完成。
  2. 這是啟動非同步程式的一種常見方式。

軟體設計的一般原則

良好的軟體設計是建立高品質軟體的基礎。在前面的章節中,我們討論了為什麼以一致的方式構建程式碼很重要,並且已經看到了編寫更緊湊和更具 Python 風格的程式碼的慣用法。現在是時候瞭解乾淨的程式碼不僅僅是遵循格式規則,而是要使程式碼盡可能地強壯,並以一種能夠最小化缺陷或使其顯而易見的方式來編寫它。

本章節以及接下來的章節將專注於更高層次的抽象設計原則。我們將介紹在 Python 中應用的軟體工程的一般原則。特別是在本章節中,我們將回顧不同的原則,這些原則使軟體設計變得更好。高品質的軟體應該圍繞這些想法構建,它們將作為設計工具。

高品質程式碼的多維度

高品質程式碼是一個具有多個維度的概念。我們可以類別似地思考軟體架構的品質屬性。例如,我們希望我們的軟體是安全的,具有良好的效能、可靠性和可維護性等等。

本章節的目標如下:

  • 瞭解強壯軟體背後的概念
  • 學習如何在應用程式的工作流程中處理錯誤資料
  • 設計可維護的軟體,使其能夠輕易地擴充套件和適應新的需求
  • 設計可重用的軟體
  • 編寫有效的程式碼,以保持開發團隊的高生產力

合約式設計(Design by Contract)

我們正在開發的軟體的一些部分並不是直接由使用者呼叫,而是由程式碼的其他部分呼叫。當我們將應用程式的責任劃分為不同的元件或層時,我們必須考慮它們之間的互動。

我們必須在每個元件後面封裝一些功能,並向客戶端公開一個介面,客戶端將使用該功能,即應用程式程式設計介面(API)。我們為該元件編寫的函式、類別或方法具有特定的工作方式,在某些條件下,如果不滿足這些條件,我們的程式碼就會當機。相反,呼叫該程式碼的客戶端期望特定的回應,我們的函式未能提供這一點將代表一個缺陷。

也就是說,如果我們有一個函式,期望使用一系列整數型別的引數,而其他函式呼叫了這個函式,那麼如果傳遞的引數型別不正確,就會導致我們的程式碼當機。客戶端期望得到正確的回應,但我們的函式卻未能提供,這是一個缺陷。

合約式設計的核心思想

合約式設計是一種設計方法,它強調了元件之間的契約關係。在這種方法中,元件之間的互動被視為一種契約關係,供應者提供服務,客戶端使用服務。供應者必須確保提供的服務符合契約的要求,客戶端也必須確保按照契約的要求使用服務。

合約式設計的核心思想是透過明確定義元件之間的契約關係來提高軟體的可靠性和可維護性。這種方法強調了明確定義介面和契約的重要性,並且要求供應者和客戶端都遵守契約的要求。

合約式設計的優點

合約式設計具有多個優點,包括:

  • 提高軟體可靠性:透過明確定義元件之間的契約關係,可以減少由於誤解或溝通不暢導致的錯誤。
  • 提高軟體可維護性:合約式設計使得軟體更容易理解和維護,因為元件之間的關係更加明確。
  • 減少測試成本:由於元件之間的契約關係更加明確,因此可以減少測試的成本和複雜度。

防禦式程式設計(Defensive Programming)

防禦式程式設計是一種與合約式設計不同的設計方法。在防禦式程式設計中,開發者假設其他元件或客戶端可能會出現錯誤或惡意行為,因此需要在自己的程式碼中新增防禦機制,以防止這些錯誤或惡意行為導致的問題。

防禦式程式設計的核心思想

防禦式程式設計的核心思想是預期其他元件或客戶端可能會出現錯誤或惡意行為,並在自己的程式碼中新增相應的防禦機制,以防止這些問題。

防禦式程式設計的優點

防禦式程式設計具有多個優點,包括:

  • 提高軟體安全性:透過預期其他元件或客戶端可能會出現錯誤或惡意行為,可以提高軟體的安全性。
  • 提高軟體可靠性:防禦式程式設計可以減少由於其他元件或客戶端的錯誤或惡意行為導致的問題。

設計契約(Design by Contract)的基本概念與實踐

軟體開發中,函式或方法的正確執行依賴於輸入引數的有效性以及內部邏輯的正確性。契約式設計(Design by Contract, DbC)是一種軟體設計方法,強調在軟體元件之間的互動中建立明確的契約,以確保各方都能正確地履行其責任。

合約的組成要素

合約主要包含以下幾個關鍵要素:

  1. 前置條件(Preconditions):在函式或方法執行前,必須滿足的條件。通常涉及輸入引數的驗證,例如檢查引數是否為非空、是否符合特定的資料格式等。前置條件約束了呼叫者的責任。

  2. 後置條件(Postconditions):在函式或方法執行完成後,必須滿足的條件。後置條件驗證了函式或方法的輸出是否符合預期,確保了被呼叫者的正確性。

  3. 不變數(Invariants):在函式或方法的執行過程中,保持不變的條件。不變數有助於表達函式或方法的內在邏輯正確性。

  4. 副作用(Side effects):函式或方法執行過程中對外部狀態的影響。雖然副作用不是合約強制執行的部分,但在檔案中描述副作用有助於理解函式或方法的行為。

為何採用契約式設計

採用契約式設計的主要原因是,當錯誤發生時,能夠快速定位問題源頭。如果前置條件檢查失敗,表示問題出在呼叫者;如果後置條件檢查失敗,則問題出在被呼叫的函式或方法本身。這種機制有助於明確劃分責任邊界,加速錯誤修復。

前置條件的實踐

前置條件是函式或方法對輸入引數的期望。在動態型別語言如Python中,除了檢查引數是否符合特定的型別外,還需要驗證引數值的有效性。雖然靜態分析工具(如mypy)可以協助檢查型別相關問題,但函式內部的驗證邏輯仍然是必要的,以確保輸入資料的有效性。

在實踐中,可以採用**嚴格(Demanding)寬容(Tolerant)**的方法。嚴格的方法要求函式自身對輸入資料進行徹底驗證,而寬容的方法則假設呼叫者已經進行了必要的驗證。根據健全性和工業實踐的考慮,契約式設計傾向於採用嚴格的方法。

程式碼範例:前置條件驗證

def calculate_area(width: int, height: int) -> int:
    """
    計算矩形面積。
    
    前置條件:width 和 height 必須為正整數。
    後置條件:傳回的面積必須為正整數。
    """
    # 前置條件驗證
    if not isinstance(width, int) or not isinstance(height, int):
        raise TypeError("Width 和 Height 必須為整數")
    if width <= 0 or height <= 0:
        raise ValueError("Width 和 Height 必須為正數")
    
    area = width * height
    
    # 後置條件驗證
    if area <= 0:
        raise RuntimeError("計算面積失敗")
    
    return area

# 使用範例
try:
    print(calculate_area(5, 10))  # 正確使用
    print(calculate_area(-1, 10)) # 觸發前置條件錯誤
except (TypeError, ValueError, RuntimeError) as e:
    print(f"錯誤:{e}")

內容解密:

  1. calculate_area 函式定義了明確的前置條件和後置條件,確保輸入引數的有效性和輸出結果的正確性。
  2. 前置條件驗證包括型別檢查和值檢查,確保 widthheight 為正整數。這種嚴格的方法有助於捕捉錯誤。
  3. 後置條件驗證確保計算結果符合預期,雖然在此範例中由於輸入驗證充分,計算結果出錯的可能性極低,但仍作為額外保障。
  4. 使用案例外處理機制來報告違約情況,使得錯誤處理更加明確和可控。

後置條件的實踐

後置條件關注的是函式或方法執行完成後的狀態驗證。雖然本例中未詳細展開後置條件的實踐,但原則上,後置條件的檢查應當緊隨函式或方法傳回之後,以確保輸出結果或狀態轉移符合預期。