PythonのProtocol:静的ダックタイピングによる静的型チェックとコードアーキテクチャの最適化

Python tutorial - IT technology blog
Python tutorial - IT technology blog

ProtocolはABCやisinstance()の代わりにいつ使うべきか?

私はPythonをほぼすべての日常タスクの自動化ツールとして使っています。デプロイスクリプトからモニタリングアラートまで様々です。よく直面する問題が、コードを書いてテストが通っても、新しいコンポーネントをシステムに統合したときに実行時エラーが発生することです。多くの場合、あるクラスにコードが期待するメソッドが不足しているのが原因です。

Pythonはもともとダックタイピングの言語です。「アヒルのように鳴くなら、それはアヒルだ」という考え方ですね。Pythonはオブジェクトがどのクラスに属するかを気にせず、必要なメソッドさえあれば問題ありません。しかし、この検査は実行時にのみ行われます。コードが実際に動いているとき、つまりコードを書いているときではありません。

Abstract Base Class(ABC)はこの問題を部分的に解決しますが、明示的な継承が必要です。外部ライブラリと連携する場合や、疎結合なコードを維持したい場合には適していません。

Protocol(PEP 544、Python 3.8+)はまさにこの問題を解決するために生まれました。静的ダックタイピング——mypyのような型チェッカーを使って、実行時ではなくコードを書く時点でダックタイピングを検証できます。

環境のセットアップ

ProtocolはPython 3.8からtypingモジュールに組み込まれています。Python 3.7以下を使っている場合は、typing_extensionsを追加インストールする必要があります:

pip install typing_extensions mypy

Pythonとmypyのバージョンを確認します:

python --version
# Python 3.8+であれば十分、Protocolはstdlibに含まれている

pip install mypy
mypy --version

mypyはPythonの公式静的型チェッカーです。コードを書く段階でProtocolの違反を検出するために使用します。

Protocolの定義と使い方 — 詳細設定

1. 基本的なProtocol

ABCとregister()を使った継承の代わりに、オブジェクトに期待する「形」を表すProtocolを定義します:

from typing import Protocol

class Serializable(Protocol):
    def to_json(self) -> str:
        ...

    def from_json(self, data: str) -> None:
        ...

Protocol内のメソッドボディは通常...です。これはインターフェースの定義であり、実装ではありません。

2. 関数シグネチャでProtocolを使う

def save_to_file(obj: Serializable, filepath: str) -> None:
    data = obj.to_json()
    with open(filepath, 'w') as f:
        f.write(data)

# このクラスはSerializableを継承していないが、
# 必要なメソッドを持っているためmypyは受け入れる
class UserConfig:
    def __init__(self, username: str, theme: str):
        self.username = username
        self.theme = theme

    def to_json(self) -> str:
        import json
        return json.dumps({"username": self.username, "theme": self.theme})

    def from_json(self, data: str) -> None:
        import json
        d = json.loads(data)
        self.username = d["username"]
        self.theme = d["theme"]

config = UserConfig("admin", "dark")
save_to_file(config, "/tmp/config.json")  # mypyで問題なし!

ここが核心です:UserConfigclass UserConfig(Serializable)は不要です。正しいメソッドさえあれば、mypyは受け入れます。

3. @runtime_checkableを使ったProtocol

デフォルトでProtocolは静的型チェッカーでのみ機能します。実行時にisinstance()を使いたい場合は、@runtime_checkableデコレーターを追加します:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None:
        ...

class DatabaseConnection:
    def close(self) -> None:
        print("DB connection closed")

db = DatabaseConnection()
print(isinstance(db, Closeable))      # True
print(isinstance("hello", Closeable)) # False

重要な注意点@runtime_checkable付きのisinstance()はメソッドの存在のみを確認し、シグネチャ(引数の型、戻り値の型)は検証しません。シグネチャの完全な検証はmypyの役割です。

4. 実践例:アラートハンドラーのプラグインシステム

拡張可能なツールを書くときによく使うパターンです。典型的な例はモニタリングアラートシステムです:

from typing import Protocol
from dataclasses import dataclass

@dataclass
class AlertEvent:
    level: str      # "info", "warning", "critical"
    message: str
    source: str

class AlertHandler(Protocol):
    def handle(self, event: AlertEvent) -> bool:
        """処理が成功した場合はTrueを返す"""
        ...

    def is_available(self) -> bool:
        ...

# ハンドラー1:Telegramへ送信
class TelegramHandler:
    def __init__(self, bot_token: str, chat_id: str):
        self.bot_token = bot_token
        self.chat_id = chat_id

    def handle(self, event: AlertEvent) -> bool:
        print(f"[Telegram] {event.level}: {event.message}")
        return True

    def is_available(self) -> bool:
        return bool(self.bot_token and self.chat_id)

# ハンドラー2:ログファイルに書き込む
class FileLogHandler:
    def __init__(self, log_path: str):
        self.log_path = log_path

    def handle(self, event: AlertEvent) -> bool:
        with open(self.log_path, 'a') as f:
            f.write(f"[{event.level}] {event.source}: {event.message}\n")
        return True

    def is_available(self) -> bool:
        import os
        return os.access(os.path.dirname(self.log_path) or '.', os.W_OK)

# dispatch_alertはlist[AlertHandler]を受け取る — 継承は一切不要
def dispatch_alert(event: AlertEvent, handlers: list[AlertHandler]) -> None:
    for handler in handlers:
        if handler.is_available():
            success = handler.handle(event)
            if not success:
                print(f"{type(handler).__name__} failed")

# 使い方
event = AlertEvent(level="critical", message="CPU > 95%", source="server-01")
handlers: list[AlertHandler] = [
    TelegramHandler("TOKEN", "CHAT_ID"),
    FileLogHandler("/var/log/alerts.log"),
]
dispatch_alert(event, handlers)

明らかな利点として、新しいハンドラー(Slack、PagerDutyなど)を追加するには、正しい2つのメソッドを持つクラスを作るだけでよく、既存のコードを修正する必要がありません。これはオープン/クローズドの原則に沿っています。

mypyを使った型チェックとモニタリング

mypyでエラーを早期発見する

# プロジェクト全体をチェック
mypy .

# Strictモード — より多くのエラーを検出
mypy alert_system.py --strict

Protocolを満たさないオブジェクトを渡したときにmypyが報告するエラーの例:

class BadHandler:
    def handle(self, event: AlertEvent) -> None:  # 間違い!Protocolは-> boolを要求する
        pass
    # is_available()がない

# mypyのエラー:
# error: Argument 2 to "dispatch_alert" has incompatible type
#        "list[BadHandler]"; expected "list[AlertHandler]"

pyproject.tomlでmypyを設定する

[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

CI/CDへの統合

# GitHub Actionsで
pip install mypy
mypy . --exit-code  # 型エラーがある場合はexit code != 0

# pytestと組み合わせる
pytest tests/ && mypy .

私はコミット前にエラーを検出するため、mypyをpre-commitフックに追加しています:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

ProtocolとABCの使い分け

  • Protocol:疎結合にしたい、継承を強制したくない、サードパーティのコードと連携する場合
  • ABC:密結合でよい、@abstractmethodで実装を強制したい、super()でコードを共有したい場合
  • TypedDict:固定した構造のdictを扱う場合(オブジェクトではない)

私の場合、プラグインシステムや明確に分離すべきコンポーネントのインターフェースを設計する際は、Protocolをデフォルトの選択肢としています。ABCは継承によって実装を共有したいときだけ使います。

Share: