継承を強制する設計には穴があります。自分が書いたクラスには基底クラスを継承させられても、外部ライブラリが返すオブジェクトには後付けできません。Pythonのtyping.Protocolは継承関係を要求せず、「必要なメソッドが生えているか」だけで型を合わせます。
継承の強制が効かない場面
抽象基底クラス(ABC)でインターフェースを定義すると、実装側に継承を求めます。class MyRepo(RepositoryABC) のように、明示的に親を指定しないと型として認められません。自分のコードベースで完結するなら問題ありません。
困るのは外部の型を扱うときです。あるSDKが返すクライアントオブジェクトに、自分で定義したABCを継承させることはできません。そのクラスのソースは書き換えられないからです。結果、isinstance も型注釈も使えず、ラッパークラスを1枚かませる羽目になります。外部SDKのクライアントをABCでラップしようとして継承できず詰まり、Protocolに切り替えたら型注釈だけで通った、という経験があります。
構造で判定すれば、この制約は消えます。「close() を持っているか」だけを見るなら、相手が誰の書いたクラスかは関係ありません。
Protocolは構造でメソッドを照合する
現行の Python 3.14.6 のドキュメントでは、Protocolは “static duck-typing” を静的型チェッカーに認識させる仕組みとされています(Protocol は 3.8 で追加)。公式仕様の “Defining a protocol” が示す通り、必要なメンバーを並べたクラスを定義するだけです。
from typing import Protocol
class Renderer(Protocol):
def render(self) -> str: ...
class Markdown:
def render(self) -> str:
return "# Hello"
class Html:
def render(self) -> str:
return "Hello
"
def show(r: Renderer) -> None:
print(r.render())
show(Markdown())
show(Html())
実行結果。
# Hello
Hello
Markdown も Html も Renderer を継承していません。render() が生えているだけで show() に渡せます。これが構造的部分型です。
明示的な継承は要らない
名目的部分型(ABC)は「親を継承したか」で判定します。構造的部分型は「形が一致するか」で判定します。後者は宣言の時点で相手を縛らないので、後から書いたクラスでも、外部ライブラリのクラスでも、条件さえ満たせば通ります。
ジェネリックなProtocolも書ける
型パラメータを取るProtocolも定義できます。3.12以降のジェネリクス構文ならこう書きます。
class Reader[T](Protocol):
def read(self) -> T: ...
Reader[bytes] や Reader[str] として、戻り値の型まで含めて構造を指定できます。
ABCとProtocolの違い
判定方式と使いどころを並べると差がはっきりします。
| 観点 | ABC(抽象基底クラス) | Protocol |
|---|---|---|
| 判定方式 | 名目的(継承したか) | 構造的(形が一致するか) |
| 継承の要否 | 必須 | 不要 |
| 外部の型への適用 | 不可(書き換えが要る) | 可能 |
実行時 isinstance | そのまま使える | @runtime_checkable が要る |
| デフォルト実装 | メソッド本体を継承できる | 継承時のみ本体を引き継ぐ |
外部の型をまとめて扱いたい、あるいは依存を疎結合にしたい場面はProtocolが向きます。継承ツリーを設計の軸にしたい場面はABCが残ります。
テストダブルをProtocolで型付けする
テストでの依存差し替えは、Protocolが実益を出す代表例です。本物のDBアクセスを、テストではメモリ上の偽物に差し替えたい。このとき依存先をProtocolで受けておくと、本物も偽物も同じ型として渡せます。偽物にわざわざ基底クラスを継承させる必要がありません。
依存をProtocolで受ける
サービス層が依存する Repository をProtocolで定義します。本番実装とテスト用のインメモリ実装、どちらも構造が一致すれば渡せます。
from typing import Protocol
class Repository(Protocol):
def get(self, id: int) -> str: ...
def save(self, value: str) -> None: ...
class PostgresRepo: # 本番: 実際にはDBへ
def get(self, id: int) -> str: ...
def save(self, value: str) -> None: ...
class InMemoryRepo: # テスト: dictで代用
def __init__(self) -> None:
self._store: dict[int, str] = {}
def get(self, id: int) -> str:
return self._store[id]
def save(self, value: str) -> None:
self._store[len(self._store)] = value
def register(repo: Repository, value: str) -> None:
repo.save(value)
register() は Repository 型だけを知っています。PostgresRepo も InMemoryRepo も Repository を継承していませんが、両方そのまま渡せます。テストコードからインメモリ実装を注入すれば、DBなしでサービス層の検証ができます。
実装漏れは静的チェックで落ちる
構造が足りないと静的チェッカーが弾きます。save を持たないクラスを渡すと、mypyはこう報告します。
class BrokenRepo:
def get(self, id: int) -> str:
return "y"
register(BrokenRepo(), "a") # save が無い
error: Argument 1 to "register" has incompatible type "BrokenRepo";
expected "Repository" [arg-type]
note: "BrokenRepo" is missing following "Repository" protocol member:
note: save
「BrokenRepo is missing following Repository protocol member: save」と、欠けているメンバー名まで出ます。継承していないクラスの過不足を、実行前に検出できるのが利点です。型チェッカーはmypy以外にもあります。Meta製の高速な選択肢はPyrefly v1.0入門—Meta製の高速Python型チェッカーをmypyから移行で扱っています。
runtime_checkableは属性の有無しか見ない
Protocolは静的チェック専用です。そのままでは isinstance() の第2引数に使えません。公式ドキュメントも “Protocol classes without the @runtime_checkable decorator cannot be used as the second argument to isinstance()” と明記しています。実行時に使うには @runtime_checkable を付けます。
シグネチャは検証されない
ただし @runtime_checkable の判定は緩いです。仕様の “@runtime_checkable decorator and narrowing types by isinstance()” にある通り、メソッドや属性が存在するかだけを見ます。型シグネチャは一切チェックしません。次のコードは close という名前の属性さえあれば通ります。
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
class FakeFile:
close = "これはメソッドではなくただの文字列"
print(isinstance(FakeFile(), Closable))
True
close が文字列でも isinstance は True を返します。公式ドキュメントも ssl.SSLObject を例に、「__init__ が TypeError を出すだけで実体は呼び出せないのに Callable の issubclass チェックは通ってしまう」と警告しています。
なぜ偽陽性が起きるか
3.12で内部実装が変わり、hasattr() ではなく inspect.getattr_static() を使うようになりました。同時にProtocolメンバーはクラス生成時に固定され、後からのモンキーパッチは isinstance の結果に影響しません。それでも見るのは名前の有無だけです。実行時の構造チェックは安全網になりません。
isinstanceが遅い問題と回避
もう一つ実行時の注意があります。@runtime_checkable なProtocolへの isinstance は、通常のクラスへのチェックよりかなり重いです。公式ドキュメントも “isinstance() checks against runtime-checkable protocols can be significantly slower” と述べ、性能が要る箇所では hasattr() を勧めています。
実測する
import timeit
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
class Resource:
def close(self) -> None: ...
class Base: ...
class Concrete(Base):
def close(self) -> None: ...
N = 1_000_000
r, c = Resource(), Concrete()
print(timeit.timeit(lambda: isinstance(r, Closable), number=N))
print(timeit.timeit(lambda: hasattr(r, "close"), number=N))
print(timeit.timeit(lambda: isinstance(c, Base), number=N))
手元環境(CPython 3.14)での1回あたりの実測値。
| 判定 | 1呼び出しあたり | 名目的との比 |
|---|---|---|
isinstance(r, Closable)(Protocol) | 約120ns | 約8倍 |
hasattr(r, "close") | 約27ns | 約1.7倍 |
isinstance(c, Base)(名目的) | 約16ns | 1倍 |
Protocolへの isinstance は名目的なクラス判定の約8倍かかります。1回なら誤差ですが、ホットパスで毎要素チェックすると効いてきます。
hasattrで代替する
実行時に「close が呼べるか」を確かめたいだけなら、hasattr(obj, "close") で十分です。シグネチャを保証したい意図があるなら、そもそも実行時チェックではなく静的チェックに寄せる。実行時の isinstance は名前の有無しか見ないので、安全性の根拠にはなりません。
明示宣言で実装を強制する
Protocolは継承もできます。仕様の “Explicitly declaring implementation” にある通り、class InMemoryRepo(Repository) と明示的に継承すれば、型チェッカーがそのクラスをProtocol準拠として検証します。ただし落とし穴があります。Protocolのメソッド本体 ... がデフォルト実装として継承されるため、save を書き忘れても静的エラーになりません。空の ... を実装とみなしてしまうからです。
実装を本当に強制したいなら @abstractmethod を併用します。
from abc import abstractmethod
from typing import Protocol
class Repository(Protocol):
@abstractmethod
def get(self, id: int) -> str: ...
@abstractmethod
def save(self, value: str) -> None: ...
class InMemoryRepo(Repository):
def get(self, id: int) -> str:
return "x"
# save 未実装
InMemoryRepo()
TypeError: Can't instantiate abstract class InMemoryRepo
without an implementation for abstract method 'save'
未実装のまま InMemoryRepo() を呼ぶと実行時に TypeError で止まり、mypyも “Cannot instantiate abstract class” を出します。構造的に渡す側のチェックと、明示継承+@abstractmethod による強制は別物として使い分けます。
まとめ
Protocolの要点を整理します。
- 継承を要求せず、メソッドの構造が一致すれば型として通る。外部ライブラリの型にも後付けで適用できる
- テストでの依存差し替えと相性が良い。本番実装とインメモリ実装を同じProtocol型で受けられる
@runtime_checkableのisinstanceは名前の有無しか見ず、シグネチャは検証しない。偽陽性が起きる- Protocolへの
isinstanceは名目的判定の約8倍。性能が要る箇所はhasattrで代替する - 明示継承は
...をデフォルト実装として継承する。実装を強制するなら@abstractmethodを併用する
静的チェックの網を細かくしたい場面ではProtocol、継承ツリーで実装を縛りたい場面ではABC。判定方式が名目的か構造的か。そこを基準にすれば、どちらを使うかは決まります。

