A コーディング — Protocol 構造的サブタイピング × TypeVar[bound=NotificationChannel] × frozen dataclass slots=True SendResult × StrEnum ChannelType × match 文 × functools.reduce(マルチチャネル通知 Bad→Good Ch2/Ch4/Ch6/Ch7/Ch10/Ch11)

2026-06-22 (Day 77) 月曜 コーディング ★★★★☆ Python 3.12 / Protocol / TypeVar / frozen dataclass / StrEnum / match / functools.reduce 良いコード設計入門 Ch2 / Ch4 / Ch6 / Ch7 / Ch10 / Ch11

概要

🔌

Protocol で構造的サブタイピング(Ch6)

class NotificationChannel(Protocol): でインターフェースを定義。具体クラスが Protocol を明示継承しなくても、send()channel_type を実装していれば型互換とみなされる(鴨型付け)。Open/Closed 原則を自然に実現し、新チャネル追加時に既存コードの変更ゼロを保証する。

🔒

frozen dataclass slots=True で値オブジェクト(Ch4)

@dataclass(frozen=True, slots=True)SendResultFrozenInstanceError で書き換え禁止・__dict__ なしで軽量。failed_ids: tuple[str, ...]list だと hashable にならないため tuple を選択する。

🏷️

StrEnum × match 文でマジックストリング排除(Ch2/Ch11)

class ChannelType(StrEnum): EMAIL = "email" ... でチャネル種別を型安全に管理。match channel_type: case ChannelType.EMAIL: return "high" ...case _: raise ValueError で新チャネル追加時のハンドリング漏れを実行時に検出できる。

🔢

functools.reduce で中間リストなし集計(Ch11)

functools.reduce(merge, results, initial) で送信件数・成功件数を集計。list.extend を繰り返すよりメモリ効率が高く、initial を指定することで空リスト時も安全に動作する。

問題

ECサイト MOps チームでは、複数チャネル(メール・プッシュ通知・LINE)からキャンペーン通知を送信するバッチを Python で実装している。以下の「悪いコード」は if/elif の分岐でチャネルごとの処理を行い、結果を dict で返す実装です。

問題点を全て洗い出しProtocolTypeVar[bound=NotificationChannel]@dataclass(frozen=True, slots=True)StrEnummatch 文・functools.reduce を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+(PEP 695 型エイリアス構文使用可)
  • Protocolsend() インターフェースを定義し、isinstance チェックを廃止すること
  • TypeVar[bound=NotificationChannel] で型安全なディスパッチ関数を定義すること
  • 送信結果は @dataclass(frozen=True, slots=True)SendResult で返すこと
  • StrEnum ChannelType でチャネル種別を型安全に管理すること
  • match 文でチャネル種別に応じた優先度ラベルを返すこと
  • functools.reduce で全チャネルの送信件数・成功件数を集計すること
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と書籍対応章

悪いコード (Before)

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_notification_dispatcher.py — isinstance 分岐・マジックストリング・dict 返し・集計がベタ書き
import logging

logger = logging.getLogger(__name__)

# 問題1: マジックストリング — "email", "push", "line" が散在
CHANNELS = ["email", "push", "line"]

def send_email(recipient_ids, message, campaign_id):
    # 問題2: 型ヒントなし・返り値が dict(型安全性ゼロ)
    logger.info("Email: %d 件送信", len(recipient_ids))
    success = recipient_ids[:90]
    failed  = recipient_ids[90:]
    return {"sent": len(recipient_ids), "success": len(success), "failed": failed}

def send_push(recipient_ids, message, campaign_id):
    logger.info("Push: %d 件送信", len(recipient_ids))
    success = recipient_ids[:95]
    failed  = recipient_ids[95:]
    return {"sent": len(recipient_ids), "success": len(success), "failed": failed}

