クラス設計
3-2

値オブジェクト+不変で堅牢にする

金額・数量・税率のような「値」を素の int や float のまま引き回すと、途中で上書きされて今の値が何なのかわからなくなったり、意図の違う数値を取り違えて渡したりする事故が起きる。値を専用のクラス(値オブジェクト)にして不変で持てば、変更は新しいインスタンスの生成として表現され、型の違いが取り違えを防いでくれる。

01 精算額を上書きせず、新しいインスタンスで返す

Bad

可変な精算額オブジェクトを関数に渡すと、渡した先で中身を書き換えられる。処理が進むにつれて「今いくらなのか」を呼び出し履歴ごと追わないとわからなくなる

引数で受け取ったインスタンスを直接書き換える
@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 になっている。
# 他にも書き換える関数があれば、現在値の特定はさらに困難になる
Good

frozen な値オブジェクトにして、加算は「合計値を持った新しいインスタンスを返す」操作にする。元の値は誰にも書き換えられない

不変にして、変更は新インスタンスで表現する
@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 型の違いで「値の渡し間違い」を防ぐ

Bad

精算額も移動距離も同じ int なので、引数の順序を取り違えてもエラーにならない。大量のデータを扱う処理では人間の注意力だけでは防ぎきれない

同じ int どうしで取り違えが起きる
def register_travel_expense(amount: int, distance_km: int) -> None:
    ...

# 引数を逆に渡してしまった。型はどちらも int なので素通りする
register_travel_expense(15, 3_200)
# 「金額15円・移動距離3,200km」の交通費が登録される
Good

金額と距離をそれぞれ値オブジェクトにする。取り違えると型が合わないので、実行する前に型チェッカーが検出してくれる

専用の型をつくって取り違えを構造的に防ぐ
@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 が「型が合わない」と実行前に教えてくれる

値オブジェクトをつくるときは、業務に存在する操作だけをメソッドにすること。「数値だから一応掛け算も用意しておこう」のような、業務上ありえない操作を善意で追加すると、うっかり使われたときにバグになる。精算額どうしの乗算が必要になる経理業務は存在しない。

参考: 『良いコード/悪いコードで学ぶ設計入門』(ミノ駆動 著、技術評論社)第3章。コード例は原則を自分の題材で表現し直したオリジナル。
3-2

値オブジェクト+不変で堅牢にする

金額・数量・税率のような「値」を素の int や float のまま引き回すと、途中で上書きされて今の値が何なのかわからなくなったり、意図の違う数値を取り違えて渡したりする事故が起きる。値を専用のクラス(値オブジェクト)にして不変で持てば、変更は新しいインスタンスの生成として表現され、型の違いが取り違えを防いでくれる。

01 精算額を上書きせず、新しいインスタンスで返す

Bad

可変な精算額オブジェクトを関数に渡すと、渡した先で中身を書き換えられる。処理が進むにつれて「今いくらなのか」を呼び出し履歴ごと追わないとわからなくなる

引数で受け取ったインスタンスを直接書き換える
@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 になっている。
# 他にも書き換える関数があれば、現在値の特定はさらに困難になる
Good

frozen な値オブジェクトにして、加算は「合計値を持った新しいインスタンスを返す」操作にする。元の値は誰にも書き換えられない

不変にして、変更は新インスタンスで表現する
@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 型の違いで「値の渡し間違い」を防ぐ

Bad

精算額も移動距離も同じ int なので、引数の順序を取り違えてもエラーにならない。大量のデータを扱う処理では人間の注意力だけでは防ぎきれない

同じ int どうしで取り違えが起きる
def register_travel_expense(amount: int, distance_km: int) -> None:
    ...

# 引数を逆に渡してしまった。型はどちらも int なので素通りする
register_travel_expense(15, 3_200)
# 「金額15円・移動距離3,200km」の交通費が登録される
Good

金額と距離をそれぞれ値オブジェクトにする。取り違えると型が合わないので、実行する前に型チェッカーが検出してくれる

専用の型をつくって取り違えを構造的に防ぐ
@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 が「型が合わない」と実行前に教えてくれる

値オブジェクトをつくるときは、業務に存在する操作だけをメソッドにすること。「数値だから一応掛け算も用意しておこう」のような、業務上ありえない操作を善意で追加すると、うっかり使われたときにバグになる。精算額どうしの乗算が必要になる経理業務は存在しない。

参考: 『良いコード/悪いコードで学ぶ設計入門』(ミノ駆動 著、技術評論社)第3章。コード例は原則を自分の題材で表現し直したオリジナル。