本文 AI 產出,尚未審核

Python 課程 – 型別提示與靜態分析

主題:Generic / Protocol


簡介

在 Python 3.5 之後,型別提示(type hints)逐漸成為大型專案的標準做法。它不會改變執行時的行為,但能讓 IDE、型別檢查工具(如 mypypyright)在開發階段即發現潛在錯誤,提升程式碼的可讀性與可維護性。

當我們需要描述「容器」或「函式」接受多種可能的型別時,**泛型(Generic)協議(Protocol)**就顯得格外重要。它們讓我們在不失靈活性的前提下,仍能保持嚴謹的型別檢查。本文將從概念說明、實作範例、常見陷阱到實務應用,完整帶你掌握 Generic 與 Protocol 的使用方式。


核心概念

1. 為什麼需要 Generic?

在日常開發中,我們常會寫像 list[int]dict[str, Any] 這樣的容器型別。若只寫 list,型別檢查器會把裡面的元素視為 Any,失去型別安全的好處。
Generic 讓我們在宣告容器時,使用「型別變數」來表示「任意型別」,同時保留對該型別的限制。

1.1 基本範例 – TypeVar

from typing import TypeVar, List

T = TypeVar('T')          # 宣告一個型別變數 T

def first_item(seq: List[T]) -> T:
    """回傳序列的第一個元素,型別會根據傳入的 List 自動推導。"""
    return seq[0]

ints = [1, 2, 3]
strs = ["a", "b", "c"]

reveal_type(first_item(ints))   # >>> int
reveal_type(first_item(strs))   # >>> str

reveal_typemypy 的內建指令,用來顯示推導出的型別。

2. 限制型別變數 – boundconstraints

有時候我們只想接受「可比較」或「具備特定屬性」的型別。可以使用 bound(上界)或 constraints(多重限制)來限制 TypeVar

2.1 使用 bound

from typing import TypeVar, Iterable

# 只接受「可比較」的型別(必須實作 __lt__ 方法)
Comparable = TypeVar('Comparable', bound='SupportsLessThan')

class SupportsLessThan:
    def __lt__(self, other): ...

def max_item(items: Iterable[Comparable]) -> Comparable:
    """回傳可比較序列中的最大值。"""
    iterator = iter(items)
    max_val = next(iterator)
    for item in iterator:
        if max_val < item:
            max_val = item
    return max_val

2.2 使用 constraints

from typing import TypeVar

# 只接受 int、float 或 str 三種型別之一
NumberOrStr = TypeVar('NumberOrStr', int, float, str)

def concat_or_add(a: NumberOrStr, b: NumberOrStr) -> NumberOrStr:
    """若是字串則串接,若是數字則相加。"""
    return a + b

3. Protocol – 靜態鴨子類型

Protocoltyping(或 typing_extensions)提供的「結構型別」概念。只要物件符合協議所描述的方法或屬性,型別檢查器就會認可,即使它沒有明確繼承該協議。這與「鴨子類型」的精神相同,但在靜態分析時提供了明確的規範。

3.1 基本 Protocol

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def safe_close(resource: SupportsClose) -> None:
    """只要物件實作 close 方法,就可以安全關閉。"""
    resource.close()

SupportsClose 並不需要繼承 Protocol,只要符合簽名即可。

3.2 具體範例 – 可迭代的容器

from typing import Protocol, Iterable, TypeVar

T = TypeVar('T')

class Container(Protocol[T]):
    def __len__(self) -> int: ...
    def __getitem__(self, index: int) -> T: ...

def second_element(c: Container[T]) -> T:
    """取得容器的第二個元素,若不存在則拋出 IndexError。"""
    return c[1]

# list、tuple、bytes 都符合 Container 協議
assert second_element([10, 20, 30]) == 20
assert second_element(b'ABCD') == 66   # ASCII 'B'

3.3 進階 Protocol – 可呼叫物件

from typing import Protocol, Callable

class Comparator(Protocol):
    def __call__(self, a: int, b: int) -> int: ...

def sort_with(comp: Comparator, data: list[int]) -> list[int]:
    """使用自訂比較函式排序。"""
    return sorted(data, key=lambda x: comp(x, 0))

def reverse_compare(a: int, b: int) -> int:
    return -a + b

print(sort_with(reverse_compare, [5, 2, 9]))  # [9, 5, 2]

4. 結合 Generic 與 Protocol