def send_line(recipient_ids, message, campaign_id):
    logger.info("LINE: %d 件送信", len(recipient_ids))
    success = recipient_ids[:85]
    failed  = recipient_ids[85:]
    return {"sent": len(recipient_ids), "success": len(success), "failed": failed}

def run_campaign(campaign_id, recipient_ids, message, channels):
    results = []
    for ch in channels:
        # 問題3: isinstance/str 比較で Open/Closed 原則違反
        if ch == "email":
            r = send_email(recipient_ids, message, campaign_id)
            r["channel"] = "email"
            # 問題4: 優先度が if/elif でハードコード
            r["priority"] = "high"
        elif ch == "push":
            r = send_push(recipient_ids, message, campaign_id)
            r["channel"] = "push"
            r["priority"] = "medium"
        elif ch == "line":
            r = send_line(recipient_ids, message, campaign_id)
            r["channel"] = "line"
            r["priority"] = "low"
        else:
            # 問題5: 新チャネル追加時にここが silent に通り抜ける
            logger.warning("未知のチャネル: %s", ch)
            continue
        results.append(r)

    # 問題6: 集計がベタ書き for ループ(functools.reduce 未使用)
    total_sent = 0
    total_success = 0
    total_failed = 0
    for r in results:
        total_sent    += r["sent"]
        total_success += r["success"]
        # 問題7: r["failed"] は list — イミュータブルでなく後から変更可能
        total_failed  += len(r["failed"])

    return {
        "total_sent":    total_sent,
        "total_success": total_success,
        "total_failed":  total_failed,
    }
問題点サマリー(7点)
1マジックストリング(Ch2)"email", "push", "line" が散在。StrEnum ChannelType で型安全に管理
2型ヒントなし・dict 返し(Ch2/Ch4) — 引数・返り値に型情報がない。frozen dataclass SendResult で型安全な値オブジェクトに
3isinstance/str 比較で OCP 違反(Ch6) — 新チャネル追加のたびに elif を追記。Protocol で dispatch に統一し変更ゼロに
4優先度が if/elif でハードコード(Ch7/Ch11)match 文 + case _: raise ValueError で網羅性チェック付きに
5未知チャネルが silent continue(Ch10) — エラーを握りつぶす。ValueError raise で早期検出(Fail-Fast)
6集計がベタ書き for ループ(Ch11)functools.reduce(merge, results, initial) で宣言的・メモリ効率的に
7failed が mutable list(Ch4) — 後から変更可能でバグの温床。tuple[str, ...] でイミュータブルに

ヒント(段階的開示)

ヒント1 — 方向性
Protocoltyping.Protocol を継承したクラスで、メソッドシグネチャだけを定義する「インターフェース」。具体クラスが Protocol を明示継承しなくても、メソッドが一致すれば型チェッカーが互換性を認識する(構造的サブタイピング)。isinstance(ch, EmailChannel) の分岐を廃止して ch.send(payload) で統一できる。match 文の case _: raise ValueError で未知チャネルの silent スルーを防ぐ。
ヒント2 — アプローチ
  • class ChannelType(StrEnum): EMAIL = "email"; PUSH = "push"; LINE = "line" でマジックストリング排除
  • class NotificationPayload(BaseModel): recipient_ids: list[str] = Field(min_length=1) で外部入力バリデーション
  • @dataclass(frozen=True, slots=True) class SendResult: failed_ids: tuple[str, ...] で値オブジェクト
  • class NotificationChannel(Protocol): def send(self, payload: NotificationPayload) -> SendResult: ...
  • ChannelT = TypeVar("ChannelT", bound=NotificationChannel) で型安全なディスパッチ関数
  • match channel_type: case ChannelType.EMAIL: return "high" ... case _: raise ValueError
  • functools.reduce(lambda acc, r: {**acc, "total_sent": acc["total_sent"] + r.sent_count}, results, initial)
ヒント3 — コードの骨格
from typing import Protocol, TypeVar
from enum import StrEnum
from dataclasses import dataclass
import functools

