概要
再代入禁止(Ch4.1)
ローカル変数への再代入をなくす。total が何度も意味を変えながら再代入されると追跡不可能になる。
副作用排除(Ch4.2)
引数のオブジェクトを書き換えない。item.price = item.price * item.qty は呼び出し元を破壊する典型的な副作用。
不変オブジェクト(Ch4.3)
@dataclass(frozen=True) で属性への再代入を禁止。FrozenInstanceError で設計意図を強制できる。
Decimal による金額精度
float の 0.1 * 100 = 9.999... 問題を防ぐ。ECサイトの金額計算では Decimal が必須。
問題
以下の「悪いコード」があります。ECサイトのカート合計金額計算クラスです。
リファクタリング要件
- 問題点を日本語で列挙すること
- 書籍第4章の原則(再代入禁止・副作用排除・不変オブジェクト)を適用
- 改善後コードに Google スタイル docstring を付けること
- 実行例(正常系・異常系)と期待出力を示すこと
- 適用した設計パターン名と書籍の対応章を明記すること
制約: 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=-100 や qty=0 の不正な CartItem が生成できる |
__post_init__ でバリデーション |
| 5 | 浮動小数点演算の精度問題 | 精度問題 | TAX_RATE = 0.10 は float のため金額計算に使うと端数誤差が発生 |
Decimal("0.10") に変更 |
副作用フロー図 — Before vs After
模範解答
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 に
Python に
final はないが、変数名に意味を持たせ一度だけ代入することで「この変数は何か」を読んだ瞬間に分かる設計にする。
total を4回再代入するより、subtotal_sum → discounted_amount → total_with_tax の流れが追いやすい。
3
frozen=True の威力(4.3)
FrozenInstanceError を発生させることで「ここは変えてはいけない」をランタイムで強制できる。
チームコードで意図しない変更を防ぐ最も確実な方法。
4
Decimal による金額計算
float の 0.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章: 関心の分離)
今日のまとめ
変数は一度だけ代入し、引数のオブジェクトを壊さないのが不変活用の本質。
EC系の金額計算では 必ず
@dataclass(frozen=True) で不変を強制し、@property で「元データを変えずに計算」を表現することで、副作用ゼロの安全なコードになる。EC系の金額計算では 必ず
Decimal を使うこと(float は誤差が出る)。