コーディング設計 — 不変の活用 × 副作用排除

2026-04-06 (Day 2) A: コーディング設計 ★★★☆☆ 「良いコード・悪いコードで学ぶ設計入門」Ch4

概要

🚫

再代入禁止(Ch4.1)

ローカル変数への再代入をなくす。total が何度も意味を変えながら再代入されると追跡不可能になる。

🛡️

副作用排除(Ch4.2)

引数のオブジェクトを書き換えない。item.price = item.price * item.qty は呼び出し元を破壊する典型的な副作用。

❄️

不変オブジェクト(Ch4.3)

@dataclass(frozen=True) で属性への再代入を禁止。FrozenInstanceError で設計意図を強制できる。

🔢

Decimal による金額精度

float0.1 * 100 = 9.999... 問題を防ぐ。ECサイトの金額計算では Decimal が必須。

問題

以下の「悪いコード」があります。ECサイトのカート合計金額計算クラスです。

リファクタリング要件

  1. 問題点を日本語で列挙すること
  2. 書籍第4章の原則(再代入禁止・副作用排除・不変オブジェクト)を適用
  3. 改善後コードに Google スタイル docstring を付けること
  4. 実行例(正常系・異常系)と期待出力を示すこと
  5. 適用した設計パターン名と書籍の対応章を明記すること
制約: Python 3.12+ / 既存の動作(合計金額の計算・クーポン割引・税込み計算・ゼロ下限)は変えない

悪いコード (Before)

このコードには 5つの設計上の問題 が隠れています。見つけてみてください。
# bad_price_calculator.py

TAX_RATE = 0.10

class CartItem:
    def __init__(self, name, price, qty):
        self.name = name
        self.price = price  # int(円)
        self.qty = qty      # int(個)


def calculate_total(items: list, coupon_discount: int = 0) -> float:
    total = 0
    for item in items:
        item.price = item.price * item.qty  # ← 小計を price に上書き(副作用!)
        total = total + item.price
    total = total - coupon_discount         # ← total 再代入
    tax = total * TAX_RATE
    total = total + tax                     # ← total 再代入
    if total < 0:
        total = 0                           # ← total 再代入
    return total

ヒント(段階的開示)

ヒント1 — 方向性
calculate_total 内で total が何度も再代入されており、「今この時点の total は何を表しているか」が読んだ瞬間に分からない。 また item.price = item.price * item.qty は呼び出し元の CartItem オブジェクトを破壊する副作用。 「変数は一度しか代入しない」「入力を壊さない」という第4章の原則で設計を見直そう。
ヒント2 — アプローチ
  • @dataclass(frozen=True) にすると属性への再代入が FrozenInstanceError になり、コードレベルで副作用を防げる
  • ローカル変数の再代入防止は Python に final キーワードがないため設計で表現する。subtotal, discounted_amount, tax_amount と意味単位で変数を割り当てる
  • CartItem の小計は item.price * item.qty@property として定義すると、元データを壊さずに計算できる
ヒント3 — 目指す骨格
@dataclass(frozen=True)
class CartItem:
    name: str
    price: int
    qty: int

    @property
    def subtotal(self) -> int:
        """単価 × 数量(元の price を破壊しない)"""
        return self.price * self.qty


def calculate_total(items: list[CartItem], coupon_discount: int = 0) -> int:
    # 再代入なし: 各金額に意味のある名前を付けて一度だけ代入
    subtotal_sum = sum(item.subtotal for item in items)
    discounted_amount = subtotal_sum - coupon_discount
    tax_amount = int((Decimal(discounted_amount) * TAX_RATE).quantize(...))
    total_with_tax = discounted_amount + tax_amount
    return max(total_with_tax, 0)

問題点分析

#問題点分類影響改善方法
1 item.price = item.price * item.qty で呼び出し元のオブジェクトを上書き 副作用 同じ items を再利用すると誤った値で計算される @property subtotal で元データを壊さず計算
2 ローカル変数 total の多重再代入 再代入 total が「小計合計」「割引後」「税込み」と意味を変えながら変化 subtotal_sum / discounted_amount / total_with_tax と分離
3 CartItem がミュータブル 不変性 外部から item.price = 999 と書き換え可能 frozen=True で不変化
4 CartItem にバリデーションがない 生焼けオブジェクト price=-100qty=0 の不正な CartItem が生成できる __post_init__ でバリデーション
5 浮動小数点演算の精度問題 精度問題 TAX_RATE = 0.10float のため金額計算に使うと端数誤差が発生 Decimal("0.10") に変更

副作用フロー図 — Before vs After

✗ Before(副作用あり) CartItem price: int (mutable) calculate_total() item.price = price * qty ← 破壊! 呼び出し元のpriceが変わる CartItem price: 6000 (破壊後!) ✓ After(副作用なし) CartItem (frozen) price: int subtotal @property ❄️ 変更不可 calculate_total() subtotal_sum discounted_amount tax_amount total_with_tax 各変数1回のみ代入 CartItem (変化なし) price: 元の値のまま ✓

模範解答

from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal


TAX_RATE = Decimal("0.10")