class ChannelType(StrEnum):
    EMAIL = "email"; PUSH = "push"; LINE = "line"

@dataclass(frozen=True, slots=True)
class SendResult:
    channel_type: ChannelType
    sent_count: int
    success_count: int
    failed_ids: tuple[str, ...]

    @property
    def success_rate(self) -> float:
        return self.success_count / self.sent_count if self.sent_count else 0.0

    @property
    def failure_count(self) -> int:
        return self.sent_count - self.success_count

class NotificationChannel(Protocol):
    @property
    def channel_type(self) -> ChannelType: ...
    def send(self, payload: NotificationPayload) -> SendResult: ...

ChannelT = TypeVar("ChannelT", bound=NotificationChannel)

def priority_label(channel_type: ChannelType) -> str:
    match channel_type:
        case ChannelType.EMAIL: return "high"
        case ChannelType.PUSH:  return "medium"
        case ChannelType.LINE:  return "low"
        case _: raise ValueError(f"未知チャネル: {channel_type!r}")

def dispatch(channel: ChannelT, payload: NotificationPayload) -> SendResult:
    return channel.send(payload)

def aggregate_results(results: list[SendResult]) -> dict[str, int]:
    def merge(acc: dict[str, int], r: SendResult) -> dict[str, int]:
        return {
            "total_sent":    acc["total_sent"]    + r.sent_count,
            "total_success": acc["total_success"] + r.success_count,
            "total_failed":  acc["total_failed"]  + r.failure_count,
        }
    return functools.reduce(merge, results, {"total_sent": 0, "total_success": 0, "total_failed": 0})

問題点分析(7点)

#問題点分類改善方法
1マジックストリング散在型安全 Ch2StrEnum ChannelType
2型ヒントなし・dict 返し型安全 Ch2/Ch4frozen dataclass SendResult
3str 比較で OCP 違反設計原則 Ch6Protocol で dispatch 統一
4優先度が if/elif ハードコード設計 Ch7/Ch11match 文 + case _: raise ValueError
5未知チャネルが silent continueFail-Fast Ch10ValueError raise で早期検出
6集計がベタ書き for ループ関数型 Ch11functools.reduce で宣言的に
7failed が mutable list不変性 Ch4tuple[str, ...] でイミュータブルに

模範解答

Before — isinstance 分岐・マジックストリング・dict・集計ベタ書き
# 問題1: マジックストリング
CHANNELS = ["email", "push", "line"]

def send_email(recipient_ids, message, campaign_id):
    # 問題2: 型ヒントなし・dict 返し
    success = recipient_ids[:90]
    failed  = recipient_ids[90:]
    return {"sent": len(recipient_ids), "success": len(success), "failed": failed}

def run_campaign(campaign_id, recipient_ids, message, channels):
    results = []
    for ch in channels:
        # 問題3: str 比較で OCP 違反
        if ch == "email":
            r = send_email(recipient_ids, message, campaign_id)
            r["channel"] = "email"
            # 問題4: 優先度が if/elif ハードコード
            r["priority"] = "high"
        elif ch == "push":
            ...
        else:
            # 問題5: 未知チャネルが silent continue
            logger.warning("未知: %s", ch)
            continue
        results.append(r)

    # 問題6: 集計がベタ書き for ループ
    total_sent = total_success = total_failed = 0
    for r in results:
        total_sent    += r["sent"]
        total_success += r["success"]
        # 問題7: r["failed"] が mutable list
        total_failed  += len(r["failed"])
    return {"total_sent": total_sent, ...}
