A コーディング — Protocol × match 文(CouponType)× heapq.nlargest × defaultdict × frozen dataclass × Decimal(キャンペーン売上集計 Bad→Good)

2026-06-08 (Day 63) 月曜 コーディング ★★★★☆ Python 3.12 / Protocol / match / heapq / StrEnum / Decimal / frozen dataclass 良いコード設計入門 Ch4 / Ch5 / Ch6 / Ch7

概要

🔌

Protocol でデータソースを抽象化(Ch5)

OrderRepository(Protocol) を定義し、BigQuery・ファイル・テスト用モックなど任意の実装を DI で差し込めるようにする。CampaignAnalyzer はデータソースの実装詳細に依存せず、テスト時は FakeOrderRepo でユニットテストが書ける。

🔀

match 文でクーポン分岐を集約(Ch6)

if coupon_type == "fixed": ... elif coupon_type == "percent": ... の文字列比較を match CouponType に置き換える。mypy が case _: による網羅性を検証でき、新しいクーポン種別は _apply_coupon()case 1行追加で対応できる(OCP)。

📊

heapq.nlargest で Top-N 効率化(Ch7)

sorted(...)[:N] は O(N log N) だが heapq.nlargest(K, ...) は O(N log K)。SKU数が数十万になるECサイトでは差が大きい。defaultdict でカテゴリ別集計の手動 if-else も廃絶する。

💰

Decimal × frozen dataclass で金額精度保証(Ch4)

float の浮動小数点誤差(0.1 + 0.2 != 0.3)は金額計算で致命的。Decimal + quantize(Decimal("0.01")) で円未満を制御。frozen=True, slots=True で値オブジェクトとして不変性を保証する。

問題

ECサイトの販促システムには、注文データをクーポン種別・商品カテゴリ別に集計してキャンペーン効果を算出するバッチ処理 CampaignAnalyzer がある。現在のコードには 7つの設計上の問題 がある。問題点を全て洗い出し、Protocol / match 文 / heapq.nlargest / defaultdict / frozen dataclass / Decimal を適用してリファクタリングせよ。

制約・前提条件

  • Python 3.12+、型ヒント(ProtocolTypeAlias)を使うこと
  • @dataclass(frozen=True, slots=True) で注文・クーポン・集計結果を値オブジェクト化すること
  • match 文でクーポン種別(FIXED / PERCENT / FREESHIP)の分岐を整理すること
  • heapq.nlargest で Top-N 集計を行い、手動ソートを使わないこと
  • Protocol でデータソースを抽象化し、DI できるようにすること
  • 金額計算は Decimal で行い float を使わないこと
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と書籍の対応章

悪いコード (Before)

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_campaign_analyzer.py — 問題だらけの集計バッチ
import json

class CampaignAnalyzer:
    def __init__(self):
        # 問題①: データソースをハードコード(テスト不可・変更困難)
        self.data_file = "/data/orders.json"

    def load_orders(self, campaign_id):
        with open(self.data_file) as f:
            data = json.load(f)
        return [o for o in data if o["campaign_id"] == campaign_id]

    def apply_coupon(self, order):
        # 問題②: マジックストリング "fixed"/"percent"/"freeship"(typo 検出不可)
        coupon = order.get("coupon")
        if coupon is None:
            return 0
        if coupon["type"] == "fixed":
            return min(coupon["value"], order["subtotal"])
        elif coupon["type"] == "percent":
            # 問題③: float 演算(0.1 + 0.2 != 0.3 問題 → 金額計算に不適切)
            return order["subtotal"] * coupon["value"] / 100
        elif coupon["type"] == "freeship":
            return order["shipping_fee"]
        else:
            # 問題④: 未知種別でも 0 を返す(エラーを握りつぶす)
            return 0

    def summarize(self, campaign_id):
        orders = self.load_orders(campaign_id)
        # 問題⑤: 手動のキー存在チェック(defaultdict で不要)
        result = {}
        for order in orders:
            cat = order["category"]
            discount = self.apply_coupon(order)
            if cat in result:
                result[cat]["count"] += 1
                result[cat]["gross"] += order["subtotal"]
                result[cat]["discount"] += discount
            else:
                result[cat] = {
                    "count": 1,
                    "gross": order["subtotal"],
                    "discount": discount,
                }
        # 問題⑥: 生 dict を返す(frozen dataclass でなく型安全でない)
        return result

    def top_skus(self, campaign_id):
        orders = self.load_orders(campaign_id)
        sku_net = {}
        for order in orders:
            sku = order["sku_id"]
            discount = self.apply_coupon(order)
            net = order["subtotal"] - discount
            if sku in sku_net:
                sku_net[sku] += net
            else:
                sku_net[sku] = net
        # 問題⑦: sorted()[:5] で全件ソート(heapq.nlargest より非効率)
        top = sorted(sku_net.items(), key=lambda x: x[1], reverse=True)[:5]
        return top
