概要
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"等)を実行時まで検出できない |
StrEnumでOrderStatus.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 クラス設計
模範解答
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
BADコードでは辞書のキーを直接書き換えていた(
@dataclass(frozen=True) でイミュータブルなデータを表現BADコードでは辞書のキーを直接書き換えていた(
order["status"] = "done")。これは副作用が追いにくくなる原因。frozen=True の dataclass は変更不可なので、状態変更には dataclasses.replace() で新インスタンスを生成するパターンを使う。
3
+= の活用と f-stringself.total = self.total + x → self._total += x の方が短く慣用的。文字列連結の "processed: " + str(n) → f-string に統一。
4
文字列を返すより辞書を返す方がプログラマブル。呼び出し側でフォーマットを決めるのが責務分離の原則(SRP)に合致する。
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() で明示的に行うことで副作用が追跡しやすくなる。