Python Protocolで型安全なDI設計—ABCとの違いと構造的部分型

Python Protocolで型安全なDI設計—ABCとの違いと構造的部分型 | mohablog

継承を強制する設計には穴があります。自分が書いたクラスには基底クラスを継承させられても、外部ライブラリが返すオブジェクトには後付けできません。Pythonのtyping.Protocolは継承関係を要求せず、「必要なメソッドが生えているか」だけで型を合わせます。

目次

継承の強制が効かない場面

抽象基底クラス(ABC)でインターフェースを定義すると、実装側に継承を求めます。class MyRepo(RepositoryABC) のように、明示的に親を指定しないと型として認められません。自分のコードベースで完結するなら問題ありません。

困るのは外部の型を扱うときです。あるSDKが返すクライアントオブジェクトに、自分で定義したABCを継承させることはできません。そのクラスのソースは書き換えられないからです。結果、isinstance も型注釈も使えず、ラッパークラスを1枚かませる羽目になります。外部SDKのクライアントをABCでラップしようとして継承できず詰まり、Protocolに切り替えたら型注釈だけで通った、という経験があります。

構造で判定すれば、この制約は消えます。「close() を持っているか」だけを見るなら、相手が誰の書いたクラスかは関係ありません。

Protocolは構造でメソッドを照合する

現行の Python 3.14.6 のドキュメントでは、Protocolは “static duck-typing” を静的型チェッカーに認識させる仕組みとされています(Protocol3.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

MarkdownHtmlRenderer を継承していません。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 型だけを知っています。PostgresRepoInMemoryRepoRepository を継承していませんが、両方そのまま渡せます。テストコードからインメモリ実装を注入すれば、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 が文字列でも isinstanceTrue を返します。公式ドキュメントも ssl.SSLObject を例に、「__init__TypeError を出すだけで実体は呼び出せないのに Callableissubclass チェックは通ってしまう」と警告しています。

なぜ偽陽性が起きるか

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)(名目的)約16ns1倍

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_checkableisinstance は名前の有無しか見ず、シグネチャは検証しない。偽陽性が起きる
  • Protocolへの isinstance は名目的判定の約8倍。性能が要る箇所は hasattr で代替する
  • 明示継承は ... をデフォルト実装として継承する。実装を強制するなら @abstractmethod を併用する

静的チェックの網を細かくしたい場面ではProtocol、継承ツリーで実装を縛りたい場面ではABC。判定方式が名目的か構造的か。そこを基準にすれば、どちらを使うかは決まります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次