問題
以下は「販促クーポン管理モジュール」の実装です。 このコードには設計上・Pythonイディオムの観点から複数の問題があります。 問題点を列挙し、Python 3.12+ のベストプラクティスで改善してください。
制約・前提条件
- Python 3.12+ を使用すること
dataclassまたは@dataclass(slots=True)でクーポンを値オブジェクトとして表現すること- 「良いコード・悪いコードで学ぶ設計入門」Ch8(条件分岐)・Ch9(コレクション)・Ch10(設計の悪魔)の観点を活用すること
StrEnumを使ってステータスを型安全に扱うこと- マジックナンバー(
9999999)は名前付き定数化すること
期待する回答形式:
1. 問題点の列挙(番号付き)
2. 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)
3. 実行例(input → output)
4. 適用した設計パターン名と書籍の対応章
1. 問題点の列挙(番号付き)
2. 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)
3. 実行例(input → output)
4. 適用した設計パターン名と書籍の対応章
悪いコード (Before)
このコードには 6つの設計上の問題 が隠れています。見つけてみてください。
# --- BAD CODE ---
COUPON_LIST = []
def add_coupon(code, discount, min_order, is_active, expires):
COUPON_LIST.append({
"code": code,
"discount": discount,
"min_order": min_order,
"is_active": is_active,
"expires": expires,
"used_count": 0,
})
def apply_coupon(code, order_amount):
for c in COUPON_LIST:
if c["code"] == code:
if c["is_active"] == True:
if c["discount"] > 0:
if order_amount >= c["min_order"]:
if c["used_count"] < 9999999:
c["used_count"] = c["used_count"] + 1
d = order_amount * c["discount"] / 100
return order_amount - d
else:
return None
else:
return None
else:
return None
else:
return None
return None
def get_active_coupons():
result = []
for c in COUPON_LIST:
if c["is_active"] == True:
result.append(c)
return result
def deactivate_expired(now):
for c in COUPON_LIST:
if c["expires"] < now:
c["is_active"] = False
ヒント(段階的開示)
ヒント1 — 問題の3分類
コードの問題は大きく3種類に分類できます。
① データ表現の問題(辞書をそのまま使っている)
② 条件分岐の構造的問題(ネスト・早期リターンの欠如)
③ コレクション操作の問題(グローバルミュータブル状態・ループの書き方)
① データ表現の問題(辞書をそのまま使っている)
② 条件分岐の構造的問題(ネスト・早期リターンの欠如)
③ コレクション操作の問題(グローバルミュータブル状態・ループの書き方)
ヒント2 — 各問題のヒント
- クーポンを
dictで表現しているため、キーの typo や型不一致が実行時まで検出されない apply_couponのifネストは5段階 → 早期returnで逆条件を先に弾くことで平坦化できるCOUPON_LISTはグローバル変数かつミュータブル → クラスにカプセル化することで副作用を制御できるused_count < 9999999はマジックナンバー →MAX_USAGE_COUNT = 9_999_999のように定数化is_active == Trueは冗長な比較 →is_activeで十分
ヒント3 — 目指す構造のスケルトン
from dataclasses import dataclass, field
from enum import StrEnum
from datetime import datetime
MAX_USAGE_COUNT = 9_999_999
class CouponStatus(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
@dataclass(slots=True)
class Coupon:
code: str
discount_rate: int
min_order_amount: int
status: CouponStatus
expires_at: datetime
used_count: int = field(default=0)
def is_usable(self, order_amount: int) -> bool:
"""早期return形式"""
if self.status != CouponStatus.ACTIVE:
return False
# ... (各ガード節)
return True
class CouponRepository:
def __init__(self) -> None:
self._coupons: dict[str, Coupon] = {}
# apply / get_active_coupons / deactivate_expired を実装
問題点分析
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | dict でクーポンを表現している |
データ表現 | @dataclass(slots=True) で値オブジェクト化。型チェックと補完が効く |
| 2 | apply_coupon の5段ネストの if |
条件分岐 | 早期 return で逆条件を先に弾く(Ch8: 早期 return) |
| 3 | COUPON_LIST グローバルミュータブル変数 |
コレクション | CouponRepository クラスに _coupons としてカプセル化(Ch9) |
| 4 | マジックナンバー 9999999 |
可読性 | MAX_USAGE_COUNT = 9_999_999 と名前付き定数化(Ch10) |
| 5 | is_active == True の冗長な比較 |
イディオム | if c.status == CouponStatus.ACTIVE: に変更 |
| 6 | 金額計算に float を使用 |
精度問題 | Decimal に変更(EC・金融系では必須) |
リファクタリング後の設計図
模範解答
"""販促クーポン管理モジュール(改善版)。
グローバル状態を排除し、Coupon を値オブジェクト、CouponRepository を
集約ルートとしてカプセル化している。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from decimal import Decimal
# --- 定数 ---
MAX_USAGE_COUNT: int = 9_999_999
"""クーポンの最大利用回数(実質的な上限なし)。"""
class CouponStatus(StrEnum):
"""クーポンのアクティブ状態を型安全に表現する列挙型。"""
ACTIVE = "active"
INACTIVE = "inactive"
@dataclass(slots=True)
class Coupon:
"""クーポンを表す値オブジェクト。
Attributes:
code: クーポンコード(一意)。
discount_rate: 割引率(0〜100 の整数 %)。
min_order_amount: 適用可能な最低注文金額(円)。
status: クーポンの有効状態。
expires_at: 有効期限。
used_count: 利用済み回数。
"""
code: str
discount_rate: int
min_order_amount: int
status: CouponStatus
expires_at: datetime
used_count: int = field(default=0)
def is_usable(self, order_amount: int) -> bool:
"""クーポンが指定注文金額に適用可能かを判定する。
早期 return で逆条件を先に弾くことでネストを排除している(Ch8)。
"""
if self.status != CouponStatus.ACTIVE:
return False
if self.discount_rate <= 0:
return False
if order_amount < self.min_order_amount:
return False
if self.used_count >= MAX_USAGE_COUNT:
return False
return True
def calc_discount_amount(self, order_amount: int) -> Decimal:
"""割引額を計算して返す(純粋関数・副作用なし)。"""
return Decimal(order_amount) * Decimal(self.discount_rate) / Decimal(100)
class CouponRepository:
"""クーポンの永続化・検索・状態変更を担うリポジトリ。
コレクション自体をカプセル化している(Ch9: コレクションのカプセル化)。
"""
def __init__(self) -> None:
# コードをキーにした辞書で O(1) 検索を実現
self._coupons: dict[str, Coupon] = {}
def add(self, coupon: Coupon) -> None:
"""クーポンを登録する。"""
if coupon.code in self._coupons:
raise ValueError(f"Coupon code '{coupon.code}' already exists.")
self._coupons[coupon.code] = coupon
def apply(self, code: str, order_amount: int) -> Decimal | None:
"""クーポンを注文に適用し、割引後金額を返す。"""
coupon = self._coupons.get(code)
if coupon is None:
return None
if not coupon.is_usable(order_amount):
return None
coupon.used_count += 1
discount = coupon.calc_discount_amount(order_amount)
return Decimal(order_amount) - discount
def get_active_coupons(self) -> list[Coupon]:
"""有効なクーポン一覧を返す(内包表記)。"""
return [c for c in self._coupons.values() if c.status == CouponStatus.ACTIVE]
def deactivate_expired(self, now: datetime) -> int:
"""有効期限切れのクーポンを無効化し、無効化した件数を返す。"""
count = 0
for coupon in self._coupons.values():
if coupon.expires_at < now and coupon.status == CouponStatus.ACTIVE:
coupon.status = CouponStatus.INACTIVE
count += 1
return count
ポイント解説
1
マジックナンバーの排除(Ch10: 設計の悪魔)
9999999 を MAX_USAGE_COUNT = 9_999_999 に置き換え。数値の意味が名前から伝わるようになった。
2
早期 return でネスト平坦化(Ch8: 条件分岐)
apply_coupon の5段ネストを is_usable の早期 return に分解。各ガード節が独立して読みやすい。
3
コレクションのカプセル化(Ch9)
グローバル
グローバル
COUPON_LIST を CouponRepository クラスの _coupons に隠蔽。外部から直接リストを操作できなくなり、副作用の範囲が明確になった。
4
値オブジェクト化(Ch3: カプセル化)
クーポンを
クーポンを
dict から @dataclass(slots=True) に変更。型チェックが静的解析で可能になり、typo によるキーエラーがなくなった。
5
StrEnum による型安全(Python 3.11+)
is_active: bool → status: CouponStatus に変更。文字列比較でありながら IDE の補完と型チェックの両方が効く。
6
Decimal による金額計算
浮動小数点誤差を避けるため
浮動小数点誤差を避けるため
Decimal を使用。EC・金融系では必須の判断。
Before vs After 比較
| 観点 | Before | After |
|---|---|---|
| データ表現 | dict(型チェック不可) | @dataclass(slots=True)(型安全) |
| 状態管理 | グローバル COUPON_LIST | CouponRepository._coupons(カプセル化) |
| 条件分岐 | 5段ネスト if | 早期 return(フラット) |
| 数値リテラル | 9999999(マジックナンバー) | MAX_USAGE_COUNT = 9_999_999 |
| 金額計算 | float(誤差あり) | Decimal(精度保証) |
| 検索効率 | 線形スキャン O(n) | 辞書検索 O(1) |
実務への応用
-
Argo Workflowsのタイムゾーン注入:
deactivate_expired(now=datetime.now(tz=JST))をArgo Workflowsの定期ジョブとして実装する際、nowを引数で受け取る設計にしておくとユニットテストで時刻を差し込める。 -
Repository パターンの実装切り替え:
CouponRepositoryの_couponsを実際の実装では BigQuery や Cloud Firestore に差し替えることで、テスト時はインメモリ、本番は外部ストレージと切り替えられる。 -
型チェックツールによる安全性向上:
applyの返り値をDecimal | Noneにしたことで、mypy/pyright が「None チェックなしに計算している」コードを検出してくれる。
次のステップ
発展問題:
CouponRepository にユニットテスト(pytest)を書く。deactivate_expired で時刻を freezegun でモックし、境界値テストを追加する。
- 参考: 「良いコード・悪いコードで学ぶ設計入門」改訂新版 Ch8・Ch9・Ch10
- 参考: PEP 663 (StrEnum)、Python
decimal標準ライブラリ
今日のまとめ
グローバルなミュータブルコレクションは「誰でも触れる状態」を生み出し、バグの温床になる。
カプセル化・早期 return・値オブジェクト化の3手を組み合わせることで、テストしやすく変更に強いコードへ変貌する。