Python は「ダックタイピング」の言語だ。あるオブジェクトが read() メソッドを持っていれば、それがファイルだろうがソケットだろうが「読み取れるもの」として扱える。しかし従来の型ヒントでは、この柔軟さを表現するのが難しかった。Protocol はダックタイピングの考え方を型の世界に持ち込み、「何を持っているか」でオブジェクトの型を判定する仕組みを提供する。
継承ベースの限界
まず、Protocol を使わない場合の問題点を見てみよう。
from abc import ABC, abstractmethod class Readable(ABC): @abstractmethod def read(self) -> str: ... class FileReader(Readable): def read(self) -> str: return "file content" def process(source: Readable) -> str: return source.read()
この方法だと、process に渡せるのは Readable を明示的に継承したクラスだけになる。標準ライブラリの io.StringIO は read() を持っているのに、Readable を継承していないから渡せない。これはダックタイピングの精神に反している。
Protocol の基本
Protocol を使えば、「このメソッドを持っていれば OK」という条件だけで型を定義できる。
from typing import Protocol class Readable(Protocol): def read(self) -> str: ...
これだけで「read() メソッドを持ち、str を返すもの」という型が定義される。継承は不要だ。
from typing import Protocol class Readable(Protocol): def read(self) -> str: ... class FileReader: def read(self) -> str: return "file content" class NetworkStream: def read(self) -> str: return "network data" def process(source: Readable) -> str: return source.read() process(FileReader()) # OK process(NetworkStream()) # OK
FileReader も NetworkStream も Readable を継承していないが、read() メソッドのシグネチャが一致するため型チェッカはこれを受け入れる。これを「構造的部分型」と呼ぶ。
クラスが明示的に基底クラスを継承していなければ型が一致しない。isinstance チェックが可能。
メソッドやアトリビュートの構造さえ合っていれば型が一致する。継承関係は問わない。
複数のメソッドを持つ Protocol
Protocol には複数のメソッドを定義できる。
from typing import Protocol class ReadWritable(Protocol): def read(self) -> str: ... def write(self, data: str) -> None: ... class Buffer: def __init__(self) -> None: self._data = "" def read(self) -> str: return self._data def write(self, data: str) -> None: self._data += data def copy(src: ReadWritable, dst: ReadWritable) -> None: dst.write(src.read())
read と write の両方を持つオブジェクトだけが ReadWritable として認められる。片方しか持たないオブジェクトを渡せば型エラーになる。
アトリビュートの Protocol
メソッドだけでなく、アトリビュートも Protocol で定義できる。
from typing import Protocol class Named(Protocol): name: str class User: def __init__(self, name: str) -> None: self.name = name class Product: def __init__(self, name: str, price: int) -> None: self.name = name self.price = price def greet(obj: Named) -> str: return f"Hello, {obj.name}!" greet(User("Alice")) # OK greet(Product("Widget", 100)) # OK — name を持つので OK
name: str というアトリビュートさえあれば、どんなクラスでも Named として扱える。
runtime_checkable で実行時チェック
Protocol はデフォルトでは静的解析のみに使われるが、runtime_checkable デコレータを付ければ isinstance チェックも可能になる。
from typing import Protocol, runtime_checkable @runtime_checkable class Closeable(Protocol): def close(self) -> None: ... class Connection: def close(self) -> None: print("closed") conn = Connection() print(isinstance(conn, Closeable)) # True
ただし、runtime_checkable による isinstance チェックはメソッドの存在確認のみを行い、シグネチャ(引数の型や戻り値の型)までは検証しない。
完全な型チェックには mypy などの静的解析ツールが必要。
実行時チェックはあくまで補助的な手段であり、型の安全性を保証するには静的解析と組み合わせるのが望ましい。
実用例:プラグインシステム
Protocol が特に威力を発揮するのは、プラグインやフックのインターフェースを定義する場面だ。
from typing import Protocol class Formatter(Protocol): def format(self, text: str) -> str: ... class UpperFormatter: def format(self, text: str) -> str: return text.upper() class MarkdownFormatter: def format(self, text: str) -> str: return f"**{text}**" def render(text: str, formatter: Formatter) -> str: return formatter.format(text) print(render("hello", UpperFormatter())) # HELLO print(render("hello", MarkdownFormatter())) # **hello**
プラグインの開発者は Formatter を継承する必要がなく、format(self, text: str) -> str というメソッドを持つクラスを書くだけでよい。ライブラリ側とプラグイン側の結合度が低く保たれるため、拡張性の高い設計になる。
Protocol と ABC の使い分け
Protocol と ABC はどちらもインターフェースを定義する手段だが、適する場面が異なる。
外部ライブラリのクラスを受け入れたい場合、継承を強制したくない場合、ダックタイピングの既存コードに型を付けたい場合。
メソッドの実装を強制したい場合、共通の実装を基底クラスに持たせたい場合、チーム内で契約を明確にしたい場合。
どちらが優れているという話ではなく、Python のダックタイピング文化に型ヒントを自然に溶け込ませたいなら Protocol、チーム開発で厳格な契約を設けたいなら ABC、という使い分けが実用的だ。Protocol は既存のコードを壊さずに型安全性を後付けできる点で、大規模なコードベースのリファクタリングにも向いている。