After — Protocol × TypeVar × frozen dataclass × StrEnum × match × reduce
"""notification_dispatcher.py — マルチチャネル通知ディスパッチャ

Ch2:  StrEnum ChannelType / Protocol 構造的サブタイピング
Ch4:  frozen dataclass slots=True SendResult(値オブジェクト)
Ch6:  Protocol + TypeVar[bound=NotificationChannel](Open/Closed 原則)
Ch7:  名前付き定数(MAX_RETRY_COUNT, DEFAULT_PRIORITY)
Ch10: SendResult.failed_ids で部分失敗を型安全に保持
Ch11: match 文 優先度ラベル / functools.reduce 集計
"""
from __future__ import annotations
import functools, logging
from dataclasses import dataclass
from enum import StrEnum
from typing import Final, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)

# ── 名前付き定数(Ch7)
MAX_RETRY_COUNT: Final[int] = 3
DEFAULT_PRIORITY: Final[str] = "medium"

# ── StrEnum(Ch2)
class ChannelType(StrEnum):
    EMAIL = "email"
    PUSH  = "push"
    LINE  = "line"

# ── 入力バリデーション(Pydantic v2)
class NotificationPayload(BaseModel):
    model_config = {"extra": "ignore"}
    campaign_id: str
    recipient_ids: list[str] = Field(min_length=1)  # 空リスト禁止
    message: str = Field(min_length=1)

# ── 値オブジェクト(Ch4)
@dataclass(frozen=True, slots=True)  # frozen: 書き換え禁止 / slots: 軽量化
class SendResult:
    """送信結果のイミュータブル値オブジェクト。"""
    channel_type: ChannelType
    sent_count: int
    success_count: int
    failed_ids: tuple[str, ...]  # tuple: hashable かつ slots 対応

    @property
    def success_rate(self) -> float:
        """成功率(ゼロ除算ガード付き)。"""
        return self.success_count / self.sent_count if self.sent_count else 0.0

    @property
    def failure_count(self) -> int:
        return self.sent_count - self.success_count

# ── Protocol(Ch6 / Ch2)
@runtime_checkable  # isinstance が必要な場合のみ
class NotificationChannel(Protocol):
    """通知チャネルのインターフェース(構造的サブタイピング)。"""
    @property
    def channel_type(self) -> ChannelType: ...
    def send(self, payload: NotificationPayload) -> SendResult: ...

# ── TypeVar(bound で Protocol を制約)
ChannelT = TypeVar("ChannelT", bound=NotificationChannel)

# ── 具体チャネル(Protocol を明示継承しなくてよい)
class EmailChannel:
    @property
    def channel_type(self) -> ChannelType:
        return ChannelType.EMAIL

    def send(self, payload: NotificationPayload) -> SendResult:
        logger.info("[Email] %s: %d 件", payload.campaign_id, len(payload.recipient_ids))
        success_ids = payload.recipient_ids[:90]   # stub: 90% 成功
        failed_ids  = tuple(payload.recipient_ids[90:])
        return SendResult(
            channel_type=ChannelType.EMAIL,
            sent_count=len(payload.recipient_ids),
            success_count=len(success_ids),
            failed_ids=failed_ids,
        )

class PushChannel:
    @property
    def channel_type(self) -> ChannelType:
        return ChannelType.PUSH

    def send(self, payload: NotificationPayload) -> SendResult:
        logger.info("[Push] %s: %d 件", payload.campaign_id, len(payload.recipient_ids))
        success_ids = payload.recipient_ids[:95]   # stub: 95% 成功
        failed_ids  = tuple(payload.recipient_ids[95:])
        return SendResult(
            channel_type=ChannelType.PUSH,
            sent_count=len(payload.recipient_ids),
            success_count=len(success_ids),
            failed_ids=failed_ids,
        )

class LineChannel:
    @property
    def channel_type(self) -> ChannelType:
        return ChannelType.LINE

    def send(self, payload: NotificationPayload) -> SendResult:
        logger.info("[LINE] %s: %d 件", payload.campaign_id, len(payload.recipient_ids))
        success_ids = payload.recipient_ids[:85]   # stub: 85% 成功
        failed_ids  = tuple(payload.recipient_ids[85:])
        return SendResult(
            channel_type=ChannelType.LINE,
            sent_count=len(payload.recipient_ids),
            success_count=len(success_ids),
            failed_ids=failed_ids,
        )