問題点サマリー(7点)
1データソースをファイルにハードコード — テスト不可・BQ移行時に全書き換えが必要。Protocol で抽象化して DI で差し込むこと(Ch5)
2マジックストリング "fixed"/"percent"/"freeship" — typo が実行時まで検出不可。StrEnum CouponType × match 文に変換すること(Ch6)
3float で金額計算subtotal * value / 100 で浮動小数点誤差が発生。Decimal + quantize() に変換すること(Ch4)
4未知クーポン種別で 0 を返す(握りつぶし) — バグが検出されない。case _: raise ValueError(...) で明示的に送出すること(Ch6)
5手動のキー存在チェック(if key in dict)defaultdict(int) / defaultdict(lambda: ZERO) で廃絶できる(Ch7)
6生 dict を返す(型安全でない) — 呼び出し元でキー名 typo が実行時エラーになる。CategorySummary(frozen=True, slots=True) に変換すること(Ch4)
7sorted()[:5] で全件ソート(非効率) — O(N log N)。heapq.nlargest(5, ...) で O(N log K) に改善すること(Ch7)

ヒント(段階的開示)

ヒント1 — 方向性
match 文はクーポン種別(StrEnum)の条件分岐に使える。heapq.nlargest(N, iterable, key=...)sorted(..., reverse=True)[:N] よりも大きなデータで効率的。Protocol でデータソースを抽象化することで、テスト時に FakeOrderRepo を差し込める。frozen=True dataclass で注文レコードを値オブジェクト化すると、誤変更を静的に防げる。金額は Decimal で扱うこと。
ヒント2 — アプローチ
  • Protocol でデータソース抽象化:
    from typing import Protocol
    class OrderRepository(Protocol):
        def fetch_orders(self, campaign_id: str) -> list[Order]: ...
  • match 文でクーポン分岐:
    match coupon.coupon_type:
        case CouponType.FIXED:
            discount = min(coupon.value, order.subtotal)
        case CouponType.PERCENT:
            discount = order.subtotal * coupon.value / PERCENT_DIVISOR
        case CouponType.FREESHIP:
            discount = order.shipping_fee
        case _:
            raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}")
  • heapq.nlargest で Top-N:
    import heapq
    top_skus = heapq.nlargest(TOP_N, sku_net.items(), key=lambda kv: kv[1])
  • Decimal で金額計算:
    from decimal import Decimal
    PERCENT_DIVISOR: Final[Decimal] = Decimal("100")
    discount = order.subtotal * coupon.value / PERCENT_DIVISOR
    discount = discount.quantize(Decimal("0.01"))  # 小数第2位に丸め
ヒント3 — コードの骨格
from __future__ import annotations
import heapq
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
from typing import Final, Protocol, TypeAlias

TOP_N: Final[int] = 5
PERCENT_DIVISOR: Final[Decimal] = Decimal("100")
ZERO: Final[Decimal] = Decimal("0")

CampaignId: TypeAlias = str

class CouponType(StrEnum):
    FIXED    = "fixed"
    PERCENT  = "percent"
    FREESHIP = "freeship"

@dataclass(frozen=True, slots=True)
class Coupon:
    coupon_id:   str
    coupon_type: CouponType
    value:       Decimal

@dataclass(frozen=True, slots=True)
class Order:
    order_id:     str
    sku_id:       str
    category:     str
    subtotal:     Decimal
    shipping_fee: Decimal
    coupon:       Coupon | None

@dataclass(frozen=True, slots=True)
class CategorySummary:
    category:       str
    order_count:    int
    gross_revenue:  Decimal
    total_discount: Decimal
    net_revenue:    Decimal

class OrderRepository(Protocol):
    def fetch_orders(self, campaign_id: CampaignId) -> list[Order]: ...

