概要
Protocol = 型安全な Duck Typing(Structural Subtyping)
typing.Protocol(PEP 544)は「明示的な継承なしに、メソッドシグネチャが一致すれば型互換」という Structural Subtyping を実現する。EmailNotifier が Notifier を継承していなくても、send(recipient: str, message: str) -> None を持つだけで mypy / pyright は Notifier 互換と判断する。if isinstance(x, EmailNotifier) 分岐は完全に不要になる。
@runtime_checkable で isinstance が使える
Protocol に @runtime_checkable を付けると isinstance(notifier, Notifier) が動作する。ただし実行時チェックはメソッドの存在のみ確認し、シグネチャは見ない。そのため静的型チェック(mypy/pyright)との組み合わせが必須。Fail-Fast パターンで「ループ前に全 Notifier を検証」することで、ループ内での型エラーを防ぐ。
NotificationResult = Value Object(frozen dataclass)
通知結果を list[bool] でなく @dataclass(frozen=True, slots=True) NotificationResult で表現する。success / channel / recipient / error を一つの不変オブジェクトにまとめることで、ログ集計・BigQuery 書き込み・DataDog メトリクス送信が型安全になる。frozen=True は誤った書き換えを防止し、ハッシュ可能にする。
Open/Closed 原則 — 新チャンネル追加時にコア変更ゼロ
Bad コードは新チャンネルのたびに dispatch_notification の if-elif を追加していた。Good コードは send(recipient, message) メソッドを持つクラスを追加するだけで動作する。send_campaign_notifications は一切変更不要。これが Open/Closed 原則(拡張に対してオープン、変更に対してクローズ)の実践。
問題
ECサイト MOps チームの通知システムを設計している。現在は各通知チャンネル(メール・SMS・Slack)が if isinstance(notifier, EmailNotifier) のような型チェックで分岐されており、新チャンネル追加のたびに分岐が増殖している。
以下の「悪いコード」を分析し、Protocol / ABC / runtime_checkable / Structural Subtyping を活用して Bad→Good にリファクタリングせよ。
制約・前提条件
- Python 3.12+
typing.Protocolと@runtime_checkableを使い、構造的サブタイピングで Notifier インターフェースを定義すること- 各 Notifier クラスは Protocol を継承せずとも静的型チェックで互換とみなされること(Structural Subtyping)
dataclass(frozen=True)でNotificationResultを定義し、成功/失敗・チャンネル・受信者・エラーを記録すること- エラーは
loggingで記録し、握りつぶさないこと - Google スタイル docstring・インラインコメント・名前付き定数を含めること
send_campaign_notificationsは Fail-Fast で未知の Notifier を事前検出できること
悪いコード (Before)
import smtplib
from email.mime.text import MIMEText
class EmailNotifier:
def __init__(self, smtp_host, port, user, password):
self.smtp_host = smtp_host
self.port = port
self.user = user
self.password = password
def send(self, to, message):
# 問題1: 型ヒントなし。何が渡されるか不明
msg = MIMEText(message)
msg["From"] = self.user
msg["To"] = to
msg["Subject"] = "通知" # 問題2: マジックストリング
with smtplib.SMTP(self.smtp_host, self.port) as server:
server.login(self.user, self.password)
server.sendmail(self.user, to, msg.as_string())
class SmsNotifier:
def __init__(self, api_key, from_number):
self.api_key = api_key
self.from_number = from_number
def send_sms(self, phone, text):
# 問題3: メソッド名が統一されていない(send_sms)
print(f"SMS to {phone}: {text}")
class SlackNotifier:
def notify(self, channel, msg):
# 問題3: メソッド名が統一されていない(notify)
print(f"Slack #{channel}: {msg}")
def dispatch_notification(notifier, recipient, message):
# 問題4: isinstance チェックで分岐。新チャンネルのたびに変更が必要
if isinstance(notifier, EmailNotifier):
notifier.send(recipient, message)
elif isinstance(notifier, SmsNotifier):
notifier.send_sms(recipient, message)
elif isinstance(notifier, SlackNotifier):
notifier.notify(recipient, message)
else:
raise ValueError(f"未知の Notifier: {type(notifier)}")
def send_campaign_notifications(notifiers, recipients, message):
# 問題5: 型ヒントなし
results = []
for notifier in notifiers:
for recipient in recipients:
try:
dispatch_notification(notifier, recipient, message)
results.append(True)
except Exception:
results.append(False)
# 問題6: エラーを握りつぶしている。失敗原因が追跡不能
return results
ヒント
ヒント1: 方向性(クリックで展開)
dispatch_notification の if isinstance(...) 分岐を撲滅するには、全 Notifier クラスに 統一されたメソッドシグネチャ を持たせる必要がある。Python で「明示的な継承なしに型互換を保証する」メカニズムを探してみよう。
キーワード: typing.Protocol (PEP 544) / Structural Subtyping / Duck Typing
ヒント2: アプローチ(クリックで展開)
typing.Protocol を使うと、クラスが Protocol を継承していなくても「同じメソッドシグネチャを持っていれば型互換」とみなせる(Structural Subtyping)。
@runtime_checkable デコレータを付けると isinstance() によるランタイムチェックも可能になる。
統一するメソッドシグネチャ:
def send(self, recipient: str, message: str) -> None: ...
ヒント3: 骨格コード(クリックで展開)
from typing import Protocol, runtime_checkable
from dataclasses import dataclass
@runtime_checkable
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None: ...
@dataclass(frozen=True, slots=True)
class NotificationResult:
success: bool
channel: str
recipient: str
error: str | None = None
# 各クラスは Notifier を継承しない — send() があれば型互換
class SmsNotifier:
def send(self, recipient: str, message: str) -> None: ...
# dispatch_notification は不要になる!
# send_campaign_notifications が直接 notifier.send() を呼ぶ
問題点分析(6つ)
全クラスのメソッドに型ヒントがなく、引数・戻り値が不明。mypy/pyright が何もチェックできない。→ def send(self, recipient: str, message: str) -> None: で明示。
"通知" がハードコードされている。変更が必要になったとき検索・修正が困難。→ DEFAULT_SUBJECT: Final[str] = "MOps キャンペーン通知" で名前付き定数化。
send / send_sms / notify と三者三様。dispatch_notification が各クラスのメソッド名を知らなければ呼べない。→ typing.Protocol で send() に統一。
新チャンネル追加のたびに dispatch_notification を修正する必要がある。→ Protocol で統一されたシグネチャを定義することで dispatch_notification 自体が不要になる。
失敗したのがどのチャンネル・どの受信者か分からない。→ NotificationResult(frozen=True) Value Object で success/channel/recipient/error を記録。
except Exception: results.append(False) でエラー情報がすべて消える。DataDog / Cloud Logging で障害を追跡不能。→ logger.error(..., exc_info=True) で記録し、NotificationResult(error=str(exc)) に格納。
設計パターン図(Before / After)
左: isinstance 分岐(新チャンネルのたびに dispatch 変更が必要)/ 右: Protocol で統一(新チャンネルは send() を持つクラスを追加するだけ)
模範解答
import smtplib
from email.mime.text import MIMEText
class EmailNotifier:
def __init__(self, smtp_host, port, user, password):
...
def send(self, to, message): # ❌ 型ヒントなし
msg = MIMEText(message)
msg["Subject"] = "通知" # ❌ マジックストリング
...
class SmsNotifier:
def send_sms(self, phone, text): # ❌ メソッド名バラバラ
...
class SlackNotifier:
def notify(self, channel, msg): # ❌ メソッド名バラバラ
...
def dispatch_notification(notifier, recipient, message):
if isinstance(notifier, EmailNotifier): # ❌ OCP違反
notifier.send(recipient, message)
elif isinstance(notifier, SmsNotifier):
notifier.send_sms(recipient, message)
elif isinstance(notifier, SlackNotifier):
notifier.notify(recipient, message)
else:
raise ValueError(...)
def send_campaign_notifications(notifiers, recipients, message):
# ❌ 型ヒントなし / 戻り値 list[bool](情報不足)
results = []
for notifier in notifiers:
for recipient in recipients:
try:
dispatch_notification(notifier, recipient, message)
results.append(True)
except Exception:
results.append(False) # ❌ エラー握りつぶし
return results
"""MOps 通知システム — Protocol + Structural Subtyping による型安全通知.
適用パターン:
Strategy (Ch4): 振る舞いを Protocol で抽象化
Value Object (Ch2): NotificationResult
Protocol / Structural Subtyping (Ch10)
"""
from __future__ import annotations
import logging
import smtplib
from dataclasses import dataclass
from email.mime.text import MIMEText
from typing import Final, Protocol, runtime_checkable
logger = logging.getLogger(__name__)
# ── 定数(マジックストリング廃絶)────────────────────
DEFAULT_SUBJECT: Final[str] = "MOps キャンペーン通知"
EMAIL_ENCODING: Final[str] = "utf-8"
# ── Value Object ─────────────────────────────────────
@dataclass(frozen=True, slots=True)
class NotificationResult:
"""通知結果 Value Object(不変・ハッシュ可能).
Attributes:
success: 通知が成功したか
channel: チャンネル名("EmailNotifier" 等)
recipient: 送信先識別子
error: 失敗時エラーメッセージ(成功時 None)
"""
success: bool
channel: str
recipient: str
error: str | None = None
# ── Notifier Protocol ────────────────────────────────
@runtime_checkable # isinstance(x, Notifier) を有効化
class Notifier(Protocol):
"""マルチチャンネル通知インターフェース.
Structural Subtyping: このクラスを継承しなくても
``send()`` シグネチャが一致すれば型互換とみなされる。
"""
def send(self, recipient: str, message: str) -> None:
"""通知を送信する.
Args:
recipient: 送信先識別子
message: 通知本文
"""
...
# ── 各実装(Protocol を継承しない — 構造的互換)────────
class EmailNotifier:
"""SMTP メール通知チャンネル."""
def __init__(
self, smtp_host: str, port: int,
user: str, password: str,
subject: str = DEFAULT_SUBJECT,
) -> None:
self._smtp_host = smtp_host
self._port = port
self._user = user
self._password = password
self._subject = subject # 名前付き定数をデフォルト値に
def send(self, recipient: str, message: str) -> None:
"""メールを送信する.
Args:
recipient: 送信先メールアドレス
message: メール本文
Raises:
smtplib.SMTPException: SMTP 接続失敗
"""
msg = MIMEText(message, "plain", EMAIL_ENCODING)
msg["From"] = self._user
msg["To"] = recipient
msg["Subject"] = self._subject
with smtplib.SMTP(self._smtp_host, self._port) as server:
server.login(self._user, self._password)
server.sendmail(self._user, recipient, msg.as_string())
logger.info("email sent", extra={"to": recipient})
class SmsNotifier:
"""SMS 通知チャンネル(Twilio 等).
Bad: send_sms() → Good: send() に統一
"""
def __init__(self, api_key: str, from_number: str) -> None:
self._api_key = api_key
self._from_number = from_number
def send(self, recipient: str, message: str) -> None:
"""SMS を送信する.
Args:
recipient: 電話番号(E.164形式)
message: SMS 本文
"""
# 実際は Twilio SDK: client.messages.create(...)
logger.info("sms sent", extra={"to": recipient})
class SlackNotifier:
"""Slack 通知チャンネル.
Bad: notify() → Good: send() に統一
"""
def __init__(self, webhook_url: str) -> None:
self._webhook_url = webhook_url
def send(self, recipient: str, message: str) -> None:
"""Slack チャンネルに通知する.
Args:
recipient: チャンネル名(例: "#mops-alert")
message: 通知本文
"""
# 実際は requests.post(self._webhook_url, ...)
logger.info("slack sent", extra={"channel": recipient})
# ── エントリーポイント ────────────────────────────────
def send_campaign_notifications(
notifiers: list[Notifier], # 型安全な引数
recipients: list[str],
message: str,
) -> list[NotificationResult]: # 情報豊富な戻り値
"""複数チャンネル × 複数受信者へキャンペーン通知を送信する.
dispatch_notification は不要: 全 Notifier が send() を持つため
直接呼び出せる(ポリモーフィズム)。
Args:
notifiers: 通知チャンネルリスト(Notifier 互換)
recipients: 送信先リスト
message: 通知本文
Returns:
各 (notifier × recipient) の NotificationResult
Raises:
TypeError: Notifier 非互換オブジェクトが含まれる場合(Fail-Fast)
"""
# Fail-Fast: ループ前に全 Notifier の互換性を一括検証
for notifier in notifiers:
if not isinstance(notifier, Notifier):
raise TypeError(
f"{type(notifier).__name__} は Notifier Protocol に非互換。"
f"send(recipient: str, message: str) -> None を実装してください。"
)
results: list[NotificationResult] = []
for notifier in notifiers:
channel_name = type(notifier).__name__
for recipient in recipients:
try:
notifier.send(recipient, message) # dispatch 不要!
results.append(NotificationResult(
success=True,
channel=channel_name,
recipient=recipient,
))
except Exception as exc:
# エラーを握りつぶさず logging で記録する
logger.error(
"notification failed",
extra={
"channel": channel_name,
"recipient": recipient,
"error": str(exc),
},
exc_info=True, # スタックトレースも記録
)
results.append(NotificationResult(
success=False,
channel=channel_name,
recipient=recipient,
error=str(exc), # Result に格納して呼び出し元へ
))
return results
# ── 実行例 ────────────────────────────────────────────
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(message)s",
)
notifiers: list[Notifier] = [
SmsNotifier(api_key="dummy_key", from_number="+810000000000"),
SlackNotifier(webhook_url="https://hooks.slack.com/dummy"),
]
results = send_campaign_notifications(
notifiers=notifiers,
recipients=["#mops-alert", "+818012345678"],
message="【キャンペーン】今週末限定セール開催中!",
)
for r in results:
status = "✅" if r.success else "❌"
print(f"{status} [{r.channel}] → {r.recipient}")
if r.error:
print(f" エラー: {r.error}")
INFO sms sent {'to': '#mops-alert'}
INFO slack sent {'channel': '#mops-alert'}
INFO sms sent {'to': '+818012345678'}
INFO slack sent {'channel': '+818012345678'}
✅ [SmsNotifier] → #mops-alert
✅ [SlackNotifier] → #mops-alert
✅ [SmsNotifier] → +818012345678
✅ [SlackNotifier] → +818012345678
ポイント解説
-
Protocol + Structural Subtyping(Ch10)
@runtime_checkable class Notifier(Protocol)は「継承なしに型互換を保証する」仕組み。mypy/pyright はsend()シグネチャの一致だけで互換性を判断する。EmailNotifierはNotifierを一切インポートしなくて良い — これが ABC(抽象基底クラス)との最大の違い。 -
@runtime_checkable の限界を理解する
isinstance(notifier, Notifier)はメソッドの存在のみチェックする。シグネチャ(引数の型・数)は確認しない。そのため静的型チェック(mypy--strict)との組み合わせが必須。Fail-Fast はあくまで「完全に無関係なオブジェクト」を除外するための第一防衛線。 -
メソッド名の統一で dispatch 関数を廃絶(Ch4/Ch5)
Bad コードの
dispatch_notificationは「型ごとに異なるメソッド名を知っている」責務を担う神クラスだった。Protocol でsend()に統一することで、呼び出し側はnotifier.send(r, m)と書くだけ。新チャンネル追加時にコアロジックを一切変更しなくてよい。 -
NotificationResult Value Object(Ch2)
frozen=True, slots=Trueの dataclass は不変で軽量な Value Object。list[bool]と比較すると「どのチャンネルが・誰への・どんなエラーで失敗したか」が一つのオブジェクトに収まり、BigQuery 書き込み・DataDog メトリクス・Slack アラートへの接続が型安全になる。 -
Fail-Fast + エラーを握りつぶさない(Ch1・実務原則)
ループ前に全 Notifier の互換性を検証することで「途中で初めて TypeError」を防ぐ。各 send() は try-except で囲むが、
except Exception: passは禁止。logger.error(..., exc_info=True)でスタックトレースを記録し、NotificationResult(error=...))で呼び出し元に伝播する。
実務への応用(MOps / ECサイト)
from typing import Any
def run_campaign_task(campaign_id: str, channel_config: dict[str, Any]) -> None:
"""Argo Workflows から呼び出されるキャンペーン配信タスク."""
notifiers: list[Notifier] = []
if email_cfg := channel_config.get("email"):
notifiers.append(EmailNotifier(**email_cfg))
if sms_cfg := channel_config.get("sms"):
notifiers.append(SmsNotifier(**sms_cfg))
if slack_cfg := channel_config.get("slack"):
notifiers.append(SlackNotifier(webhook_url=slack_cfg["webhook"]))
recipients = fetch_recipients(campaign_id) # BigQuery から対象者取得
results = send_campaign_notifications(
notifiers, recipients, message="今週末限定セール!"
)
# 結果を BigQuery に書き込み
write_results_to_bq(campaign_id, results)
# 失敗率を DataDog メトリクスに送信
failures = [r for r in results if not r.success]
if failures:
statsd.gauge(
"campaign.notification.failure_rate",
value=len(failures) / len(results),
tags=[f"campaign:{campaign_id}"],
)
# 新チャンネル(LINE Notify 等)追加 → NotifierProtocol を実装するだけ
# send_campaign_notifications は変更不要
- Protocol(Structural): 外部ライブラリのクラスや複数リポジトリ間の連携に最適。継承の縛りなし。
- ABC(Nominal): 同一パッケージ内で「必ず継承させたい」場合(
@abstractmethodでオーバーライド漏れを強制検出)。 - ECサイト MOps: チャンネルは将来的に社外 SDK(LINE SDK, Firebase等)を使うため Protocol が適切。
今日のまとめ
typing.Protocol + @runtime_checkable で「明示的な継承なしに型互換を保証する Structural Subtyping」を実現し、if isinstance(...) 分岐を廃絶してポリモーフィズムに置き換えることで、Open/Closed 原則を満たした拡張可能な通知システムを設計できる。
新チャンネル追加時は send(recipient: str, message: str) -> None を持つクラスを追加するだけ — コアロジック変更ゼロ。