概要
退化コメントの排除(Ch12)
# 小計、# 送料を計算する、# 合計を返す のような、コードを読めば1秒で分かることをそのまま日本語化しただけのコメントを削除し、変数名・定数名自体で意図を伝える。
コメントと実装の同期(Ch12)
# 送料880円を加算 というコメントが、送料改定で実装が 990 に変わった後も取り残されていた。名前付き定数に置き換えることで、値そのものが「今の正しい値」であり続け、原理的にズレが起きなくする。
死んだコードの削除(Ch12)
理由不明のままコメントアウトされたポイント還元ロジックを削除。Gitの履歴で復元可能なため、ソースコード上に残す理由がない。
Why型コメント・docstring・TODO書式(Ch12)
閾値・割引率が「なぜその値なのか」というビジネス根拠を docstring に明記し、# TODO: ... を # TODO(担当者): 内容 (Issue: 番号, 対応予定: 時期) の追跡可能な形式に統一する。
問題
ECサイト MOps チームでは、カート合計金額(小計・送料・キャンペーン割引・総額)を計算する calculate_cart_total を Python で実装している。以下の「悪いコード」は、コードをそのまま日本語に翻訳しただけの「退化コメント」が随所にあり、さらに送料のコメント(880円を加算)が2025-10の送料改定で実装(990)と食い違ったまま放置されている。加えて、理由不明のままコメントアウトされたポイント還元ロジックの死んだコードや、担当者・期限・チケット番号のないTODOコメントも残っている。
制約・前提条件
- Python 3.12+、金額計算には
decimal.Decimalを使用すること(浮動小数点誤差を避ける) - 送料無料閾値・標準送料・キャンペーン割引閾値・割引率は名前付き定数にすること(
0.9のような乗数ではなくCAMPAIGN_DISCOUNT_RATE = Decimal("0.10")のように「引かれる側の率」を明示すること) - 戻り値は
subtotal/shipping_fee/discount/totalを保持するdataclass(frozen=True, slots=True)の値オブジェクト(CartTotal)にすること - 公開関数には Google スタイル docstring を付け、Args/Returns/Raises に加えて「なぜその閾値・割引率なのか」というビジネス上の根拠(Why)を1〜2文で書くこと(架空のチケット番号可)
- コメントアウトされた死んだコード(ポイント還元ロジック)は削除すること(Gitの履歴に残るため復元は可能、という方針をどこかに一言残せばよい)
- TODOコメントは
# TODO(担当者): 内容 (Issue: チケット番号, 対応予定: 時期)の形式で書き直すこと - コードを読めば分かることを繰り返すだけの「退化コメント」(例:「小計」「送料を計算する」「合計を返す」)は残さないこと
悪いコード (Before)
def calculate_cart_total(price, quantity, is_premium_member):
"""カート合計を計算する"""
# 問題1: 退化コメント(読めば分かることの繰り返し)
subtotal = price * quantity # 小計
# 送料を計算する
# 問題2: 実装は990円なのにコメントは880円のまま(送料改定で不一致)
shipping_fee = 990 # 送料880円を加算
if subtotal >= 5000:
shipping_fee = 0 # 5000円以上は送料無料にする
# プレミアム会員は送料無料
if is_premium_member:
shipping_fee = 0
# キャンペーン割引を適用する
total = subtotal + shipping_fee
# 問題3: マジックナンバー(10000, 0.9)の意味がコメント頼み
if subtotal >= 10000:
total = total * 0.9 # 0.9をかけて10%引く
# 問題4: 理由不明のまま放置された死んだコード
# 以下は旧ロジック(ポイント還元は別モジュールに移動済み)
# points = int(subtotal * 0.01)
# total -= points
# 問題5: 担当者・期限・チケット番号のないTODO
# TODO: 消費税の軽減税率対応
# 合計を返す
return total
# 小計, # 送料を計算する, # 合計を返す はコードと同じ情報量しか持たず、読み手に新しい情報を与えない# 送料880円を加算 と書かれているが、実装は送料改定後の 990 のまま取り残され、読むと誤解する5000, 990, 10000, 0.9 がコメントで補足されているだけで名前付き定数化されていない"""カート合計を計算する""" で、Args/Returns/Raisesの説明がない# TODO: 消費税の軽減税率対応 に担当者・チケット番号・対応時期がなく、いつまでも放置されやすいヒント(段階的開示)
ヒント1 — 方向性
# 単価に数量をかけて小計を出す というコメントの直後に subtotal = price * quantity という、まさにその通りのコードが続いている。このコメントは何か新しい情報を読み手に与えているだろうか? コードを読めば1秒で分かることをもう一度日本語で言い直しているだけなら、それは第12章でいう「退化コメント」であり、むしろメンテナンスコスト(コードを直したのにコメントを直し忘れる)だけを増やす。実際に # 送料880円を加算 というコメントは、実装が 990 に変わった後も更新されず、今や嘘の情報源になっている。良いコメントは「コードを見れば分かるWhat」ではなく「コードを見ても分からないWhy(なぜその値・その判断なのか)」を書くものだと考えると、どこを削り、どこを足すべきかが見えてくる。
ヒント2 — アプローチ
- 退化コメント(
# 小計,# 送料を計算する,# 合計を返すなど)→ すべて削除。その代わりsubtotal,shipping_fee,discount,totalという変数名・定数名自体で意図が伝わるようにする - 食い違ったコメント(
# 送料880円を加算vs 実装990)→ 削除し、STANDARD_SHIPPING_FEE_YEN = Decimal("990")という名前付き定数+docstring内の「Why」記述に置き換える # 以下は旧ロジック(...)のコメントアウトされた死んだコード → 削除する。Gitで復元可能なので保持する理由がない5000,990,10000,0.9のマジックナンバー →FREE_SHIPPING_THRESHOLD_YEN/STANDARD_SHIPPING_FEE_YEN/CAMPAIGN_DISCOUNT_THRESHOLD_YEN/CAMPAIGN_DISCOUNT_RATEに置き換え、なぜその値なのかは関数の docstring に1〜2文でまとめて書く(コード各所に散らばせない)# TODO: 消費税の軽減税率対応→# TODO(kainuma): 軽減税率(8%)対象商品の税区分判定を追加する (Issue: MOPS-1421, 対応予定: 2026-Q4)のように、担当者・内容・チケット・時期が揃った形式にする- 関数の一行docstring
"""カート合計を計算する"""→ Args/Returns/Raises と、閾値・割引率の設定根拠(Why)を含む Google スタイル docstring に拡張する
ヒント3 — コードの骨格
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
FREE_SHIPPING_THRESHOLD_YEN = Decimal("5000")
STANDARD_SHIPPING_FEE_YEN = Decimal("990")
CAMPAIGN_DISCOUNT_THRESHOLD_YEN = Decimal("10000")
CAMPAIGN_DISCOUNT_RATE = Decimal("0.10")
@dataclass(frozen=True, slots=True)
class CartTotal:
subtotal: Decimal
shipping_fee: Decimal
discount: Decimal
total: Decimal
def calculate_cart_total(
unit_price: Decimal, quantity: int, is_premium_member: bool
) -> CartTotal:
"""...
Why: ここに閾値・割引率の設定根拠を書く(Issue番号でも可)
Args: ...
Returns: ...
Raises: ...
"""
... # 退化コメントなし。Why以外のコメントは基本不要
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 退化コメント(小計/送料計算/合計を返す) | 退化コメント Ch12 | 削除。変数名・定数名で意図を伝える |
| 2 | 「送料880円」コメントが実装990円と不一致 | 実装との不一致 Ch12 | STANDARD_SHIPPING_FEE_YEN 定数化 |
| 3 | 5000/990/10000/0.9 のマジックナンバー | マジックナンバー Ch10 | 名前付き定数4種 |
| 4 | コメントアウトされたポイント還元ロジック | 死んだコード Ch12 | 削除(Git履歴で代替) |
| 5 | 閾値・割引率の設定根拠(Why)が皆無 | Why欠如 Ch12 | docstringに根拠・チケット番号記載 |
| 6 | 一行docstringでArgs/Returns/Raisesなし | docstring未整備 Ch12 | Googleスタイルdocstring |
| 7 | 担当者・チケット・時期のないTODO | TODO書式 Ch12 | TODO(担当者): ... (Issue, 対応予定) |
コメント分類図(SVG)— 消す/直す/足すの3分類
模範解答
def calculate_cart_total(price, quantity, is_premium_member):
"""カート合計を計算する"""
# 単価に数量をかけて小計を出す
subtotal = price * quantity # 小計
# 送料を計算する
shipping_fee = 990 # 送料880円を加算
if subtotal >= 5000:
shipping_fee = 0 # 5000円以上は送料無料にする
# プレミアム会員は送料無料
if is_premium_member:
shipping_fee = 0
# キャンペーン割引を適用する
total = subtotal + shipping_fee
if subtotal >= 10000:
total = total * 0.9 # 0.9をかけて10%引く
# 以下は旧ロジック(ポイント還元は別モジュールに移動済み)
# points = int(subtotal * 0.01)
# total -= points
# TODO: 消費税の軽減税率対応
# 合計を返す
return total
"""cart_pricing.py — カート合計金額(小計・送料・割引・総額)の計算
良いコード・悪いコードで学ぶ設計入門(改訂新版)
Ch12: コメント — 退化コメントの排除、コメントと実装の同期、目的(Why)の文書化
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
# 送料改定(2025-10)以降の基準値。この金額(円)以上の小計、または
# プレミアム会員(会員ランク特典 2025-Q4, PROMO-2025-42)の場合に送料無料となる。
FREE_SHIPPING_THRESHOLD_YEN = Decimal("5000")
# 国内標準送料(2025-10 改定後の金額、円)。
STANDARD_SHIPPING_FEE_YEN = Decimal("990")
# まとめ買い促進キャンペーンの適用条件(販促企画チーム承認: PROMO-2025-88)。
# 小計がこの金額(円)以上の注文に CAMPAIGN_DISCOUNT_RATE を適用する。
CAMPAIGN_DISCOUNT_THRESHOLD_YEN = Decimal("10000")
CAMPAIGN_DISCOUNT_RATE = Decimal("0.10")
@dataclass(frozen=True, slots=True)
class CartTotal:
"""カート合計金額の内訳を保持する値オブジェクト。
Attributes:
subtotal: 小計(単価 × 数量、円)。
shipping_fee: 送料(円)。無料条件を満たす場合は0。
discount: キャンペーン割引額(円)。
total: 小計 + 送料 - 割引の総額(円)。
"""
subtotal: Decimal
shipping_fee: Decimal
discount: Decimal
total: Decimal
def calculate_cart_total(
unit_price: Decimal, quantity: int, is_premium_member: bool
) -> CartTotal:
"""カート合計金額(小計・送料・割引・総額)を計算する。
送料はプレミアム会員のLTV向上施策(会員ランク特典 2025-Q4, PROMO-2025-42)
として、小計が FREE_SHIPPING_THRESHOLD_YEN 以上、またはプレミアム会員の
場合に無料となる。キャンペーン割引(CAMPAIGN_DISCOUNT_RATE)は、まとめ買い
促進を目的として小計 CAMPAIGN_DISCOUNT_THRESHOLD_YEN 以上の注文に適用される
(販促企画チーム承認: PROMO-2025-88)。
Args:
unit_price: 商品単価(円)。
quantity: 数量。
is_premium_member: プレミアム会員かどうか。
Returns:
小計・送料・割引額・総額を保持する CartTotal。
Raises:
ValueError: unit_price または quantity が0以下の場合。
"""
if unit_price <= 0 or quantity <= 0:
raise ValueError("unit_price と quantity は正の値である必要があります")
subtotal = unit_price * quantity
shipping_fee = STANDARD_SHIPPING_FEE_YEN
if subtotal >= FREE_SHIPPING_THRESHOLD_YEN or is_premium_member:
shipping_fee = Decimal("0")
discount = Decimal("0")
if subtotal >= CAMPAIGN_DISCOUNT_THRESHOLD_YEN:
discount = (subtotal * CAMPAIGN_DISCOUNT_RATE).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
total = subtotal + shipping_fee - discount
# TODO(kainuma): 軽減税率(8%)対象商品の税区分判定を追加する。
# 現在は全商品を標準税率10%として扱っており、食品等が混在するカートの
# 税額按分は未対応。 (Issue: MOPS-1421, 対応予定: 2026-Q4)
return CartTotal(
subtotal=subtotal, shipping_fee=shipping_fee, discount=discount, total=total
)
# 通常会員・少額(送料あり・割引なし)
result = calculate_cart_total(Decimal("1200"), 2, is_premium_member=False)
print(result)
# CartTotal(subtotal=Decimal('2400'), shipping_fee=Decimal('990'), discount=Decimal('0'), total=Decimal('3390'))
# 通常会員・送料無料閾値超(割引なし)
result = calculate_cart_total(Decimal("2500"), 3, is_premium_member=False)
print(result)
# CartTotal(subtotal=Decimal('7500'), shipping_fee=Decimal('0'), discount=Decimal('0'), total=Decimal('7500'))
# 通常会員・キャンペーン割引適用
result = calculate_cart_total(Decimal("5000"), 3, is_premium_member=False)
print(result)
# CartTotal(subtotal=Decimal('15000'), shipping_fee=Decimal('0'), discount=Decimal('1500'), total=Decimal('13500'))
# プレミアム会員・少額でも送料無料
result = calculate_cart_total(Decimal("500"), 2, is_premium_member=True)
print(result)
# CartTotal(subtotal=Decimal('1000'), shipping_fee=Decimal('0'), discount=Decimal('0'), total=Decimal('1000'))
# 異常系: 数量0
calculate_cart_total(Decimal("1000"), 0, is_premium_member=False)
# ValueError: unit_price と quantity は正の値である必要があります
| ポイント | 適用した設計原則 | 書籍対応章 |
|---|---|---|
# 小計, # 送料を計算する, # 合計を返す の削除 | 退化コメントの排除 | Ch12 |
# 送料880円を加算(実装990と不一致)の削除 | コメントと実装の同期 | Ch12 |
# 以下は旧ロジック(...) の死んだコードの削除 | 死んだコードの排除(Git履歴で代替) | Ch12 |
| docstring に「なぜ5000円/10000円/10%なのか」を明記 | 目的(Why)の文書化 | Ch12 |
"""カート合計を計算する""" → Args/Returns/Raises付き docstring | docstring整備(Googleスタイル) | Ch12 |
# TODO: ... → # TODO(kainuma): ... (Issue, 対応予定) | TODOコメントの書式統一 | Ch12 |
5000/990/10000/0.9 → 名前付き定数 | マジックナンバーの排除 | Ch10 |
CartTotal を dataclass(frozen=True, slots=True) 化 | 値オブジェクト・不変の活用 | Ch3/Ch4 |
# tests/test_cart_pricing.py
from decimal import Decimal
import pytest
from cart_pricing import calculate_cart_total
class TestCalculateCartTotal:
def test_standard_shipping_fee_applies_below_threshold(self):
result = calculate_cart_total(Decimal("1200"), 2, is_premium_member=False)
assert result.shipping_fee == Decimal("990")
assert result.total == Decimal("3390")
def test_free_shipping_at_threshold(self):
result = calculate_cart_total(Decimal("2500"), 2, is_premium_member=False)
assert result.subtotal == Decimal("5000")
assert result.shipping_fee == Decimal("0")
def test_premium_member_always_free_shipping(self):
result = calculate_cart_total(Decimal("500"), 1, is_premium_member=True)
assert result.shipping_fee == Decimal("0")
def test_campaign_discount_applies_at_threshold(self):
result = calculate_cart_total(Decimal("5000"), 3, is_premium_member=False)
assert result.subtotal == Decimal("15000")
assert result.discount == Decimal("1500")
assert result.total == Decimal("13500")
def test_no_discount_below_campaign_threshold(self):
result = calculate_cart_total(Decimal("3000"), 3, is_premium_member=False)
assert result.discount == Decimal("0")
def test_raises_value_error_for_zero_quantity(self):
with pytest.raises(ValueError):
calculate_cart_total(Decimal("1000"), 0, is_premium_member=False)
def test_raises_value_error_for_non_positive_price(self):
with pytest.raises(ValueError):
calculate_cart_total(Decimal("-100"), 1, is_premium_member=False)
ポイント解説
# 単価に数量をかけて小計を出す の直後に subtotal = price * quantity があるように、コードを読めば1秒で分かることをもう一度日本語で言い直すコメントは、読み手にとって新しい情報を何も追加しない。それどころか、コードだけを修正してコメントを直し忘れるリスクを常に抱える。実際にこのコード内の # 送料880円を加算 は、送料が 990 に変わった後もコメントだけが取り残され、今や「読むと誤解する」有害なコメントになっている。コメントを書く前に「このコメントを消してもコードから同じ情報が読み取れるか」を自問するとよい。STANDARD_SHIPPING_FEE_YEN = Decimal("990"))にすれば、値そのものが「今の正しい値」であり続け、コメントとのズレが原理的に起きなくなる。# points = int(subtotal * 0.01) は、「使われていない」のか「一時的に無効化しているだけ」なのか読み手には区別できず、判断コストだけを生む。バージョン管理システムに履歴が残っている以上、コードとして復元したければ git log を辿ればよく、ソースコード上に残しておく理由はない。# TODO: 消費税の軽減税率対応 は、誰がいつ対応するのか分からないまま放置されやすく、実際に多くのコードベースで「何年も残り続けるTODO」の典型パターンになる。担当者・チケット番号・対応予定時期をセットで書くことで、TODOが「単なる願望」ではなく「追跡可能なタスク」として扱われるようになる。実務への応用
MOps チームのようにキャンペーン施策の閾値・割引率が頻繁に変わるドメインでは、「なぜその値なのか」というWhyの記録が失われると、次に閾値を変更する担当者(自分自身かもしれない)が過去の販促企画の意図を推測するところから始めることになる。docstring にチケット番号や施策名を残しておけば、Confluence やチケット管理システムを検索せずとも、コードの中に「この定数がなぜ存在するか」の手がかりが残る。
今日のまとめ
次のステップ
- 発展問題:
CAMPAIGN_DISCOUNT_RATEのようなキャンペーン割引率が「常時開催の1種類」から「複数キャンペーンの重ね掛け可否ルールを持つ複数種類」に増えた場合を想定し、Why型コメント・docstring の書き方をどう発展させるべきか(例:CampaignPolicyのような値オブジェクトに施策名・根拠チケットをフィールドとして持たせ、コメントではなくデータとして表現する設計)を検討してください。 - 参考: 「良いコード・悪いコードで学ぶ設計入門(改訂新版)」Ch12(コメント)/ Ch10(設計の悪魔・マジックナンバー)/ Google Python Style Guide(Comments and Docstrings)