# ── 優先度ラベル(match 文 Ch11)
def priority_label(channel_type: ChannelType) -> str:
    """チャネル種別に応じた優先度ラベルを返す。

    Raises:
        ValueError: 未知チャネル種別。
    """
    match channel_type:
        case ChannelType.EMAIL:
            return "high"    # 開封率重視キャンペーンに最優先
        case ChannelType.PUSH:
            return "medium"
        case ChannelType.LINE:
            return "low"
        case _:
            # 新チャネル追加時のハンドリング漏れを Fail-Fast で検出
            raise ValueError(f"未知チャネル: {channel_type!r}")

# ── 型安全ディスパッチ(TypeVar bound)
def dispatch(channel: ChannelT, payload: NotificationPayload) -> SendResult:
    """Protocol 準拠オブジェクトに send() を委譲する薄いラッパー。"""
    label = priority_label(channel.channel_type)
    logger.debug("ch=%s priority=%s recipients=%d",
                 channel.channel_type, label, len(payload.recipient_ids))
    return channel.send(payload)

# ── 集計(functools.reduce)
def aggregate_results(results: list[SendResult]) -> dict[str, int]:
    """全 SendResult を functools.reduce で集計する。"""
    def merge(acc: dict[str, int], r: SendResult) -> dict[str, int]:
        return {
            "total_sent":    acc["total_sent"]    + r.sent_count,
            "total_success": acc["total_success"] + r.success_count,
            "total_failed":  acc["total_failed"]  + r.failure_count,
        }
    initial: dict[str, int] = {"total_sent": 0, "total_success": 0, "total_failed": 0}
    return functools.reduce(merge, results, initial)

# ── メイン処理
def run_campaign(
    campaign_id: str,
    recipient_ids: list[str],
    message: str,
    channels: list[NotificationChannel],
) -> dict[str, int]:
    """マルチチャネルにキャンペーン通知を送信し集計結果を返す。"""
    payload = NotificationPayload(
        campaign_id=campaign_id,
        recipient_ids=recipient_ids,
        message=message,
    )
    results = [dispatch(ch, payload) for ch in channels]
    for r in results:
        logger.info("ch=%s priority=%s rate=%.1f%% failed=%d",
                    r.channel_type, priority_label(r.channel_type),
                    r.success_rate * 100, r.failure_count)
    return aggregate_results(results)
import logging
logging.basicConfig(level=logging.INFO)

# 宛先 ID を 100 件生成
recipient_ids = [f"user-{i:03d}" for i in range(100)]

# チャネルを Protocol 準拠オブジェクトとして用意
channels: list[NotificationChannel] = [
    EmailChannel(),
    PushChannel(),
    LineChannel(),
]

# run_campaign 実行
summary = run_campaign(
    campaign_id="CP-2026-SUMMER",
    recipient_ids=recipient_ids,
    message="夏セール開始!今すぐチェック",
    channels=channels,
)
print(summary)
# INFO [Email] CP-2026-SUMMER: 100 件
# INFO ch=email priority=high rate=90.0% failed=10
# INFO [Push] CP-2026-SUMMER: 100 件
# INFO ch=push priority=medium rate=95.0% failed=5
# INFO [LINE] CP-2026-SUMMER: 100 件
# INFO ch=line priority=low rate=85.0% failed=15
# {'total_sent': 300, 'total_success': 270, 'total_failed': 30}

# SendResult はイミュータブル
from decimal import Decimal
email_result = EmailChannel().send(
    NotificationPayload(campaign_id="CP-001", recipient_ids=["u1", "u2"], message="test")
)
print(f"success_rate={email_result.success_rate:.0%}")  # success_rate=100%(2件で閾値未満)
print(f"failed_ids={email_result.failed_ids}")           # failed_ids=()

