Protocol とダックタイピング

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.StringIOread() を持っているのに、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

FileReaderNetworkStreamReadable を継承していないが、read() メソッドのシグネチャが一致するため型チェッカはこれを受け入れる。これを「構造的部分型」と呼ぶ。

名目的部分型(ABC)

クラスが明示的に基底クラスを継承していなければ型が一致しない。isinstance チェックが可能。

構造的部分型(Protocol)

メソッドやアトリビュートの構造さえ合っていれば型が一致する。継承関係は問わない。

複数のメソッドを持つ 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())

readwrite の両方を持つオブジェクトだけが 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 はどちらもインターフェースを定義する手段だが、適する場面が異なる。

Protocol が向いている場面

外部ライブラリのクラスを受け入れたい場合、継承を強制したくない場合、ダックタイピングの既存コードに型を付けたい場合。

ABC が向いている場面

メソッドの実装を強制したい場合、共通の実装を基底クラスに持たせたい場合、チーム内で契約を明確にしたい場合。

どちらが優れているという話ではなく、Python のダックタイピング文化に型ヒントを自然に溶け込ませたいなら Protocol、チーム開発で厳格な契約を設けたいなら ABC、という使い分けが実用的だ。Protocol は既存のコードを壊さずに型安全性を後付けできる点で、大規模なコードベースのリファクタリングにも向いている。