A: コーディング — StrEnum × frozen dataclass × replace() パターン

2026-04-20 (Day 8) A: コーディング/アルゴリズム ★★★☆☆ OrderProcessor 設計改善 Python 3.12+ / StrEnum / dataclass

概要

🔤

StrEnum(型安全なステータス)

"pending" という生の文字列はtypを防げない。StrEnumでIDEの補完・型チェックが効くようになる。

🧊

frozen=True dataclass

辞書の直接変更(order["status"] = "done")を排除。状態変更はreplace()で新インスタンスを生成する。

🔄

replace()パターン

dataclasses.replace(obj, field=new_value)で不変オブジェクトの一部を変更した新インスタンスを生成する不変パターン。

📖

dict返却→型安全dict

get_summary()は文字列ではなくdict[str, int | float]を返す。呼び出し側がフォーマットを決める責務分離。

問題

ECサイトの注文処理クラス OrderProcessor に設計上・Pythonイディオムの観点から複数の問題があります。改善してください。

制約・前提条件

  • Python 3.12 以上を使用すること
  • 型ヒント(type hints)を全面的に付与すること
  • dataclass または StrEnum を適切に活用すること
  • 状態を表す文字列("pending", "done", "fail")は定数化すること
  • get_summary() は辞書形式で返すように変更すること
期待する回答形式: 改善後のコード + 問題点の列挙 + 解説

悪いコード (Before)

このコードには「型安全性」「可変状態の危険性」「文字列ハードコーディング」「Python的でない書き方」の4カテゴリに問題があります。
# --- BAD CODE ---

class OrderProcessor:
    def __init__(self):
        self.orders = []
        self.processed = []
        self.failed = []
        self.total = 0

    def add_order(self, id, amount, status, user):
        order = {"id": id, "amount": amount, "status": status, "user": user}
        self.orders.append(order)

    def process(self):
        for order in self.orders:
            if order["status"] == "pending":
                if order["amount"] > 0:
                    order["status"] = "done"
                    self.total = self.total + order["amount"]
                    self.processed.append(order)
                else:
                    order["status"] = "fail"
                    self.failed.append(order)

    def get_summary(self):
        return "processed: " + str(len(self.processed)) + \
               ", failed: " + str(len(self.failed)) + \
               ", total: " + str(self.total)

ヒント(段階的開示)

ヒント1 — 方向性
問題点は大きく「型安全性」「可変状態の危険性」「文字列ハードコーディング」「Python的でない書き方」の4カテゴリに分類できます。
ヒント2 — アプローチ
  • 注文の「状態」を StrEnum で定義すると文字列の誤入力を防げる
  • 注文データそのものを @dataclass(frozen=True) で表現するとイミュータブルになる
  • self.total = self.total + order["amount"]+= で書ける
  • 文字列結合は f-string を使う
ヒント3 — 骨格
from enum import StrEnum
from dataclasses import dataclass, field, replace

class OrderStatus(StrEnum):
    PENDING = "pending"
    DONE = "done"
    FAILED = "failed"

@dataclass(frozen=True)
class Order:
    id: str
    amount: float
    status: OrderStatus
    user: str

@dataclass
class OrderProcessor:
    _orders: list[Order] = field(default_factory=list)
    _processed: list[Order] = field(default_factory=list)
    _failed: list[Order] = field(default_factory=list)
    _total: float = 0.0

    def add_order(self, order: Order) -> None: ...
    def process(self) -> None: ...
    def get_summary(self) -> dict[str, int | float]: ...

問題点分析

#問題点分類影響改善方法
1 "pending" / "done" / "fail" が生の文字列でハードコード 文字列ハードコード typo("panding"等)を実行時まで検出できない StrEnumOrderStatus.PENDING等に定数化
2 注文データがdictで型情報なし 型安全性なし IDEの補完が効かない。誤ったキー名アクセスを検出できない @dataclass(frozen=True)でOrder型を定義
3 order["status"] = "done" でdictを直接変更 可変状態の危険 副作用が追いにくくなる。共有参照時に予期しない変更が起きる frozen=True + replace()で新インスタンスを生成
4 self.total = self.total + x+=を使わない) 非Python的 冗長で読みにくい self._total += done_order.amount
5 文字列結合でget_summary()の返り値を構築 設計 呼び出し側がパースしないと使えない。テストしにくい dict[str, int | float]を返す

設計構造図 — Before / After クラス設計

✗ Before OrderProcessor fields (型なし): orders: list (dict混在) processed: list failed: list total: 0 (型注釈なし) methods: add_order(id, amount, status, user) process() → order["status"]を直接変更 get_summary() → str(文字列結合) ❌ 文字列ハードコード: "pending", "done", "fail" ❌ dict直接変更(可変) ❌ 型注釈なし ✓ After OrderStatus(StrEnum) PENDING = "pending" DONE = "done" FAILED = "failed" Order(frozen=True) id: str amount: float status: OrderStatus OrderProcessor fields (型付き): _orders: list[Order] _processed: list[Order] _failed: list[Order] _total: float = 0.0 methods: add_order(Order) -> None process() → replace()使用 get_summary() → dict[str, int|float] ✓ StrEnumで型安全なステータス管理 ✓ frozen=True + replace()で不変オブジェクト ✓ 全フィールドに型注釈