class CampaignAnalyzer:
    def __init__(self, repo: OrderRepository) -> None:
        self._repo = repo

    def _apply_coupon(self, order: Order) -> Decimal:
        if order.coupon is None:
            return ZERO
        coupon = order.coupon
        match coupon.coupon_type:
            case CouponType.FIXED:
                return min(coupon.value, order.subtotal)
            case CouponType.PERCENT:
                return (order.subtotal * coupon.value / PERCENT_DIVISOR).quantize(Decimal("0.01"))
            case CouponType.FREESHIP:
                return order.shipping_fee
            case _:
                raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}")

問題点分析(7点)

#問題点分類改善方法
1データソースをファイルにハードコード(テスト不可)Ch5 依存関係Protocol OrderRepository + DI で抽象化
2マジックストリング "fixed"/"percent"/"freeship"Ch6 条件分岐StrEnum CouponType × match
3float で金額計算(浮動小数点誤差)Ch4 不変/精度Decimal + quantize()
4未知クーポン種別で 0 を返す(握りつぶし)Ch6 エラー処理case _: raise ValueError
5手動のキー存在チェック(if key in dictCh7 コレクションdefaultdict(int) / defaultdict(lambda: ZERO)
6生 dict を返す(型安全でない集計結果)Ch4 不変値CategorySummary(frozen=True, slots=True)
7sorted()[:5] で全件ソート(O(N log N))Ch7 効率heapq.nlargest(TOP_N, ...) で O(N log K)

模範解答

"""campaign_analyzer.py — クーポン適用 × カテゴリ別売上集計(Bad → Good リファクタリング)

良いコード・悪いコードで学ぶ設計入門(改訂新版)
  - Ch4: frozen=True dataclass × Decimal で不変値オブジェクト × 金額精度保証
  - Ch5: Protocol による依存関係の整理(テスタブル設計)
  - Ch6: match 文でクーポン種別の条件分岐を1箇所に集約(DRY × OCP)
  - Ch7: defaultdict + heapq.nlargest でコレクション操作を整理(効率化)
Python 3.12+ / match / Protocol / heapq / StrEnum / Decimal
"""
from __future__ import annotations

import heapq
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
from typing import Final, Protocol, TypeAlias

# ── 名前付き定数(マジックナンバー根絶)────────────────────────────────────
TOP_N: Final[int] = 5                             # Top-N SKU ランキングの件数
PERCENT_DIVISOR: Final[Decimal] = Decimal("100")  # % → 小数変換用除数
ZERO: Final[Decimal] = Decimal("0")               # Decimal のゼロ値(型一致のため)

# PEP 695 TypeAlias(Python 3.12+)。str の意味を明示する型エイリアス
CampaignId: TypeAlias = str


# ── 問題①②: マジックストリングを StrEnum で型安全化(Ch6)────────────────
class CouponType(StrEnum):
    """クーポン種別を型安全に管理する StrEnum。

    StrEnum は str のサブクラスなので json.dumps() にそのまま渡せる。
    match 文の case 節でリテラル比較が静的に検証される。
    """
    FIXED    = "fixed"     # 定額割引(円)
    PERCENT  = "percent"   # 割合割引(%)
    FREESHIP = "freeship"  # 送料無料


# ── 問題③⑥: dict でなく frozen=True dataclass で値オブジェクト化(Ch4)──
@dataclass(frozen=True, slots=True)
class Coupon:
    """クーポン1件を表す不変値オブジェクト。

    Attributes:
        coupon_id:   クーポンID。
        coupon_type: クーポン種別(CouponType)。
        value:       割引値(FIXED=円、PERCENT=%, FREESHIP は無視)。
    """
    coupon_id:   str
    coupon_type: CouponType
    value:       Decimal          # float ではなく Decimal(問題③の解消)


@dataclass(frozen=True, slots=True)
class Order:
    """注文1件を表す不変値オブジェクト。

    Attributes:
        order_id:     注文ID。
        sku_id:       商品SKU ID。
        category:     商品カテゴリ(例: "apparel", "electronics")。
        subtotal:     小計(税抜き)。Decimal で精度を保証する。
        shipping_fee: 送料。Decimal で精度を保証する。
        coupon:       適用クーポン(なければ None)。
    """
    order_id:     str
    sku_id:       str
    category:     str
    subtotal:     Decimal         # float → Decimal(問題③の解消)
    shipping_fee: Decimal         # float → Decimal(問題③の解消)
    coupon:       Coupon | None


@dataclass(frozen=True, slots=True)
class CategorySummary:
    """カテゴリ別売上集計を表す不変値オブジェクト(問題⑥の解消)。

    Attributes:
        category:       商品カテゴリ名。
        order_count:    注文件数。
        gross_revenue:  クーポン適用前の総売上(小計合計)。
        total_discount: クーポン割引総額。
        net_revenue:    クーポン適用後の純売上。
    """
    category:       str
    order_count:    int
    gross_revenue:  Decimal
    total_discount: Decimal
    net_revenue:    Decimal


# ── 問題①: データソースを Protocol で抽象化(Ch5 依存関係整理)────────────
class OrderRepository(Protocol):
    """注文データを取得するデータソースのインターフェース。

    実装例: BigQueryOrderRepo / FileOrderRepo / FakeOrderRepo(テスト用)。
    Protocol にすることで CampaignAnalyzer はデータソースに依存しない。
    """
    def fetch_orders(self, campaign_id: CampaignId) -> list[Order]:
        """指定キャンペーンIDに紐づく注文一覧を返す。"""
        ...


# ── メインクラス ──────────────────────────────────────────────────────────
class CampaignAnalyzer:
    """キャンペーン効果を集計・分析するクラス。

    OrderRepository を DI で受け取るため、テスト時にモックを差し込める。

    Attributes:
        _repo: 注文データ取得リポジトリ(Protocol に準拠した任意の実装)。
    """

    def __init__(self, repo: OrderRepository) -> None:
        # アンダースコアでカプセル化(外部から直接アクセスさせない)
        self._repo = repo

    def _apply_coupon(self, order: Order) -> Decimal:
        """注文にクーポンを適用して割引額を返す(問題②④の解消)。

        match 文(Python 3.10+)でクーポン種別を分岐する。
        StrEnum と組み合わせることで mypy が網羅性を静的検証できる。

        Args:
            order: 割引額を計算する対象注文。

        Returns:
            割引額(Decimal)。クーポンなしは ZERO。

        Raises:
            ValueError: 未知の CouponType が渡された場合(問題④の解消)。
        """
        if order.coupon is None:
            return ZERO  # クーポンなしは割引なし

        coupon = order.coupon
        match coupon.coupon_type:
            case CouponType.FIXED:
                # 定額割引: 割引額は小計を超えない(最大でも小計まで)
                return min(coupon.value, order.subtotal)

            case CouponType.PERCENT:
                # 割合割引: subtotal × (value / 100)、小数第2位まで丸め
                # float ではなく Decimal で計算(問題③の解消)
                discount = order.subtotal * coupon.value / PERCENT_DIVISOR
                return discount.quantize(Decimal("0.01"))

            case CouponType.FREESHIP:
                # 送料無料: shipping_fee 相当額を割引(Decimal で一致)
                return order.shipping_fee

            case _:
                # StrEnum を使っていれば通常到達しないが防衛的に送出(問題④の解消)
                raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}")

    def summarize(self, campaign_id: CampaignId) -> list[CategorySummary]:
        """カテゴリ別の売上・割引サマリーを生成する(問題⑤⑥の解消)。

        Args:
            campaign_id: 集計対象のキャンペーンID。

        Returns:
            カテゴリ別 CategorySummary のリスト(net_revenue 降順)。
        """
        orders = self._repo.fetch_orders(campaign_id)

        # 問題⑤: 手動の if-else キー存在チェックを defaultdict で廃絶(Ch7)
        counts:    defaultdict[str, int]     = defaultdict(int)
        gross:     defaultdict[str, Decimal] = defaultdict(lambda: ZERO)
        discounts: defaultdict[str, Decimal] = defaultdict(lambda: ZERO)

        for order in orders:
            discount = self._apply_coupon(order)  # 割引計算は1箇所に集約
            counts[order.category]    += 1
            gross[order.category]     += order.subtotal
            discounts[order.category] += discount

        # 問題⑥: 生 dict でなく CategorySummary(frozen=True)で返す(Ch4)
        summaries = [
            CategorySummary(
                category=cat,
                order_count=counts[cat],
                gross_revenue=gross[cat],
                total_discount=discounts[cat],
                net_revenue=gross[cat] - discounts[cat],
            )
            for cat in counts
        ]
        return sorted(summaries, key=lambda s: s.net_revenue, reverse=True)

    def top_skus(self, campaign_id: CampaignId) -> list[tuple[str, Decimal]]:
        """純売上 Top-N の SKU リストを返す(問題⑦の解消)。

        heapq.nlargest は O(N log K)(K=TOP_N)。
        sorted()[:N] の O(N log N) より SKU 数が大きいほど効率的。

        Args:
            campaign_id: 集計対象のキャンペーンID。

        Returns:
            (sku_id, net_revenue) タプルリスト(net_revenue 降順、最大 TOP_N 件)。
        """
        orders = self._repo.fetch_orders(campaign_id)

        sku_net: defaultdict[str, Decimal] = defaultdict(lambda: ZERO)
        for order in orders:
            discount = self._apply_coupon(order)
            sku_net[order.sku_id] += order.subtotal - discount

        # 問題⑦: sorted()[:5] の代わりに heapq.nlargest で Top-N を効率取得(Ch7)
        return heapq.nlargest(TOP_N, sku_net.items(), key=lambda kv: kv[1])

