コレクション
7-3

ファーストクラスコレクション

生の list をあちこちのクラスへ引数で渡して回すと、件数上限や重複禁止といったルールの検査が複数箇所に重複し、検査を通らずに append できる抜け道も生まれる。コレクションを専用クラスで包み、操作ロジックを同じ場所に集約したものがファーストクラスコレクション。不変に設計すれば堅牢さも手に入る。

01 月次仕訳の明細管理

Bad

生の list[JournalLine] が複数のサービスを渡り歩き、貸借一致チェックや上限ルールがコレクションの外に置かれている

コレクションとルールが別々の場所に
MAX_LINES = 200

class JournalDraftService:
    def add_line(self, lines: list[JournalLine], new: JournalLine) -> None:
        if len(lines) >= MAX_LINES:
            raise ValueError("仕訳明細数が上限です")
        lines.append(new)

    def assert_balanced(self, lines: list[JournalLine]) -> None:
        debit = sum(line.debit_amount for line in lines)
        credit = sum(line.credit_amount for line in lines)
        if debit != credit:
            raise ValueError("貸借が一致していません")

class LedgerPostService:
    def total_debit(self, lines: list[JournalLine]) -> int:
        # 別のサービスにも同じ list が渡され、直接 append できてしまう
        return sum(line.debit_amount for line in lines)
Good

コレクションを JournalLines クラスに閉じ込め、追加・判定・集計のロジックを同居させる。追加は検査済みの新インスタンスを返す

ファーストクラスコレクション(不変)
@dataclass(frozen=True)
class JournalLines:
    MAX_LINES: ClassVar[int] = 200
    lines: tuple[JournalLine, ...] = ()

    def add(self, new: JournalLine) -> "JournalLines":
        if self.is_full:
            raise ValueError("仕訳明細数が上限です")
        return JournalLines(self.lines + (new,))

    @property
    def is_full(self) -> bool:
        return len(self.lines) == JournalLines.MAX_LINES

    @property
    def total_debit(self) -> int:
        return sum(line.debit_amount for line in self.lines)

    @property
    def total_credit(self) -> int:
        return sum(line.credit_amount for line in self.lines)

    @property
    def is_balanced(self) -> bool:
        return self.total_debit == self.total_credit

add() が新しい JournalLines を返す設計は第4章「変更は新しいインスタンスで返す」の応用。ルールを破った仕訳明細リストはそもそも作れなくなり、検査ロジックの重複も消える。

02 内部コレクションを外に漏らさない

Bad

内部の list をそのまま返すと、呼び出し側が add() の検査を素通りして直接書き換えられる

内部リストの漏出
class JournalLines:
    def __init__(self) -> None:
        self._lines: list[JournalLine] = []

    @property
    def lines(self) -> list[JournalLine]:
        return self._lines  # 内部リストへの参照がそのまま漏れる

# 呼び出し側: 上限も貸借一致も検査されない追加経路ができてしまう
journal_lines.lines.append(unchecked_line)
Good

外へ渡すときは tuple に変換して返す。読み取りは自由だが、変更は必ず add() を通るしかなくなる

変更不能なビューで返す
class JournalLines:
    def __init__(self) -> None:
        self._lines: list[JournalLine] = []

    @property
    def lines(self) -> tuple[JournalLine, ...]:
        return tuple(self._lines)  # 変更できないコピーを渡す

Java の unmodifiableList に相当する発想。Python なら tuple(dict なら types.MappingProxyType)で変更不能なビューを返す。最初から内部を tuple で持つ前セットの設計なら、この心配自体がなくなる。

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

ファーストクラスコレクション

生の list をあちこちのクラスへ引数で渡して回すと、件数上限や重複禁止といったルールの検査が複数箇所に重複し、検査を通らずに append できる抜け道も生まれる。コレクションを専用クラスで包み、操作ロジックを同じ場所に集約したものがファーストクラスコレクション。不変に設計すれば堅牢さも手に入る。

01 月次仕訳の明細管理

Bad

生の list[JournalLine] が複数のサービスを渡り歩き、貸借一致チェックや上限ルールがコレクションの外に置かれている

コレクションとルールが別々の場所に
MAX_LINES = 200

class JournalDraftService:
    def add_line(self, lines: list[JournalLine], new: JournalLine) -> None:
        if len(lines) >= MAX_LINES:
            raise ValueError("仕訳明細数が上限です")
        lines.append(new)

    def assert_balanced(self, lines: list[JournalLine]) -> None:
        debit = sum(line.debit_amount for line in lines)
        credit = sum(line.credit_amount for line in lines)
        if debit != credit:
            raise ValueError("貸借が一致していません")

class LedgerPostService:
    def total_debit(self, lines: list[JournalLine]) -> int:
        # 別のサービスにも同じ list が渡され、直接 append できてしまう
        return sum(line.debit_amount for line in lines)
Good

コレクションを JournalLines クラスに閉じ込め、追加・判定・集計のロジックを同居させる。追加は検査済みの新インスタンスを返す

ファーストクラスコレクション(不変)
@dataclass(frozen=True)
class JournalLines:
    MAX_LINES: ClassVar[int] = 200
    lines: tuple[JournalLine, ...] = ()

    def add(self, new: JournalLine) -> "JournalLines":
        if self.is_full:
            raise ValueError("仕訳明細数が上限です")
        return JournalLines(self.lines + (new,))

    @property
    def is_full(self) -> bool:
        return len(self.lines) == JournalLines.MAX_LINES

    @property
    def total_debit(self) -> int:
        return sum(line.debit_amount for line in self.lines)

    @property
    def total_credit(self) -> int:
        return sum(line.credit_amount for line in self.lines)

    @property
    def is_balanced(self) -> bool:
        return self.total_debit == self.total_credit

add() が新しい JournalLines を返す設計は第4章「変更は新しいインスタンスで返す」の応用。ルールを破った仕訳明細リストはそもそも作れなくなり、検査ロジックの重複も消える。

02 内部コレクションを外に漏らさない

Bad

内部の list をそのまま返すと、呼び出し側が add() の検査を素通りして直接書き換えられる

内部リストの漏出
class JournalLines:
    def __init__(self) -> None:
        self._lines: list[JournalLine] = []

    @property
    def lines(self) -> list[JournalLine]:
        return self._lines  # 内部リストへの参照がそのまま漏れる

# 呼び出し側: 上限も貸借一致も検査されない追加経路ができてしまう
journal_lines.lines.append(unchecked_line)
Good

外へ渡すときは tuple に変換して返す。読み取りは自由だが、変更は必ず add() を通るしかなくなる

変更不能なビューで返す
class JournalLines:
    def __init__(self) -> None:
        self._lines: list[JournalLine] = []

    @property
    def lines(self) -> tuple[JournalLine, ...]:
        return tuple(self._lines)  # 変更できないコピーを渡す

Java の unmodifiableList に相当する発想。Python なら tuple(dict なら types.MappingProxyType)で変更不能なビューを返す。最初から内部を tuple で持つ前セットの設計なら、この心配自体がなくなる。

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