# frozen → 変更不可
try:
    email_result.sent_count = 999  # type: ignore
except Exception as e:
    print(f"FrozenInstanceError: {type(e).__name__}")

# 未知チャネルは ValueError(Fail-Fast)
try:
    priority_label(ChannelType("sms"))  # type: ignore
except ValueError as e:
    print(f"ValueError: {e}")

# ChannelType は str 互換(StrEnum)
print(f"EMAIL == 'email': {ChannelType.EMAIL == 'email'}")  # True
print(f"JSON 出力: {ChannelType.EMAIL!r}")                   # 'email'
ポイント適用した設計原則/パターン書籍対応章
StrEnum ChannelType でマジックストリング排除型の活用・マジックストリング排除Ch2
NotificationPayload(BaseModel) で入力バリデーション型の活用・入力検証Ch2
@dataclass(frozen=True, slots=True) SendResult値オブジェクト・不変性・軽量化Ch4 / Ch2
Protocol NotificationChannel で構造的サブタイピングOpen/Closed 原則・インターフェース分離Ch6
TypeVar[bound=NotificationChannel] で型安全 dispatch型の活用・ジェネリクスCh6 / Ch2
MAX_RETRY_COUNT, DEFAULT_PRIORITY 名前付き定数マジックナンバー排除Ch7
tuple[str, ...] で failed_ids をイミュータブル化不変性・型安全Ch10 / Ch4
match ... case _: raise ValueError Fail-FastFail-Fast・網羅性チェックCh10 / Ch11
functools.reduce で宣言的集計関数型プログラミング・可読性Ch11
# tests/test_notification_dispatcher.py
import pytest
from notification_dispatcher import (
    ChannelType, NotificationPayload, SendResult,
    EmailChannel, PushChannel, LineChannel,
    priority_label, dispatch, aggregate_results, run_campaign,
)

class TestChannelType:
    def test_str_compatibility(self):
        assert ChannelType.EMAIL == "email"
        assert str(ChannelType.PUSH) == "push"

class TestNotificationPayload:
    def test_valid_payload(self):
        p = NotificationPayload(campaign_id="CP-001", recipient_ids=["u1"], message="test")
        assert p.campaign_id == "CP-001"

    def test_empty_recipient_ids_raises(self):
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            NotificationPayload(campaign_id="CP-001", recipient_ids=[], message="test")

class TestSendResult:
    def make_result(self, **kwargs):
        defaults = dict(channel_type=ChannelType.EMAIL, sent_count=10, success_count=9, failed_ids=("u1",))
        return SendResult(**{**defaults, **kwargs})

    def test_success_rate(self):
        r = self.make_result(sent_count=10, success_count=8)
        assert r.success_rate == pytest.approx(0.8)

    def test_zero_sent_rate(self):
        r = self.make_result(sent_count=0, success_count=0)
        assert r.success_rate == 0.0

    def test_frozen(self):
        r = self.make_result()
        with pytest.raises(Exception):
            r.sent_count = 999  # type: ignore

    def test_failed_ids_is_tuple(self):
        r = self.make_result(failed_ids=("u1", "u2"))
        assert isinstance(r.failed_ids, tuple)

class TestPriorityLabel:
    def test_known_channels(self):
        assert priority_label(ChannelType.EMAIL) == "high"
        assert priority_label(ChannelType.PUSH)  == "medium"
        assert priority_label(ChannelType.LINE)  == "low"

    def test_unknown_raises(self):
        with pytest.raises(ValueError):
            priority_label(ChannelType("sms"))  # type: ignore