テスト用フィクスチャ(FakeOrderRepo)

from decimal import Decimal

# FakeOrderRepo: Protocol に準拠したテスト用モック(BQ 接続不要)
class FakeOrderRepo:
    def __init__(self, orders: list[Order]) -> None:
        self._orders = orders

    def fetch_orders(self, campaign_id: str) -> list[Order]:
        return [o for o in self._orders if True]  # 全件返す(テスト用簡略化)

orders = [
    Order("O-001", "SKU-A", "apparel",     Decimal("5000"), Decimal("500"),
          Coupon("C-1", CouponType.FIXED,   Decimal("500"))),
    Order("O-002", "SKU-B", "electronics", Decimal("20000"), Decimal("800"),
          Coupon("C-2", CouponType.PERCENT, Decimal("10"))),
    Order("O-003", "SKU-A", "apparel",     Decimal("3000"), Decimal("0"),
          Coupon("C-3", CouponType.FREESHIP, Decimal("0"))),
    Order("O-004", "SKU-C", "apparel",     Decimal("8000"), Decimal("500"), None),
]

analyzer = CampaignAnalyzer(repo=FakeOrderRepo(orders))

summarize() — カテゴリ別集計

summaries = analyzer.summarize("CMP-2026-06")
for s in summaries:
    print(f"{s.category}: gross={s.gross_revenue}, discount={s.total_discount}, net={s.net_revenue}")