我們可以同時使用 GenericProtocol,定義「支援特定操作的容器」:

from typing import Protocol, Generic, TypeVar

T = TypeVar('T')

class Addable(Protocol):
    def __add__(self, other: object) -> object: ...

class SummableList(List[T], Generic[T]):
    """只接受可相加的元素,提供 sum 方法。"""
    def total(self) -> T:
        result: T = self[0]
        for item in self[1:]:
            result = result + item   # 型別檢查器會確保 T 支援 __add__
        return result

class MyNumber(int, Addable): pass

nums = SummableList([MyNumber(1), MyNumber(2), MyNumber(3)])
print(nums.total())   # 6

常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記在 Protocol 前加 @runtime_checkable 若在執行時使用 isinstance(obj, Protocol),會拋出 TypeError 只在需要 runtime 檢查時加上 @runtime_checkable,平常僅供靜態分析即可。
過度使用 Any 把所有型別都寫成 Any 會失去提示的意義。 盡量使用具體的 GenericProtocol,必要時才退回 Any
TypeVar 未設定 bound/constraints 會讓型別推導過於寬鬆,導致錯誤未被捕捉。 明確限制 TypeVar,或在函式內部使用 assert isinstance(..., ...) 以輔助檢查。
在 Protocol 中使用 @property 時忘記標註返回型別 型別檢查器無法推導正確型別。 為每個 @property 加上返回型別註解,例如 @property def name(self) -> str: ...
使用舊版 Python(<3.8)時缺少 Protocol Protocol 只在 Python 3.8+ 標準庫中提供。 於較舊環境安裝 typing_extensions,並從 typing_extensions import Protocol 匯入。

最佳實踐

  1. 先寫型別提示,再寫實作:先決定函式/類別的介面,使用 Protocol 描述需求,可避免日後重構。
  2. 把公共協議抽離成獨立模組:讓不同子專案共享同一套協議,提升一致性。
  3. 在 CI 中加入 mypy --strict:嚴格模式會檢查未註解的變數、隱式 Any 等問題。
  4. 對外部套件使用 stub 檔(.pyi):若第三方套件缺少型別資訊,可自行撰寫 stub,讓靜態分析完整。

實際應用場景

場景 為什麼需要 Generic / Protocol 示例
資料處理管線(ETL) 各階段可能接受 list[dict]pandas.DataFrame、自訂 Record 類別。使用 Protocol 定義「可迭代且支援 .keys()」的資料列。 class RowLike(Protocol): def __getitem__(self, key: str) -> Any: ...
插件系統 主程式只關心插件提供的 initialize()run(data: T) -> R 方法。使用 Protocol 描述插件介面,同時以 Generic[T, R] 表示資料型別。 class Plugin(Protocol[Input, Output]): def run(self, data: Input) -> Output: ...
演算法庫 像排序、搜尋演算法需要「可比較」的元素。使用 bound=SupportsLessThan 限制型別變數,確保使用者傳入的自訂類別實作比較運算子。 def quick_sort(items: List[Comparable]) -> List[Comparable]: ...
網路 I/O async 函式常返回 Awaitable[T],而不同的 HTTP 客戶端回傳不同的 Response 類別。利用 Protocol 定義 ResponseLike,讓函式接受任意符合介面的物件。 class ResponseLike(Protocol): def json(self) -> dict: ...
機器學習模型封裝 模型的 predict 方法接受 np.ndarraytorch.Tensor 或自訂資料結構。使用 Protocol 抽象化「可轉換為向量」的需求。 class Vectorizable(Protocol): def to_vector(self) -> np.ndarray: ...

總結

  • Generic 讓我們在保持靈活性的同時,仍能在編譯階段捕捉型別錯誤。透過 TypeVarboundconstraints,可以精確描述容器與函式的輸入/輸出。
  • Protocol 則是「結構型別」的核心工具,讓 Python 在靜態分析時支援鴨子類型,減少繼承耦合,提升介面的可重用性。
  • 結合兩者,我們可以寫出 型別安全且具彈性的 API,在大型專案或插件化系統中,提供明確的合約(contract),同時讓 IDE、CI、測試工具發揮最大效益。

只要在日常開發中養成 先寫型別提示、使用 Protocol 來抽象行為 的習慣,未來的程式碼維護、重構與協作都會變得更加順暢。祝你在 Python 的型別世界裡玩得開心,寫出更可靠的程式!