快轉到主要內容
  1. 教學文章/

Python ABC + Protocol:介面設計與 Structural Subtyping

·8 分鐘· loading · loading · ·
Python Abc Protocol Typing Interface Oop
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 40: 本文

一、前言
#

嗨,我是拍拍君 🐍

寫 Python 的時候,你有沒有遇過這種情況:你定義了一個函式,接收一個「可以呼叫 .read() 方法的東西」,但你不確定該怎麼表達這個約束?

def process_data(source):
    data = source.read()
    # ... 處理資料

source 可以是 open() 回傳的檔案物件、io.StringIOio.BytesIO,甚至是你自己寫的類別——只要它有 .read() 方法就行。但要怎麼告訴別人(和型別檢查器)這個約束呢?

Python 提供了兩種截然不同的做法:

  1. ABC(Abstract Base Class):傳統的「名義子型別」(nominal subtyping),你必須明確繼承才算符合
  2. 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 變得更聰明了 🦆


延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 40: 本文

相關文章

Rust trait vs Python ABC/Protocol:抽象介面大比拼
·8 分鐘· loading · loading
Rust Python Trait Abc Protocol 泛型 Interface
少寫一半程式碼:dataclasses 讓你的 Python 類別煥然一新
·6 分鐘· loading · loading
Python Dataclasses Oop 標準庫
Python Typing:讓你的程式碼更安全、更好維護
·4 分鐘· loading · loading
Python Typing Type Hints
Python Packaging:從 pyproject.toml 到發佈 PyPI 全攻略
·7 分鐘· loading · loading
Python Packaging Pyproject.toml Pypi Pip Uv Setuptools
Python Generators/Yield 完全攻略:惰性運算的藝術
·7 分鐘· loading · loading
Python Generator Yield Lazy Evaluation Iterator
Python click:比 argparse 更優雅的 CLI 框架
·10 分鐘· loading · loading
Python Click Cli 命令列 開發工具