# apparel:     gross=16000, discount=500,    net=15500
# electronics: gross=20000, discount=2000.00, net=18000.00

# O-001 (apparel):     FIXED 500円割引 → discount=500
# O-002 (electronics): PERCENT 10% → 20000 * 10 / 100 = 2000.00
# O-003 (apparel):     FREESHIP → shipping_fee=0 → discount=0
# O-004 (apparel):     クーポンなし → discount=0
# apparel: gross=5000+3000+8000=16000, discount=500+0+0=500, net=15500
# electronics: gross=20000, discount=2000.00, net=18000.00

top_skus() — Top-N SKU

top = analyzer.top_skus("CMP-2026-06")
for sku_id, net in top:
    print(f"  {sku_id}: net={net}")

# SKU-B: net=18000.00   ← electronics 20000 - 2000 = 18000
# SKU-C: net=8000        ← apparel 8000 - 0 = 8000
# SKU-A: net=7500        ← 5000-500 + 3000-0 = 7500

Decimal 精度確認

# float vs Decimal の精度差
print(0.1 + 0.2)           # 0.30000000000000004 ← 誤差あり
print(Decimal("0.1") + Decimal("0.2"))  # 0.3 ← 正確

# PERCENT 割引の精度
order = Order("O-X", "SKU-X", "test", Decimal("9999"), Decimal("0"),
              Coupon("C-X", CouponType.PERCENT, Decimal("33")))
analyzer_x = CampaignAnalyzer(FakeOrderRepo([order]))
# 9999 * 33 / 100 = 3299.67(小数第2位に丸め)
discount = analyzer_x._apply_coupon(order)
print(discount)  # 3299.67

heapq.nlargest の効率比較

