値オブジェクト+不変で堅牢にする
金額・数量・税率のような「値」を素の int や float のまま引き回すと、途中で上書きされて今の値が何なのかわからなくなったり、意図の違う数値を取り違えて渡したりする事故が起きる。値を専用のクラス(値オブジェクト)にして不変で持てば、変更は新しいインスタンスの生成として表現され、型の違いが取り違えを防いでくれる。
01 精算額を上書きせず、新しいインスタンスで返す
@dataclass
class SettlementAmount:
value: int
def apply_commute_allowance(total: SettlementAmount, days: int) -> None:
total.value += 500 * days # 呼び出し元のオブジェクトを直接変更
monthly = SettlementAmount(82_000)
apply_commute_allowance(monthly, 20)
# monthly は知らないうちに 92,000 になっている。
# 他にも書き換える関数があれば、現在値の特定はさらに困難になる@dataclass(frozen=True)
class SettlementAmount:
value: int
def __post_init__(self) -> None:
if self.value < 0:
raise ValueError("精算額には0以上を指定してください")
def add(self, other: "SettlementAmount") -> "SettlementAmount":
return SettlementAmount(self.value + other.value)
monthly = SettlementAmount(82_000)
commute = SettlementAmount(500 * 20)
total = monthly.add(commute) # monthly は 82,000 のまま変わらないJava の final フィールドに相当するのが Python の `@dataclass(frozen=True)`。代入しようとすると実行時に FrozenInstanceError になる。「値が変わらない」前提が成立すると、どの時点の値かを気にせずコードを読めるようになる。
02 型の違いで「値の渡し間違い」を防ぐ
def register_travel_expense(amount: int, distance_km: int) -> None:
...
# 引数を逆に渡してしまった。型はどちらも int なので素通りする
register_travel_expense(15, 3_200)
# 「金額15円・移動距離3,200km」の交通費が登録される@dataclass(frozen=True)
class ExpenseAmount:
value: int
@dataclass(frozen=True)
class DistanceKm:
value: int
def register_travel_expense(amount: ExpenseAmount, distance: DistanceKm) -> None:
...
register_travel_expense(DistanceKm(15), ExpenseAmount(3_200))
# mypy / pyright が「型が合わない」と実行前に教えてくれる値オブジェクトをつくるときは、業務に存在する操作だけをメソッドにすること。「数値だから一応掛け算も用意しておこう」のような、業務上ありえない操作を善意で追加すると、うっかり使われたときにバグになる。精算額どうしの乗算が必要になる経理業務は存在しない。