@dataclass(frozen=True)
class CartItem:
    """カートの商品1行を表す不変オブジェクト。

    frozen=True により属性への再代入を禁止し、外部からの破壊的変更を防ぐ。

    Args:
        name: 商品名。
        price: 単価(円)。1以上の整数。
        qty: 数量(個)。1以上の整数。
    """

    name: str
    price: int
    qty: int

    def __post_init__(self) -> None:
        if self.price < 1:
            raise ValueError(f"単価は1円以上が必要です: {self.price}")
        if self.qty < 1:
            raise ValueError(f"数量は1個以上が必要です: {self.qty}")

    @property
    def subtotal(self) -> int:
        """単価 × 数量の小計を返す(元の price を変更しない)。"""
        return self.price * self.qty


def calculate_total(items: list[CartItem], coupon_discount: int = 0) -> int:
    """カートの税込み合計金額を計算する。

    各変数は一度だけ代入し、副作用を持たない純粋な計算を行う。

    Args:
        items: カート内の商品リスト。
        coupon_discount: クーポン割引額(円)。デフォルト 0。

    Returns:
        税込み合計金額(円)。割引・税計算後がマイナスの場合は 0 を返す。
    """
    # 小計の合算(items は一切変更しない)
    subtotal_sum = sum(item.subtotal for item in items)

    # クーポン割引後(再代入なし: 新しい変数に意味のある名前で代入)
    discounted_amount = subtotal_sum - coupon_discount

    # 税額(Decimal で端数処理: 四捨五入)
    tax_amount = int(
        (Decimal(discounted_amount) * TAX_RATE).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
    )

    # 税込み合計
    total_with_tax = discounted_amount + tax_amount

    # ゼロ下限(クーポン過大適用への防御)
    return max(total_with_tax, 0)
# --- 正常系: 通常購入 ---
items = [
    CartItem("Tシャツ", 2000, 2),   # 小計 4000円
    CartItem("帽子", 1000, 1),       # 小計 1000円
]
print(calculate_total(items))                        # 5500  ← 5000 * 1.10
print(calculate_total(items, coupon_discount=500))   # 4950  ← (5000-500) * 1.10
# items は変化していない(副作用なし)
print(items[0].price)                                # 2000  ← 元の単価のまま

# --- 正常系: クーポン過大適用(ゼロ下限) ---
print(calculate_total(items, coupon_discount=99999)) # 0

# --- 異常系: 不正な CartItem ---
try:
    CartItem("無効商品", -100, 1)
except ValueError as e:
    print(e)  # 単価は1円以上が必要です: -100

# --- frozen=True の確認 ---
item = CartItem("テスト商品", 500, 3)
print(item.subtotal)  # 1500
print(item.price)     # 500  ← subtotal を呼んでも price は変わらない
try:
    item.price = 999  # type: ignore
except Exception as e:
    print(type(e).__name__)  # FrozenInstanceError

適用した設計パターン

パターン書籍の章内容
不変オブジェクト(frozen=True) 第4章 4.3.1 @dataclass(frozen=True) で属性への再代入を禁止。外部からの破壊的変更をランタイムで防ぐ
副作用の排除 第4章 4.2 引数 items を一切変更しない。小計計算は @property subtotal に委譲し元データを保護
再代入防止 第4章 4.1 total の多重再代入を廃止し、subtotal_sum / discounted_amount / tax_amount / total_with_tax と意味単位で変数を分離
完全コンストラクタ 第3章 3.4.1 __post_init__ でバリデーションを行い、生焼けオブジェクトをなくす(前章の復習)

ポイント解説

1 副作用の排除(4.2)
item.price = item.price * item.qty は「関数が引数として受け取ったオブジェクトの状態を変える」典型的な副作用。 呼び出し側は price が変わるとは思っていないため、二回目に calculate_total を呼ぶと壊れた値で計算される。 @property で「壊さずに計算する」が副作用ゼロの基本形。
2 再代入防止(4.1)
Python に final はないが、変数名に意味を持たせ一度だけ代入することで「この変数は何か」を読んだ瞬間に分かる設計にする。 total を4回再代入するより、subtotal_sumdiscounted_amounttotal_with_tax の流れが追いやすい。
3 frozen=True の威力(4.3)
FrozenInstanceError を発生させることで「ここは変えてはいけない」をランタイムで強制できる。 チームコードで意図しない変更を防ぐ最も確実な方法。
4 Decimal による金額計算
float0.10 * 100 = 9.999999... 問題は金額計算で致命的。 Decimal("0.10") を使い ROUND_HALF_UP で端数処理することが EC システムの標準パターン。

実務への応用

EC販促システム(MOps)での活用例
  • Argo Workflows: @dataclass(frozen=True) の設定オブジェクトを各 Workflow ステップに渡すと、ステップ内で設定が書き換えられる心配がなくなる
  • BigQuery エクスポート: 不変な CartItem は複数ワーカーからの並列アクセスでも安全。Pydantic v2 モデルに変換して BQ ストリーミングインサートに渡せる
  • 金額計算の Decimal 化: dbt モデルの NUMERIC 型と整合しやすく、集計クエリでの端数誤差も減らせる

次のステップ

発展問題1: CartItem の「数量変更」操作を dataclasses.replace() で実装するとどうなるか?(コピーオンライト設計)
発展問題2: 複数の税率(軽減税率 8% / 通常税率 10%)を CartItem に持たせるにはどう設計するか?(第6章: 関心の分離)

今日のまとめ

変数は一度だけ代入し、引数のオブジェクトを壊さないのが不変活用の本質。

@dataclass(frozen=True) で不変を強制し、@property で「元データを変えずに計算」を表現することで、副作用ゼロの安全なコードになる。

EC系の金額計算では 必ず Decimal を使うこと(float は誤差が出る)。

自己評価

自分の回答

気づき・メモ