# SKU 数が 100,000 の場合の計算量比較
# Bad:  sorted(sku_net.items(), ...)[:5]  → O(N log N) = O(100000 × 17) ≈ 1,700,000 ops
# Good: heapq.nlargest(5, sku_net.items(), ...) → O(N log K) = O(100000 × 2) ≈ 200,000 ops
# → 約 8.5 倍の効率化(K=5, log5 ≈ 2)
ポイント適用した設計原則/パターン書籍/仕様対応
OrderRepository(Protocol) + DI依存関係の逆転・テスタブル設計Ch5 依存関係の整理
StrEnum CouponType型安全なステータス管理(文字列リテラル廃絶)Ch6 条件分岐の整理
match coupon.coupon_typeパターンマッチングで条件分岐を1箇所に集約Ch6 / PEP 634 (Python 3.10+)
case _: raise ValueError握りつぶし禁止・防衛的プログラミングCh6 / Ch10 エラー処理
defaultdict(int) / defaultdict(lambda: ZERO)手動 if-else のキー存在チェックを廃絶Ch7 コレクション操作の整理
heapq.nlargest(TOP_N, ...)Top-N 取得の効率化 O(N log K)Ch7 コレクション操作の整理
CategorySummary(frozen=True, slots=True)不変値オブジェクト(Value Object)Ch4 不変の活用
Decimal + quantize()金額精度保証(浮動小数点誤差の排除)Ch4 / Python decimal モジュール

設計図 — Bad(ハードコード × float × sorted)vs Good(Protocol × Decimal × heapq)

Bad(ハードコード × float × sorted) self.data_file = "/data/orders.json" ← ハードコード BQ 移行時に全書き換え / テスト時にファイル依存 if coupon["type"] == "fixed": ← マジックストリング elif "percent" ... elif "freeship" → DRY 違反 × typo 検出不可 else: return 0 ← 未知種別を握りつぶし(バグ隠蔽) subtotal * value / 100 ← float 演算 0.1 + 0.2 = 0.30000000000000004 → 金額計算に不適 手動 if-else キー存在チェック if cat in result: result[cat]["count"] += 1 else: result[cat] = {"count": 1, ...} ← defaultdict で不要 return result ← 生 dict(型安全でない) result["typo_key"] が実行時 KeyError になる可能性あり sorted(sku_net.items(), ...)[:5] ← O(N log N) N=100,000 SKU で全件ソート(heapq.nlargest より非効率) 問題点まとめ ① データソースハードコード(テスト不可) ② マジックストリング ③ float 金額誤差 ④ 握りつぶし ⑤ 手動集計 ⑥ 生dict ⑦ 全件ソート リファクタ Good(Protocol × Decimal × heapq) OrderRepository(Protocol) — DI でデータソース抽象化 BigQueryOrderRepo / FileOrderRepo / FakeOrderRepo(テスト用)を差し替え可能 def fetch_orders(self, campaign_id: CampaignId) -> list[Order]: ... StrEnum CouponType × match 文(Ch6 DRY) case CouponType.FIXED: return min(coupon.value, order.subtotal) case CouponType.PERCENT: ... quantize(Decimal("0.01")) case _: raise ValueError(...) ← 握りつぶし禁止 Decimal + quantize(Decimal("0.01"))(Ch4 金額精度) Decimal("0.1") + Decimal("0.2") = 0.3 ← float 誤差なし defaultdict で手動集計を廃絶(Ch7) counts: defaultdict[str, int] = defaultdict(int) gross: defaultdict[str, Decimal] = defaultdict(lambda: ZERO) CategorySummary(frozen=True, slots=True)(Ch4 値オブジェクト) 生 dict → 不変値オブジェクト / フィールド typo を静的検出 heapq.nlargest(TOP_N, sku_net.items(), key=...) ← O(N log K) sorted()[:5] の O(N log N) より K=5 なら約 8.5 倍効率的 Good の効果まとめ ① テスタブル(Protocol) ② 型安全(StrEnum + match) ③ 精度保証(Decimal) ④ 効率(heapq) ⑤ 不変(frozen)

ポイント解説

