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で問題なし!
ここが核心です:UserConfigにclass 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は継承によって実装を共有したいときだけ使います。

