概要
StrEnum による型安全
status: str → OrderStatus(StrEnum) でタイポによる不正値を防ぐ。JSON シリアライズとも互換。
dataclasses.replace 非破壊的更新
元オブジェクトを変更せず新しいインスタンスを返す。副作用フリーな関数で呼び出し元を守る。
コンテキストマネージャ
with path.open() で確実なファイルクローズ。例外が発生しても __exit__ が呼ばれる。
名前付き定数 APPROVAL_THRESHOLD
マジックナンバー 10000 を Final 付き定数に。変更箇所が一箇所になりビジネスルール変更に強くなる。
問題
以下の「悪いコード」は ECサイトの注文データを処理する Python モジュール。問題点を列挙し、第3章(クラスで表現する)・第5章(低凝集) の原則を適用してリファクタリングしてください。
制約・前提条件
- Python 3.12 以上
itemsの各要素は{"price": float, "qty": int}の dictstatusは"pending"/"approved"/"review"の3値のみsave_ordersはファイル書き込み失敗も考慮すること- Pydantic v2 は使わず、標準ライブラリ(
dataclasses,enum,pathlib)のみ使用 process_ordersは元のリストを破壊的変更しないこと
期待する回答形式: 問題点の列挙 + 改善後コード(docstring・名前付き定数含む)+ 実行例 + 適用した設計パターン名
悪いコード (Before)
このコードには 5つの設計上の問題 が隠れています。
# order_processor.py
from dataclasses import dataclass
import json
@dataclass
class Order:
id: int
user_id: int
items: list
status: str
def process_orders(orders):
result = []
for o in orders:
if o.status == "pending":
total = 0
for item in o.items:
total = total + item["price"] * item["qty"]
if total > 10000:
o.status = "approved"
else:
o.status = "review"
result.append(o)
return result
def save_orders(orders, path):
data = []
for o in orders:
data.append({
"id": o.id,
"user_id": o.user_id,
"items": o.items,
"status": o.status
})
f = open(path, "w")
json.dump(data, f)
f.close()
print("saved")
ヒント(段階的開示)
ヒント1 — 方向性
コードを読んで「何が変わりやすいか」「何が壊れやすいか」に着目してください。magic number、ミュータブルな状態変更、リソース管理、型安全性の4軸で問題を分類すると整理しやすいです。
ヒント2 — アプローチ
statusは文字列ではなくStrEnumで表現するとタイポを防げます(Python 3.11+ 標準)total > 10000の10000は名前付き定数にすべきですo.status = ...は呼び出し元のオブジェクトを直接変更しており、副作用が読みにくい- ファイルを
open()/close()で管理するのは例外安全でない @dataclassのitems: listは型情報が弱すぎる
ヒント3 — 目指す構造
✗ Before(破壊的変更)
def process_orders(orders):
for o in orders:
if o.status == "pending":
# 呼び出し元のオブジェクトを直接変更
o.status = "approved"
result.append(o)
✓ After(非破壊的)
def _classify_order(order: Order) -> Order:
total = _calculate_total(order)
new_status = (
OrderStatus.APPROVED
if total >= APPROVAL_THRESHOLD
else OrderStatus.REVIEW
)
# 新しいインスタンスを返す
return dataclasses.replace(order, status=new_status)
問題点分析
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | status: str — タイポによる不正値を防げない |
型安全性 | StrEnum に変更 |
| 2 | 10000 のマジックナンバー |
Ch10 | APPROVAL_THRESHOLD: Final[float] に定数化 |
| 3 | o.status = ... が呼び出し元オブジェクトを破壊的変更 |
副作用 | dataclasses.replace() で新インスタンスを返す |
| 4 | open() / close() の手動管理 — 例外時にクローズされない |
リソース管理 | with path.open() コンテキストマネージャ |
| 5 | print("saved") — ログレベル制御不可 |
可観測性 | logger.info() に置き換え |
設計構造図 — 改善後のモジュール構成
模範解答
# order_processor.py
"""注文データの処理と永続化モジュール。
このモジュールは pending 状態の注文を合計金額で審査し、
JSON ファイルへ書き出す機能を提供します。
Typical usage example::
orders = [Order(id=1, user_id=42, items=[OrderItem(price=5000.0, qty=3)],
status=OrderStatus.PENDING)]
processed = process_orders(orders)
save_orders(processed, Path("orders.json"))
"""
from __future__ import annotations
import dataclasses
import json
import logging
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Final
logger = logging.getLogger(__name__)
# --- 定数 ---
APPROVAL_THRESHOLD: Final[float] = 10_000.0 # この金額以上で自動承認
class OrderStatus(StrEnum):
"""注文ステータスの列挙型。
文字列との互換性を保ちつつ、タイポによる不正値を防ぐ。
"""
PENDING = "pending"
APPROVED = "approved"
REVIEW = "review"
@dataclass
class OrderItem:
"""注文明細の1行。"""
price: float
qty: int
def subtotal(self) -> float:
"""小計を返す。"""
return self.price * self.qty
@dataclass
class Order:
"""注文エンティティ。
Attributes:
id: 注文ID。
user_id: 購入ユーザーのID。
items: 注文明細のリスト。
status: 現在のステータス。
"""
id: int
user_id: int
items: list[OrderItem]
status: OrderStatus
def _calculate_total(order: Order) -> float:
"""注文合計金額を計算する。
Args:
order: 対象の注文。
Returns:
全明細の合計金額。
"""
return sum(item.subtotal() for item in order.items)
def _classify_order(order: Order) -> Order:
"""pending 注文を合計金額で審査し、新しい Order を返す(非破壊的)。
Args:
order: pending 状態の注文。
Returns:
status が approved または review に更新された新しい Order。
"""
total = _calculate_total(order)
new_status = (
OrderStatus.APPROVED if total >= APPROVAL_THRESHOLD else OrderStatus.REVIEW
)
# dataclasses.replace で元オブジェクトを変更せず新しいインスタンスを生成
return dataclasses.replace(order, status=new_status)
def process_orders(orders: list[Order]) -> list[Order]:
"""pending 状態の注文を審査して返す。
呼び出し元のリストおよびオブジェクトを破壊的変更しない。
Args:
orders: 処理対象の注文リスト(mixed status 可)。
Returns:
pending だった注文を審査済みステータスで返したリスト。
"""
return [
_classify_order(order)
for order in orders
if order.status == OrderStatus.PENDING
]
def save_orders(orders: list[Order], path: Path) -> None:
"""注文リストを JSON ファイルへ書き出す。
Args:
orders: 書き出す注文リスト。
path: 保存先ファイルパス。
Raises:
OSError: ファイルの書き込みに失敗した場合。
"""
records = [dataclasses.asdict(order) for order in orders]
try:
# コンテキストマネージャで確実にファイルをクローズ
with path.open("w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
logger.info("Saved %d orders to %s", len(orders), path)
except OSError:
logger.exception("Failed to save orders to %s", path)
raise # 呼び出し元に例外を伝搬させ、サイレント失敗を防ぐ
ポイント解説
1
StrEnum による型安全なステータス管理status: str は任意の文字列を受け入れてしまいタイポによるバグが発生しやすい。StrEnum を使うと == 比較・JSON シリアライズ両方で文字列として機能しつつ、IDEの補完と型チェックが効く。
2
名前付き定数
APPROVAL_THRESHOLD10000 という magic number は変更時に全ファイルを grep しなければならない。Final 付き定数として切り出すことで変更箇所が一箇所になり、ビジネスルールの変更に強くなる。
3
元の
dataclasses.replace による非破壊的更新元の
Order オブジェクトを直接変更すると、呼び出し元が保持している参照の値も変わる(副作用)。replace() で新しいインスタンスを生成することで関数を副作用フリーにする。
4
コンテキストマネージャ (
with) によるリソース管理open() / close() の手動管理は例外発生時にファイルがクローズされない。with 文を使うと __exit__ が確実に呼ばれる。
5
print → logging への置き換えprint("saved") は本番環境でのログレベル制御・構造化ロギング・テスト時の出力抑制ができない。logger.info() を使うことで運用上のコントロールが可能になる。
Before vs After 比較
| 観点 | Before | After |
|---|---|---|
| ステータス表現 | str(タイポ可) | StrEnum(型安全) |
| 閾値 | 10000(マジックナンバー) | APPROVAL_THRESHOLD: Final[float] |
| 状態変更 | o.status = ...(破壊的) | dataclasses.replace()(非破壊) |
| ファイル管理 | open()/close()(例外安全でない) | with path.open()(確実クローズ) |
| ログ | print()(制御不可) | logger.info()(レベル制御可) |
実務への応用
- Argo Workflows との親和性: 副作用フリーな関数はべき等性を保証しやすく、ワークフローの再実行(retry)に対して安全
- GCS URI との互換:
save_ordersのpathをPath型にしておくとPurePosixPathでラップしたGCSユーティリティへ差し替えやすい - BigQuery との親和性:
OrderStatusをStrEnumにしておくと BigQuery のSTRINGカラムとのマッピングが自然に行える
今日のまとめ
「状態を表す文字列は
ファイル操作は必ずコンテキストマネージャ(
StrEnum で、変更は dataclasses.replace で新インスタンスとして返す」——型安全性と副作用フリー設計の2つが、保守性の高い Python コードの核心。ファイル操作は必ずコンテキストマネージャ(
with)を使い、print ではなく logging を使う習慣が実務品質の基準となる。