1 Protocol でデータソースを抽象化(Ch5)
self.data_file = "/data/orders.json" のようにデータソースをクラス内にハードコードすると、テスト時にファイルを用意しなければならず、BigQuery 移行時にクラス全体を書き換えることになる。OrderRepository(Protocol) を定義して __init__ に DI することで、FakeOrderRepo(テスト用)/ BigQueryOrderRepo(本番)を差し替えられる。Protocol は抽象基底クラス(ABC)と異なり、継承なしで構造的サブタイピングが機能する。
2 match 文 × StrEnum でクーポン分岐を集約(Ch6)
if coupon["type"] == "fixed": の文字列比較は "fixd" のような typo が実行時まで検出されない。StrEnum CouponType を定義し match coupon.coupon_type に変換すると、mypy が case 節の型を静的検証する。新しいクーポン種別(例: BUNDLE = "bundle")を追加するときも StrEnum に1行追加して _apply_coupon()case を追加するだけで済む(OCP の部分的な達成)。
3 Decimal で金額精度を保証(Ch4)
float は IEEE 754 の浮動小数点数であり、0.1 + 0.2 == 0.30000000000000004 になる。金額計算でこの誤差が積み重なると請求金額が数円ずれることがある。Decimal は10進小数を正確に表現し、quantize(Decimal("0.01")) で小数点以下2桁に丸めることができる。入力時点から Decimal を使い、内部計算で float に変換しないことが重要。
4 case _: raise ValueError で握りつぶし禁止(Ch6/Ch10)
Bad コードの else: return 0 は未知のクーポン種別が追加されたときにサイレントに 0 を返すため、バグが検出されない。case _: raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}") に変更することで、新しい種別が追加されたが _apply_coupon() に対応する case を追加し忘れたときに即座にエラーが出る。
5 defaultdict で手動集計を廃絶(Ch7)
if cat in result: result[cat]["count"] += 1 else: result[cat] = {"count": 1, ...} の手動キー存在チェックは defaultdict(int) / defaultdict(lambda: ZERO) で廃絶できる。集計ロジックの本質(何を足すか)だけがコードに残り、「存在確認してなければ初期化する」という定型句が消える。
6 heapq.nlargest で Top-N 取得を効率化(Ch7)
sorted(sku_net.items(), key=..., reverse=True)[:5] は全件を O(N log N) でソートしてから先頭5件を取る。heapq.nlargest(5, sku_net.items(), key=...) は O(N log K)(K=5)で、N が大きいほど差が開く。SKU 数が 100,000 の場合、理論上約 8.5 倍効率的になる(log5 ≈ 2 vs log100000 ≈ 17)。
7 CategorySummary(frozen=True, slots=True) 値オブジェクト(Ch4)
生 dict を返すと呼び出し元で result["net_revneue"](typo)が実行時 KeyError になる。CategorySummaryfrozen=True, slots=True の dataclass にすることで、フィールドへの直接代入禁止・メモリ効率化・mypy による属性アクセスの静的検証が同時に得られる。

実務への応用

  • Argo Workflows キャンペーン集計ステップ: CampaignAnalyzer.summarize() をワークフローの最終ステップに組み込み、CategorySummary(frozen=True)を dataclasses.asdict() で BigQuery の mart_campaign_summary テーブルへ INSERT する。型付きのデータクラスは BQ スキーマ定義と1対1対応させやすい。
  • BigQuery OrderRepository 実装: BigQueryOrderRepo クラスで OrderRepository(Protocol) を実装し、BQ から注文データを取得する。Decimal は BQ の NUMERIC(精度29桁)型に対応するため、BigQuery Storage API からの読み取りも精度を落とさずに扱える。
  • DataDog カスタムメトリクス: top_skus() の結果を DataDog statsd / API で送信し「SKU別キャンペーン貢献売上」のダッシュボードを構築する。Decimalfloat の変換はメトリクス送信時のみ行い、内部計算は常に Decimal を維持すること。
  • テスト戦略: FakeOrderRepoOrderRepository として実装し、pytest でキャンペーン集計ロジックを BQ 接続なしにテストできる。Protocol 採用により unittest.mock.MagicMock でのモックも容易になる。dbt Unit Tests(dbt 1.8+)と組み合わせて Python バッチと SQL モデルの集計結果をクロスチェックする。

今日のまとめ

Protocol でデータソースを抽象化し match 文 × StrEnum でクーポン分岐を1箇所に集約することで、新しいクーポン種別の追加が _apply_coupon()case 1行追加で完結する。

Decimal × frozen=True dataclass × defaultdict × heapq.nlargest の組み合わせは、ECサイトの集計バッチにおける「精度・不変性・効率・型安全」の4条件を同時に満たす実務標準パターン。

自己評価

自分の回答

気づき・メモ