Python 課程 – 型別提示與靜態分析
主題:Generic / Protocol
簡介
在 Python 3.5 之後,型別提示(type hints)逐漸成為大型專案的標準做法。它不會改變執行時的行為,但能讓 IDE、型別檢查工具(如 mypy、pyright)在開發階段即發現潛在錯誤,提升程式碼的可讀性與可維護性。
當我們需要描述「容器」或「函式」接受多種可能的型別時,**泛型(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_type是mypy的內建指令,用來顯示推導出的型別。
2. 限制型別變數 – bound 與 constraints
有時候我們只想接受「可比較」或「具備特定屬性」的型別。可以使用 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 – 靜態鴨子類型
Protocol 是 typing(或 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
我們可以同時使用 Generic 與 Protocol,定義「支援特定操作的容器」:
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 會失去提示的意義。 |
盡量使用具體的 Generic 或 Protocol,必要時才退回 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 匯入。 |
最佳實踐
- 先寫型別提示,再寫實作:先決定函式/類別的介面,使用
Protocol描述需求,可避免日後重構。 - 把公共協議抽離成獨立模組:讓不同子專案共享同一套協議,提升一致性。
- 在 CI 中加入
mypy --strict:嚴格模式會檢查未註解的變數、隱式Any等問題。 - 對外部套件使用 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.ndarray、torch.Tensor 或自訂資料結構。使用 Protocol 抽象化「可轉換為向量」的需求。 |
class Vectorizable(Protocol): def to_vector(self) -> np.ndarray: ... |
總結
- Generic 讓我們在保持靈活性的同時,仍能在編譯階段捕捉型別錯誤。透過
TypeVar、bound、constraints,可以精確描述容器與函式的輸入/輸出。 - Protocol 則是「結構型別」的核心工具,讓 Python 在靜態分析時支援鴨子類型,減少繼承耦合,提升介面的可重用性。
- 結合兩者,我們可以寫出 型別安全且具彈性的 API,在大型專案或插件化系統中,提供明確的合約(contract),同時讓 IDE、CI、測試工具發揮最大效益。
只要在日常開發中養成 先寫型別提示、使用 Protocol 來抽象行為 的習慣,未來的程式碼維護、重構與協作都會變得更加順暢。祝你在 Python 的型別世界裡玩得開心,寫出更可靠的程式!