一、前言 #
嗨,我是拍拍君 🐍
寫 Python 的時候,你有沒有遇過這種情況:你定義了一個函式,接收一個「可以呼叫 .read() 方法的東西」,但你不確定該怎麼表達這個約束?
def process_data(source):
data = source.read()
# ... 處理資料
source 可以是 open() 回傳的檔案物件、io.StringIO、io.BytesIO,甚至是你自己寫的類別——只要它有 .read() 方法就行。但要怎麼告訴別人(和型別檢查器)這個約束呢?
Python 提供了兩種截然不同的做法:
- ABC(Abstract Base Class):傳統的「名義子型別」(nominal subtyping),你必須明確繼承才算符合
- Protocol:Python 3.8+ 的「結構子型別」(structural subtyping),只要結構吻合就算符合
這兩種設計哲學各有千秋,今天拍拍君就帶你搞懂它們的差異、使用場景和最佳實踐!
📌 如果你對 Python 的型別標註還不太熟,建議先看看 Python typing 型別標註教學。
二、ABC:抽象基底類別 #
什麼是 ABC? #
ABC 是 Python abc 模組提供的機制,讓你定義「抽象介面」——規定子類別必須實作哪些方法,否則無法實例化。
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self) -> str:
"""每種動物都要會叫"""
...
@abstractmethod
def move(self) -> str:
"""每種動物都要會移動"""
...
def breathe(self) -> str:
"""但呼吸是共同的(非抽象方法)"""
return "吸氣...吐氣..."
繼承與強制實作 #
class Dog(Animal):
def speak(self) -> str:
return "汪汪!"
def move(self) -> str:
return "四腳奔跑 🐕"
class Cat(Animal):
def speak(self) -> str:
return "喵~"
def move(self) -> str:
return "優雅漫步 🐈"
# ✅ 正常運作
pypy_dog = Dog()
print(pypy_dog.speak()) # 汪汪!
print(pypy_dog.breathe()) # 吸氣...吐氣...
如果你忘記實作抽象方法呢?
class Fish(Animal):
def speak(self) -> str:
return "..."
# 忘了實作 move()!
fish = Fish()
# ❌ TypeError: Can't instantiate abstract class Fish
# with abstract method move
ABC 在實例化時就會幫你抓錯——不是等到你呼叫 move() 才爆炸,而是在 Fish() 這行就擋下來。
ABC 的核心特性 #
| 特性 | 說明 |
|---|---|
@abstractmethod |
子類必須實作,否則無法實例化 |
| 非抽象方法 | 可以提供預設實作,子類直接繼承 |
isinstance() 檢查 |
支援 isinstance(obj, Animal) |
| 名義子型別 | 必須明確繼承 Animal 才算是 Animal |
實戰範例:資料來源抽象化 #
from abc import ABC, abstractmethod
from typing import Iterator
class DataSource(ABC):
"""所有資料來源的抽象介面"""
@abstractmethod
def connect(self) -> None:
"""建立連線"""
...
@abstractmethod
def fetch(self) -> Iterator[dict]:
"""取得資料"""
...
@abstractmethod
def close(self) -> None:
"""關閉連線"""
...
def fetch_all(self) -> list[dict]:
"""便利方法:一次取得所有資料"""
self.connect()
try:
return list(self.fetch())
finally:
self.close()
class DatabaseSource(DataSource):
def __init__(self, connection_string: str):
self.conn_str = connection_string
self._conn = None
def connect(self) -> None:
print(f"連線到資料庫: {self.conn_str}")
# self._conn = create_connection(self.conn_str)
def fetch(self) -> Iterator[dict]:
yield {"id": 1, "name": "拍拍君"}
yield {"id": 2, "name": "拍拍醬"}
def close(self) -> None:
print("關閉資料庫連線")
class APISource(DataSource):
def __init__(self, url: str):
self.url = url
def connect(self) -> None:
print(f"連線到 API: {self.url}")
def fetch(self) -> Iterator[dict]:
yield {"status": "ok", "data": "from API"}
def close(self) -> None:
print("關閉 API session")
使用時完全不用管具體是哪種資料來源:
def analyze(source: DataSource) -> None:
data = source.fetch_all()
print(f"取得 {len(data)} 筆資料")
analyze(DatabaseSource("postgresql://localhost/pypy"))
analyze(APISource("https://api.example.com/data"))
三、Protocol:結構子型別 #
ABC 的痛點 #
ABC 很好,但有個根本問題:你控制不了別人的程式碼。
假設你想寫一個函式處理「任何有 .read() 方法的東西」:
class Readable(ABC):
@abstractmethod
def read(self) -> str:
...
def process(source: Readable) -> str:
return source.read().upper()
問題來了——io.StringIO 有 .read() 方法,但它沒有繼承你的 Readable。所以 mypy 會報錯:
import io
process(io.StringIO("hello")) # ❌ mypy error: Argument 1 has incompatible type
你沒辦法回去修改標準函式庫讓 StringIO 繼承 Readable,對吧?
這就是 Protocol 閃亮登場的時候了。
Protocol 基礎 #
from typing import Protocol
class Readable(Protocol):
def read(self) -> str:
...
就這樣。不需要 ABC、不需要 @abstractmethod、不需要任何人繼承這個 class。只要一個物件有 read() -> str 這個方法,它就自動符合 Readable——這就是 structural subtyping(結構子型別),也叫做 duck typing 的靜態版本。
import io
def process(source: Readable) -> str:
return source.read().upper()
# ✅ 全部通過 mypy 檢查!
process(io.StringIO("hello")) # StringIO 有 .read()
process(open("data.txt")) # 檔案物件有 .read()
你自己寫的類別也自動符合,不需要顯式繼承:
class MyReader:
def read(self) -> str:
return "拍拍君的資料"
process(MyReader()) # ✅ MyReader 有 .read() -> str,自動符合
Protocol 的核心特性 #
| 特性 | 說明 |
|---|---|
| 結構子型別 | 不需繼承,結構符合就行 |
| 靜態檢查 | mypy / pyright 在編譯時檢查 |
runtime_checkable |
加上裝飾器後可用 isinstance() |
| 屬性支援 | 可以定義 attribute: type |
| 方法簽名 | 只看方法名稱和型別簽名 |
runtime_checkable:讓 Protocol 支援 isinstance #
預設情況下,Protocol 只在靜態分析時有效。如果你想在 runtime 用 isinstance(),需要加上 @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
# 現在可以用 isinstance 了
import io
stream = io.StringIO()
print(isinstance(stream, Closable)) # True ✅
print(isinstance(42, Closable)) # False ✅
⚠️ 注意:runtime_checkable 只檢查方法是否存在,不檢查參數型別和回傳型別。深度的型別檢查還是要靠 mypy。
四、ABC vs Protocol:正面對決 #
直覺比較 #
# ABC 風格:「你是誰」—— 名義子型別
class Flyable(ABC):
@abstractmethod
def fly(self) -> None: ...
class Bird(Flyable): # ✅ 明確繼承
def fly(self) -> None:
print("拍拍翅膀飛走了")
class Airplane: # ❌ 沒繼承 Flyable
def fly(self) -> None:
print("引擎啟動,起飛!")
def take_off(thing: Flyable) -> None:
thing.fly()
take_off(Bird()) # ✅
take_off(Airplane()) # ❌ mypy error!
# Protocol 風格:「你能做什麼」—— 結構子型別
class Flyable(Protocol):
def fly(self) -> None: ...
class Bird: # 不需要繼承任何東西
def fly(self) -> None:
print("拍拍翅膀飛走了")
class Airplane: # 也不需要
def fly(self) -> None:
print("引擎啟動,起飛!")
def take_off(thing: Flyable) -> None:
thing.fly()
take_off(Bird()) # ✅ 有 .fly() 就行
take_off(Airplane()) # ✅ 也有 .fly(),過!
完整比較表 #
| 比較項目 | ABC | Protocol |
|---|---|---|
| 繼承要求 | ✅ 必須明確繼承 | ❌ 不需要 |
| 型別檢查方式 | 名義子型別(nominal) | 結構子型別(structural) |
| runtime isinstance | ✅ 原生支援 | 需加 @runtime_checkable |
| 預設實作 | ✅ 非抽象方法可提供 | ⚠️ 可以但不建議 |
| 第三方程式碼 | ❌ 無法修改 | ✅ 自動適配 |
| Python 版本 | 2.6+ | 3.8+(typing_extensions 可回溯) |
| 適合場景 | 你控制的類別階層 | 跨套件的介面約束 |
如何選擇? #
拍拍君的經驗法則:
問自己:「我能控制所有要符合這個介面的類別嗎?」
├── 能 → ABC(更嚴格、有預設實作、isinstance 免費)
└── 不能 → Protocol(更彈性、跨套件、duck typing 精神)
更具體地說:
- 寫框架/函式庫的內部介面 → ABC(你控制所有實作者)
- 寫函式庫的公開 API 型別 → Protocol(使用者不該被迫繼承你的 class)
- 型別標註「任何有某方法的東西」 → Protocol
- 需要
isinstance()做 runtime 分派 → ABC 更可靠
五、進階技巧 #
1. Protocol + 屬性 #
Protocol 不只能定義方法,還能定義屬性:
from typing import Protocol
class Named(Protocol):
name: str # 必須有 name 屬性
class HasAge(Protocol):
@property
def age(self) -> int: # 也支援 property
...
class User:
def __init__(self, name: str, birth_year: int):
self.name = name
self._birth_year = birth_year
@property
def age(self) -> int:
return 2026 - self._birth_year
def greet(person: Named) -> str:
return f"哈囉,{person.name}!"
def check_age(person: HasAge) -> str:
return f"你 {person.age} 歲了"
user = User("拍拍君", 2024)
print(greet(user)) # ✅ User 有 .name
print(check_age(user)) # ✅ User 有 .age property
2. 組合多個 Protocol #
你可以用繼承來組合多個 Protocol,建立更複雜的約束:
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
class Writable(Protocol):
def write(self, data: str) -> None: ...
class Closable(Protocol):
def close(self) -> None: ...
# 組合!
class ReadWriteClosable(Readable, Writable, Closable, Protocol):
...
def process_stream(stream: ReadWriteClosable) -> None:
data = stream.read()
stream.write(data.upper())
stream.close()
注意:組合 Protocol 時,最後要加上 Protocol 作為基底類別。
3. ABC 的 register:逆向註冊 #
ABC 有一個鮮為人知的功能——register(),可以讓已存在的類別事後「登記」為某個 ABC 的子類:
from abc import ABC, abstractmethod
class Printable(ABC):
@abstractmethod
def to_string(self) -> str:
...
# 把內建的 int 註冊為 Printable
# (注意:這只影響 isinstance,不會實際檢查方法是否存在)
Printable.register(int)
print(isinstance(42, Printable)) # True
⚠️ 但是:register() 不會檢查被註冊的類別是否真的有實作抽象方法。所以這比較像是「我擔保這個類別符合介面」的聲明,用的時候要小心。
4. 泛型 Protocol #
Protocol 也支援泛型,這在寫通用介面時超好用:
from typing import Protocol, TypeVar
T = TypeVar("T")
class Container(Protocol[T]):
def get(self) -> T: ...
def set(self, value: T) -> None: ...
class Box:
def __init__(self, value: int):
self._value = value
def get(self) -> int:
return self._value
def set(self, value: int) -> None:
self._value = value
def double_content(box: Container[int]) -> None:
box.set(box.get() * 2)
b = Box(21)
double_content(b) # ✅ Box 符合 Container[int]
print(b.get()) # 42
六、常見陷阱 #
陷阱 1:Protocol 方法簽名要完全匹配 #
class Adder(Protocol):
def add(self, x: int, y: int) -> int: ...
class MyCalc:
def add(self, x: int, y: int, z: int = 0) -> int: # 多了一個參數
return x + y + z
def compute(adder: Adder) -> int:
return adder.add(1, 2)
compute(MyCalc()) # ✅ 這其實是 OK 的!
# 因為 MyCalc.add 可以接受 (int, int),多的參數有預設值
但反過來就不行:
class StrictAdder(Protocol):
def add(self, x: int, y: int, z: int) -> int: ...
class SimpleCalc:
def add(self, x: int, y: int) -> int: # 少了一個參數
return x + y
# ❌ SimpleCalc 不符合 StrictAdder,因為它沒辦法接受三個參數
陷阱 2:ABC 的 abstractmethod 要和 property 配合 #
from abc import ABC, abstractmethod
class Config(ABC):
@property
@abstractmethod
def name(self) -> str:
...
class AppConfig(Config):
@property
def name(self) -> str:
return "拍拍 App"
注意 @property 要放在 @abstractmethod 上面(外面)。
陷阱 3:不要把 Protocol 當作基底類別來繼承 #
class Greetable(Protocol):
def greet(self) -> str: ...
# ❌ 不建議這樣做
class Person(Greetable): # 這會讓 Person 變成「顯式實作」Protocol
def greet(self) -> str:
return "嗨!"
# ✅ 正確做法:直接實作方法,不繼承 Protocol
class Person:
def greet(self) -> str:
return "嗨!"
Protocol 的設計理念是隱式符合,如果你讓類別繼承 Protocol,就失去了 structural subtyping 的優勢。
結語 #
今天我們深入比較了 Python 的兩大介面設計工具:
- ABC:嚴格的名義子型別,適合你控制的類別階層,提供預設實作和 runtime 檢查
- Protocol:彈性的結構子型別,適合跨套件的介面約束,是 duck typing 的靜態化身
拍拍君的建議是:先考慮 Protocol。Python 的 duck typing 文化根深蒂固,Protocol 更符合這個精神。只在你需要強制繼承關係、提供共用預設實作、或做複雜的 runtime 型別分派時,才使用 ABC。
現代 Python(3.12+)的型別系統越來越強大,Protocol 搭配 mypy 或 pyright 可以在不犧牲 Python 靈活性的前提下,大幅提升程式碼的安全性和可讀性。
寫出有型別的 Python,不代表要放棄 Python 的靈魂——只是讓 duck typing 變得更聰明了 🦆