Pythonで「このオブジェクトには、このメソッドが必要」と表現したいとき、候補になるのが abc.ABC と typing.Protocol です。
どちらも型の契約を記述できますが、前提とする考え方が異なります。
-
ABCは「この基底クラスを継承しているか」を軸にする -
Protocolは「必要なメソッドや属性を持っているか」を軸にする
この記事では、両者を同じ題材で比較し、継承、静的型チェック、実行時検証の違いから使い分けを整理します。
この記事のコード例はPython 3.12以降を対象とします。
Protocol 自体はPython 3.8で追加されました。ジェネリクスの節を除けば、ほとんどの例はPython 3.8以降で利用できます。
先に比較表
| 観点 | abc.ABC |
typing.Protocol |
|---|---|---|
| 部分型の考え方 | 名目的部分型 | 構造的部分型 |
| 基本となる判定 | 継承関係 | メソッドや属性の構造 |
| 実装側の明示的な継承 | 基本的に必要 | 不要 |
| 主なチェック時期 | クラス定義・インスタンス化などの実行時 | mypyやPyrightなどによる静的解析時 |
| 未実装メソッドの実行時強制 |
@abstractmethod で可能 |
基本的には行わない |
| 共通実装や状態の提供 | 向いている | 明示的に継承した場合を除き、主目的ではない |
| 既存クラスや外部ライブラリへの適用 | 継承・登録・フックが必要 | 構造が一致すれば適用できる |
isinstance() |
利用できる |
@runtime_checkable を付けた場合のみ利用できる |
資格にたとえるなら、ABCは「指定された資格を持つ人」、Protocolは「資格の有無にかかわらず、必要な作業ができる人」に近い考え方です。
ただし、このたとえだけでABCを「厳密」、Protocolを「緩い」と捉えると正確ではありません。Protocolも静的型チェッカーを通せば、メソッド名だけでなく引数や戻り値まで検査できます。違いは強弱ではなく、契約への参加を継承で宣言するか、構造から判断するかです。
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を小さく作りたい
- ダックタイピングを保ちながら静的型チェックを使いたい
「Protocolが現在の主流」とは一概に言えない
Protocolは、既存クラスへ手を入れずに型の契約を与えられます。依存方向を小さく保ちやすく、ライブラリ境界やテストダブルとの相性もよい仕組みです。
一方、ProtocolはABCを置き換えるために導入されたものではありません。PEP 544でも、名目的部分型と構造的部分型にはそれぞれ長所と短所があり、Protocolは既存の名目的部分型を補完するものと説明されています。
そのため、「現代のPythonでは常にProtocol」と決めるのではなく、契約の目的から選びます。
| 欲しい契約 | 選択の目安 |
|---|---|
| 実装クラスに継承を明示させたい | ABC |
| 抽象メソッドの未実装をインスタンス化時に止めたい | ABC |
| 共通実装や状態を提供したい | ABC |
| 既存クラスを変更せずに受け入れたい | Protocol |
| 利用側が必要な機能だけを小さく定義したい | Protocol |
| ダックタイピングを静的に検査したい | Protocol |
ABCでも継承なしのクラスを判定できる
ABCは通常、明示的な継承を使います。ただし、継承していないクラスを仮想サブクラスとして扱う方法もあります。
register() で登録する
from abc import ABC
class Serializer(ABC):
pass
class ThirdPartySerializer:
def serialize(self, value: object) -> bytes:
return str(value).encode()
Serializer.register(ThirdPartySerializer)
assert isinstance(ThirdPartySerializer(), Serializer)
__subclasshook__() で構造を判定する
from abc import ABC, abstractmethod
from typing import Any
class Serializer(ABC):
@abstractmethod
def serialize(self, value: object) -> bytes:
raise NotImplementedError
@classmethod
def __subclasshook__(cls, subclass: type[Any]):
if cls is Serializer:
has_serialize = any(
"serialize" in base.__dict__ for base in subclass.__mro__
)
if has_serialize:
return True
return NotImplemented
class ThirdPartySerializer:
def serialize(self, value: object) -> bytes:
return str(value).encode()
assert isinstance(ThirdPartySerializer(), Serializer)
__subclasshook__() は判定ロジックを自分で実装できるため柔軟です。ただし、例では serialize という名前の存在しか確認しておらず、引数や戻り値の型までは検査していません。
register() や __subclasshook__() で認識された仮想サブクラスは、ABCを実際には継承していません。
ABCの共通実装はMROへ入らず、@abstractmethod の実装強制も受けません。 isinstance() が True になることと、基底クラスの機能を継承することは別です。
Protocolでも実行時に isinstance() を使える
通常のProtocolは、isinstance() の第2引数に指定できません。 @runtime_checkable を付けると実行時検証を利用できます。
from typing import Protocol, runtime_checkable
@runtime_checkable
class Serializer(Protocol):
def serialize(self, value: object) -> bytes: ...
class JsonSerializer:
def serialize(self, value: object) -> bytes:
return b"{}"
assert isinstance(JsonSerializer(), Serializer)
ただし、@runtime_checkable は静的型チェックと同じ検証を実行時に行う機能ではありません。属性が存在するかを確認しますが、メソッドの引数や戻り値の型は検査しません。
class IncompatibleSerializer:
def serialize(self) -> str:
return "not bytes"
assert isinstance(IncompatibleSerializer(), Serializer)
# Trueになり得るが、静的型チェックではSerializerと互換性がない
Python公式ドキュメントでは、runtime-checkable Protocolに対する isinstance() は通常の isinstance() より遅くなる場合があるため、性能を重視するコードでは hasattr() などの選択肢も検討するよう説明されています。
実行時検証が必要だからといって、すべての境界へ isinstance() を追加する必要はありません。通常のアプリケーションコードでは、静的型チェッカーで契約を確認し、実際の呼び出しで失敗した例外を適切な境界で処理する方法もあります。
実行時検証で選ぶなら何を見るか
ABCとruntime-checkable Protocolのどちらも isinstance() に利用できますが、意味は異なります。
| 方法 | 実行時に主に確認するもの | 注意点 |
|---|---|---|
| ABCの明示的なサブクラス | 継承関係と抽象メソッドの実装 | 実装側がABCへ依存する |
ABC + register()
|
開発者が登録したクラス | 登録時に構造を自動検証しない |
ABC + __subclasshook__()
|
開発者が実装した判定条件 | シグネチャ検証まで行うなら自前実装が必要 |
Protocol + @runtime_checkable
|
Protocolメンバーの存在 | シグネチャや型は検査しない |
プラグインをロードする場面で「このフレームワークの正式な実装か」を確認したいなら、ABCの明示的な継承が分かりやすい場合があります。外部オブジェクトが必要な操作を持つかだけを確認したいなら、runtime-checkable Protocolや個別の hasattr() が候補になります。
入力データの値や形式まで保証したい場合は、どちらも十分ではありません。別途バリデーションが必要です。
ジェネリクスは両方で使える
ABCとProtocolは、どちらもジェネリックにできます。ジェネリクスを使いたいかどうかは、両者の選択基準にはなりません。
Python 3.12以降では、型パラメータ構文を使えます。
from abc import ABC, abstractmethod
from typing import Protocol
class RepositoryProtocol[T](Protocol):
def get(self, key: str) -> T: ...
def save(self, value: T) -> None: ...
class RepositoryABC[T](ABC):
@abstractmethod
def get(self, key: str) -> T:
raise NotImplementedError
@abstractmethod
def save(self, value: T) -> None:
raise NotImplementedError
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
型パラメータには上限境界や制約も指定できます。ただし、これはABCとProtocolの違いではなく、型変数をどの範囲へ制限するかという別の設計判断です。
判断フロー
型の契約を定義したい
│
├─ 実装クラスへ継承を明示させたい
│ └─ ABC
│
├─ 共通実装や状態を継承させたい
│ └─ ABC
│
├─ 既存クラスや外部ライブラリも変更せず受け入れたい
│ └─ Protocol
│
├─ 利用側が必要な操作だけを小さく定義したい
│ └─ Protocol
│
└─ 実行時にも検証したい
│
├─ 継承関係と未実装を強制したい
│ └─ ABC
│
└─ メンバーの存在を確認できればよい
└─ @runtime_checkable または個別の hasattr()
まとめ
ABCとProtocolは、どちらも契約を表現できます。選択の中心は、契約をどのように成立させたいかです。
- ABCは、継承関係を明示し、抽象メソッドや共通実装を使いたい場合に向く
- Protocolは、継承関係を要求せず、構造を静的型チェックしたい場合に向く
-
__subclasshook__()や@runtime_checkableを使うと両者は似た動きもできるが、検証内容と継承の効果は異なる - どちらが新しいかではなく、名目的な契約と構造的な契約のどちらが必要かで選ぶ
実装側を管理し、フレームワークの一員であることを明示したいならABCが自然です。既存実装を疎結合なまま受け入れたいなら、Protocolが扱いやすくなります。