Pythonで「このオブジェクトには、このメソッドが必要」と表現したいとき、候補になるのが abc.ABC と typing.Protocol です。
どちらも型の契約を記述できますが、前提とする考え方が異なります。
-
ABCは「この基底クラスを継承しているか」を軸にする -
Protocolは「必要なメソッドや属性を持っているか」を軸にする
この記事では、両者を同じ題材で比較し、継承、静的型チェック、実行時検証の違いから使い分けを整理します。
この記事のコード例はPython 3.12以降を対象とします。
Protocol 自体はPython 3.8で追加されました。ジェネリクスの節を除けば、ほとんどの例はPython 3.8以降で利用できます。
ABCは継承関係で契約を定義する
ABCはAbstract Base Class(抽象基底クラス)の略です。 ABC を継承し、実装を必須にしたいメソッドへ @abstractmethod を付けます。
from abc import ABC, abstractmethod
class Serializer(ABC):
@abstractmethod
def serialize(self, value: object) -> bytes:
raise NotImplementedError
class JsonSerializer(Serializer):
def serialize(self, value: object) -> bytes:
import json
return json.dumps(value).encode()
JsonSerializer は Serializer を明示的に継承しています。抽象メソッドを実装しないままインスタンス化すると、実行時に TypeError が発生します。
class BrokenSerializer(Serializer):
pass
BrokenSerializer()
# TypeError: Can't instantiate abstract class BrokenSerializer ...
ABCが向いているのは、継承関係そのものに意味があり、実装側を自分で管理できる場面です。
- フレームワークが提供する基底クラス
- プラグインが必ず継承する拡張ポイント
- 共通実装や状態もサブクラスへ提供したい場合
- 未実装のサブクラスを実行時にインスタンス化させたくない場合
この性質から、実装を管理でき、継承関係や共通実装を契約に含めたい基盤コードなどの内部契約で有用です。
Protocolはオブジェクトの構造で契約を定義する
Protocolは、必要なメソッドや属性を定義します。実装側はProtocolを継承する必要がありません。
from typing import Protocol
class Serializer(Protocol):
def serialize(self, value: object) -> bytes: ...
class JsonSerializer:
def serialize(self, value: object) -> bytes:
import json
return json.dumps(value).encode()
def save(serializer: Serializer, value: object) -> None:
payload = serializer.serialize(value)
print(payload)
save(JsonSerializer(), {"status": "ok"})
JsonSerializer と Serializer の間に継承関係はありません。それでも、mypyやPyrightなどの静的型チェッカーは、JsonSerializer が必要なシグネチャを持っているため Serializer と互換性があると判断します。
構造が合わなければ、静的型チェックで検出できます。
class TextSerializer:
def serialize(self, value: object) -> str:
return str(value)
save(TextSerializer(), {"status": "ok"})
# 静的型チェックでは、戻り値がbytesではなくstrなのでエラー
Protocolが便利なのは、利用側が必要最小限の契約を定義したい場面です。
- 外部ライブラリのクラスも同じ関数で受け取りたい
- 実装側へ特定の基底クラスを継承させたくない
- テスト用のfakeやstubを小さく作りたい
- ダックタイピングを保ちながら静的型チェックを使いたい
実装側へ特定の依存を持ち込まず、必要な操作だけを公開APIの境界として示したい場合など、外部契約でも扱いやすい仕組みです。
ジェネリクスで型の関係を保つ
ABCとProtocolは、どちらもジェネリックにできます。ジェネリクスを使いたいかどうかは、両者の選択基準にはなりませんが、抽象化した後も型同士の関係を静的型チェッカーへ伝えるために役立ちます。
入力と出力が同じ型であることを表す
次の first() は、受け取ったリストの要素と同じ型を返します。 list[str] を渡せば戻り値は str、list[int] を渡せば int と推論されます。
def first[T](items: list[T]) -> T:
return items[0]
型を object にすると戻り値まで object になりますが、型パラメータ T を使えば、具体的な型を固定せずに入力と出力の関係を保てます。
constraintsで許容する型を列挙する
値制約(constraints)は、型パラメータとして許容する型を列挙します。次の例では str または bytes を受け付け、それ以外の型は静的型チェックでエラーになります。
def duplicate[T: (str, bytes)](value: T) -> tuple[T, T]:
return value, value
boundで共通の上限境界を指定する
上限境界(bound)は、指定したクラスのサブクラスを受け付けます。Protocolをboundに指定した場合は、そのProtocolに適合する型を受け付けます。
from typing import Protocol
class Closable(Protocol):
def close(self) -> None: ...
def close_and_return[T: Closable](resource: T) -> T:
resource.close()
return resource
この例では、close() を持つ具体的な型を受け付け、戻り値も渡したオブジェクトと同じ具体的な型として扱えます。
Python 3.11以前では TypeVar を使う
ここまでの型パラメータ構文はPython 3.12で追加されました。Python 3.11以前をサポートする場合は、TypeVar と Generic を使います。
from abc import ABC, abstractmethod
from typing import Generic, Protocol, TypeVar
T = TypeVar("T")
class RepositoryProtocol(Protocol[T]):
def get(self, key: str) -> T: ...
def save(self, value: T) -> None: ...
class RepositoryABC(ABC, Generic[T]):
@abstractmethod
def get(self, key: str) -> T:
raise NotImplementedError
@abstractmethod
def save(self, value: T) -> None:
raise NotImplementedError
constraintsは TypeVar("T", str, bytes)、boundは TypeVar("T", bound=Closable) と記述します。Python 3.12以降では、型パラメータを関数名やクラス名の直後へ書けるため、TypeVar を事前に宣言する必要がありません。
ABCとProtocolの違いから選択基準を整理する
| 観点 | abc.ABC |
typing.Protocol |
|---|---|---|
| 基本となる部分型関係 | 継承・登録による名前的部分型(nominal subtyping) | 構造的部分型(structural subtyping) |
| 部分型判定の補足 |
__subclasshook__() による構造的な実行時判定も可能 |
メソッドや属性の構造を静的に検査 |
| 実装側の明示的な継承 | 基本的に必要 | 不要 |
| 主なチェック時期 | クラス定義・インスタンス化などの実行時 | mypyやPyrightなどによる静的解析時 |
| 未実装メソッドの実行時強制 |
@abstractmethod で可能 |
基本的には行わない |
| 共通実装や状態の提供 | 向いている | 明示的に継承した場合を除き、主目的ではない |
| 既存クラスや外部ライブラリへの適用 | 継承・登録・フックが必要 | 構造が一致すれば適用できる |
isinstance() |
利用できる |
@runtime_checkable を付けた場合のみ利用できる |
資格にたとえるなら、ABCは「指定された資格を持つ人」、Protocolは「資格の有無にかかわらず、必要な作業ができる人」に近い考え方です。
ただし、このたとえだけでABCを「厳密」、Protocolを「緩い」と捉えると正確ではありません。Protocolも静的型チェッカーを通せば、メソッド名だけでなく引数や戻り値まで検査できます。違いは強弱ではなく、契約への参加を継承で宣言するか、構造から判断するかです。
Protocolは、既存クラスへ手を入れずに型の契約を与えられます。依存方向を小さく保ちやすく、ライブラリ境界やテストダブルとの相性もよい仕組みです。
一方、ProtocolはABCを置き換えるために導入されたものではありません。PEP 544では、名前的部分型と構造的部分型にはそれぞれ長所と短所があり、Protocolは既存の名前的部分型を補完するものと説明されています。
選択するときは、APIの公開範囲だけでなく、契約へ参加する方法を基準にします。実装側に基底クラスの継承を求め、共通実装やインスタンス化時の検証も契約に含めるならABCが適しています。実装側へ継承を求めず、必要なメソッドや属性を満たすことを契約とするならProtocolが適しています。
| 欲しい契約 | 選択の目安 |
|---|---|
| 実装クラスに継承を明示させたい | ABC |
| 抽象メソッドの未実装をインスタンス化時に止めたい | ABC |
| 共通実装や状態を提供したい | ABC |
| 既存クラスを変更せずに受け入れたい | Protocol |
| 利用側が必要な機能だけを小さく定義したい | Protocol |
| ダックタイピングを静的に検査したい | Protocol |
選択フロー
型の契約を定義したい
│
├─ 実装クラスへ継承を明示させたい
│ └─ ABC
│
├─ 共通実装や状態を継承させたい
│ └─ ABC
│
├─ 既存クラスや外部ライブラリも変更せず受け入れたい
│ └─ Protocol
│
├─ 利用側が必要な操作だけを小さく定義したい
│ └─ Protocol
│
└─ 実行時にも検証したい
│
├─ 継承関係と未実装を強制したい
│ └─ ABC
│
└─ メンバーの存在を確認できればよい
└─ @runtime_checkable または個別の hasattr()
ABCは、明示的に継承したクラスを isinstance() で判定できます。Protocolも @runtime_checkable を付ければ isinstance() に利用できますが、メソッドの引数や戻り値の型までは検査しません。
ABCの register() や __subclasshook__() を含む実行時検証の違いは、ABCとProtocolで実行時検証する方法に分けてまとめています。
まとめ
ABCとProtocolは、どちらも契約を表現できます。選択の中心は、契約をどのように成立させたいかです。
- ABCは、継承関係を明示し、抽象メソッドや共通実装を使いたい場合に向く
- Protocolは、継承関係を要求せず、構造を静的型チェックしたい場合に向く
-
__subclasshook__()や@runtime_checkableを使うと両者は似た動きもできるが、検証内容と継承の効果は異なる - どちらが新しいかではなく、名前的な契約と構造的な契約のどちらが必要かで選ぶ
実装側を管理し、フレームワークの一員であることを明示したいならABCが自然です。既存実装を疎結合なまま受け入れたいなら、Protocolが扱いやすくなります。