コーディング/アルゴリズム — StrEnum × 非破壊的更新 × リソース管理

2026-05-11 (Day 28) 月曜 A: コーディング ★★★☆☆ Python 3.12 / dataclasses / StrEnum 「良いコード・悪いコードで学ぶ設計入門」Ch3 × Ch5

概要

🏷️

StrEnum による型安全

status: strOrderStatus(StrEnum) でタイポによる不正値を防ぐ。JSON シリアライズとも互換。

🔄

dataclasses.replace 非破壊的更新

元オブジェクトを変更せず新しいインスタンスを返す。副作用フリーな関数で呼び出し元を守る。

📁

コンテキストマネージャ

with path.open() で確実なファイルクローズ。例外が発生しても __exit__ が呼ばれる。

🔢

名前付き定数 APPROVAL_THRESHOLD

マジックナンバー 10000Final 付き定数に。変更箇所が一箇所になりビジネスルール変更に強くなる。

問題

以下の「悪いコード」は ECサイトの注文データを処理する Python モジュール。問題点を列挙し、第3章(クラスで表現する)第5章(低凝集) の原則を適用してリファクタリングしてください。

制約・前提条件

  • Python 3.12 以上
  • items の各要素は {"price": float, "qty": int} の dict
  • status"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 > 1000010000 は名前付き定数にすべきです
  • o.status = ... は呼び出し元のオブジェクトを直接変更しており、副作用が読みにくい
  • ファイルを open() / close() で管理するのは例外安全でない
  • @dataclassitems: 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() に置き換え

設計構造図 — 改善後のモジュール構成

OrderStatus (StrEnum) PENDING = "pending" APPROVED = "approved" REVIEW = "review" OrderItem @dataclass price: float; qty: int subtotal() -> float Order @dataclass id: int; user_id: int items: list[OrderItem] status: OrderStatus 関数群(モジュールレベル) _calculate_total(order) -> float 純粋関数・副作用なし _classify_order(order) -> Order dataclasses.replace で非破壊的 process_orders(orders) -> list[Order] pending のみフィルタして審査 save_orders(orders, path) -> None with path.open() + OSError 伝搬 APPROVAL_THRESHOLD Final[float] = 10_000.0

模範解答

# 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_THRESHOLD
10000 という magic number は変更時に全ファイルを grep しなければならない。Final 付き定数として切り出すことで変更箇所が一箇所になり、ビジネスルールの変更に強くなる。
3 dataclasses.replace による非破壊的更新
元の Order オブジェクトを直接変更すると、呼び出し元が保持している参照の値も変わる(副作用)。replace() で新しいインスタンスを生成することで関数を副作用フリーにする。
4 コンテキストマネージャ (with) によるリソース管理
open() / close() の手動管理は例外発生時にファイルがクローズされない。with 文を使うと __exit__ が確実に呼ばれる。
5 printlogging への置き換え
print("saved") は本番環境でのログレベル制御・構造化ロギング・テスト時の出力抑制ができない。logger.info() を使うことで運用上のコントロールが可能になる。

Before vs After 比較

観点BeforeAfter
ステータス表現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_orderspathPath 型にしておくと PurePosixPath でラップしたGCSユーティリティへ差し替えやすい
  • BigQuery との親和性: OrderStatusStrEnum にしておくと BigQuery の STRING カラムとのマッピングが自然に行える

今日のまとめ

「状態を表す文字列は StrEnum で、変更は dataclasses.replace で新インスタンスとして返す」——型安全性と副作用フリー設計の2つが、保守性の高い Python コードの核心。

ファイル操作は必ずコンテキストマネージャ(with)を使い、print ではなく logging を使う習慣が実務品質の基準となる。

自己評価

自分の回答

気づき・メモ