模範解答

from __future__ import annotations
from dataclasses import dataclass, field, replace
from enum import StrEnum


class OrderStatus(StrEnum):
    """注文ステータス。文字列との互換性を保ちながら型安全に管理する。"""
    PENDING = "pending"
    DONE = "done"
    FAILED = "failed"


@dataclass(frozen=True)
class Order:
    """注文を表すイミュータブルなデータクラス。

    frozen=True により、生成後に状態を変更できない。
    ステータス変更は replace() で新しいインスタンスを生成する。
    """
    id: str
    amount: float
    status: OrderStatus
    user: str


@dataclass
class OrderProcessor:
    """注文処理エンジン。注文の受付・処理・集計を担う。"""
    _orders: list[Order] = field(default_factory=list)
    _processed: list[Order] = field(default_factory=list)
    _failed: list[Order] = field(default_factory=list)
    _total: float = 0.0

    def add_order(self, order: Order) -> None:
        """注文を処理キューに追加する。"""
        self._orders.append(order)

    def process(self) -> None:
        """PENDING 状態の注文を処理し、結果に応じて分類する。"""
        for order in self._orders:
            if order.status != OrderStatus.PENDING:
                continue

            if order.amount > 0:
                done_order = replace(order, status=OrderStatus.DONE)
                self._total += done_order.amount
                self._processed.append(done_order)
            else:
                failed_order = replace(order, status=OrderStatus.FAILED)
                self._failed.append(failed_order)

    def get_summary(self) -> dict[str, int | float]:
        """処理結果のサマリーを辞書形式で返す。"""
        return {
            "processed": len(self._processed),
            "failed": len(self._failed),
            "total": self._total,
        }


# --- 実行例 ---
if __name__ == "__main__":
    processor = OrderProcessor()
    processor.add_order(Order(id="ord-001", amount=3000.0, status=OrderStatus.PENDING, user="alice"))
    processor.add_order(Order(id="ord-002", amount=0.0,    status=OrderStatus.PENDING, user="bob"))
    processor.add_order(Order(id="ord-003", amount=1500.0, status=OrderStatus.PENDING, user="carol"))

    processor.process()
    print(processor.get_summary())
    # → {'processed': 2, 'failed': 1, 'total': 4500.0}

ポイント解説

1 StrEnum でステータスを型安全に管理
"pending" という生の文字列は typo を防げない。StrEnum を使えば IDE の補完が効き、== "pending" という比較も == OrderStatus.PENDING に統一される。Python 3.11+ で標準ライブラリに追加(from enum import StrEnum)。
2 @dataclass(frozen=True) でイミュータブルなデータを表現
BADコードでは辞書のキーを直接書き換えていた(order["status"] = "done")。これは副作用が追いにくくなる原因。frozen=True の dataclass は変更不可なので、状態変更には dataclasses.replace() で新インスタンスを生成するパターンを使う。
3 += の活用と f-string
self.total = self.total + xself._total += x の方が短く慣用的。文字列連結の "processed: " + str(n) → f-string に統一。
4 get_summary() の返り値を dict に変更
文字列を返すより辞書を返す方がプログラマブル。呼び出し側でフォーマットを決めるのが責務分離の原則(SRP)に合致する。

Before vs After 比較

✗ Before(dict直接変更)
def process(self):
    for order in self.orders:
        if order["status"] == "pending":
            if order["amount"] > 0:
                order["status"] = "done"  # 直接変更
                self.total = self.total + order["amount"]
                self.processed.append(order)
✓ After(replace()で不変)
def process(self) -> None:
    for order in self._orders:
        if order.status != OrderStatus.PENDING:
            continue
        if order.amount > 0:
            done_order = replace(order, status=OrderStatus.DONE)
            self._total += done_order.amount
            self._processed.append(done_order)

実務への応用

MOps/販促システムでのバッチ処理(Argo Workflows)

frozen=True + replace() パターンは、BigQuery に書き込む前の「データ変換ステップ」でよく使われる。特に Pydantic v2 のモデルでも model_copy(update={...}) として同じ概念が登場するので、このパターンを習得しておくと応用範囲が広い。

次のステップ

発展問題: OrderProcessor.process()async def で実装し、複数注文を asyncio.gather() で並列処理する
  • 参考: dataclasses モジュール公式ドキュメント
  • 参考: PEP 681 (dataclass_transform)
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第3章(クラス設計・責務の分離)

今日のまとめ

StrEnum + frozen=True dataclass の組み合わせにより、文字列ハードコーディングを排除しつつイミュータブルなドメインモデルを構築できる。状態変更は replace() で明示的に行うことで副作用が追跡しやすくなる。

自己評価

自分の回答

気づき・メモ