class TestAggregateResults:
    def test_empty_list(self):
        result = aggregate_results([])
        assert result == {"total_sent": 0, "total_success": 0, "total_failed": 0}

    def test_multiple_results(self):
        results = [
            SendResult(ChannelType.EMAIL, 100, 90, tuple(f"u{i}" for i in range(10))),
            SendResult(ChannelType.PUSH,  100, 95, tuple(f"u{i}" for i in range(5))),
        ]
        agg = aggregate_results(results)
        assert agg["total_sent"]    == 200
        assert agg["total_success"] == 185
        assert agg["total_failed"]  == 15

class TestRunCampaign:
    def test_full_run(self):
        channels = [EmailChannel(), PushChannel(), LineChannel()]
        recipients = [f"u{i}" for i in range(100)]
        summary = run_campaign("CP-TEST", recipients, "msg", channels)
        assert summary["total_sent"]    == 300
        assert summary["total_success"] == 270
        assert summary["total_failed"]  == 30

設計図(Protocol 構造的サブタイピング)

«Protocol» NotificationChannel channel_type: ChannelType send(payload) → SendResult 構造的サブタイピング EmailChannel channel_type = EMAIL send() → SendResult PushChannel channel_type = PUSH send() → SendResult LineChannel channel_type = LINE send() → SendResult @dataclass(frozen=True, slots=True) SendResult channel_type, sent_count, success_count failed_ids: tuple[str, ...] (immutable) dispatch(channel: ChannelT payload) → SendResult aggregate_results( results) → dict 明示継承不要(構造が一致すれば互換)

ポイント解説

  1. Protocol で構造的サブタイピング(Ch6): 具体クラスが Protocol を明示継承しなくても、send()channel_type を実装すれば型互換。Open/Closed 原則を自然に実現し、新チャネル追加時に dispatch()aggregate_results() の変更ゼロを保証する
  2. @dataclass(frozen=True, slots=True) SendResult(Ch4): frozen=TrueFrozenInstanceError による書き換え禁止、slots=True__dict__ を排除して軽量化。failed_ids: tuple[str, ...]list だと hashable にならないため tuple を選択
  3. StrEnum ChannelType(Ch2): "email", "push", "line" のマジックストリングを排除。StrEnumstr と互換性があるため JSON 出力・ログでも自然に使える
  4. match 文 + case _: raise ValueError(Ch10/Ch11): if/elif より可読性が高く、型チェッカーが網羅性を確認できる。case _: で Fail-Fast することで新チャネル追加時のハンドリング漏れを実行時に即検出
  5. functools.reduce で宣言的集計(Ch11): 中間リストの作成や副作用のある for ループを避けて、初期値 initial から純粋関数 merge で集計。空リスト時も initial がそのまま返るため安全

実務への応用

  • ECサイト MOps: メール・プッシュ・LINE の 3 チャネルで同一キャンペーンを送信するシナリオが典型。Protocol を使うことで 4 チャネル目(SMS, WebPush 等)を追加する際に既存の dispatch()aggregate_results() を変更ゼロで対応できる
  • Argo Workflows: 各チャネルを独立した Step として定義し、dispatch() を共通エントリポイントに使う。SendResult.failed_ids を Argo の outputs.parameters に渡してリドライブ(再送)に活用できる
  • DataDog/OTel: priority_label の結果をスパン属性(span.set_attribute("channel.priority", label))として付与し、優先度別のレイテンシ分布をダッシュボードで可視化できる
  • BigQuery 集計との対比: functools.reduce は Python 内集計の小規模利用に適する。大規模(数百万件)では BigQuery の SUM(sent_count) OVER (PARTITION BY campaign_id) ウィンドウ関数に移管する判断基準を持つこと

今日のまとめ

Protocol で構造的サブタイピングを活用することで isinstance 分岐を廃止し、新チャネル追加時のコード変更ゼロを実現。frozen dataclass slots=True + StrEnum + match 文 + functools.reduce の組み合わせで型安全・軽量・拡張しやすいマルチチャネルディスパッチャを構築できる。

自己評価(あとで記入)

理解度

自分の回答

気づき・メモ