コレクション
7-3

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

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

01 投資ポートフォリオの銘柄管理

Bad

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

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

class TradeService:
    def add_holding(self, holdings: list[Holding], new: Holding) -> None:
        if any(h.ticker == new.ticker for h in holdings):
            raise ValueError("既に保有している銘柄です")
        if len(holdings) >= MAX_HOLDINGS:
            raise ValueError("保有銘柄数が上限です")
        holdings.append(new)

class ReportService:
    def total_value(self, holdings: list[Holding]) -> int:
        # 別のサービスにも同じ list が渡され、直接 append できてしまう
        return sum(h.value for h in holdings)
Good

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

ファーストクラスコレクション(不変)
@dataclass(frozen=True)
class Portfolio:
    MAX_HOLDINGS: ClassVar[int] = 10
    holdings: tuple[Holding, ...] = ()

    def add(self, new: Holding) -> "Portfolio":
        if self.contains(new.ticker):
            raise ValueError("既に保有している銘柄です")
        if self.is_full:
            raise ValueError("保有銘柄数が上限です")
        return Portfolio(self.holdings + (new,))

    def contains(self, ticker: str) -> bool:
        return any(h.ticker == ticker for h in self.holdings)

    @property
    def is_full(self) -> bool:
        return len(self.holdings) == Portfolio.MAX_HOLDINGS

    @property
    def total_value(self) -> int:
        return sum(h.value for h in self.holdings)

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

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

Bad

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

内部リストの漏出
class Portfolio:
    def __init__(self) -> None:
        self._holdings: list[Holding] = []

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

# 呼び出し側: 上限も重複も検査されない追加経路ができてしまう
portfolio.holdings.append(unchecked_holding)
Good

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

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

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

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

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

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

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

01 投資ポートフォリオの銘柄管理

Bad

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

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

class TradeService:
    def add_holding(self, holdings: list[Holding], new: Holding) -> None:
        if any(h.ticker == new.ticker for h in holdings):
            raise ValueError("既に保有している銘柄です")
        if len(holdings) >= MAX_HOLDINGS:
            raise ValueError("保有銘柄数が上限です")
        holdings.append(new)

class ReportService:
    def total_value(self, holdings: list[Holding]) -> int:
        # 別のサービスにも同じ list が渡され、直接 append できてしまう
        return sum(h.value for h in holdings)
Good

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

ファーストクラスコレクション(不変)
@dataclass(frozen=True)
class Portfolio:
    MAX_HOLDINGS: ClassVar[int] = 10
    holdings: tuple[Holding, ...] = ()

    def add(self, new: Holding) -> "Portfolio":
        if self.contains(new.ticker):
            raise ValueError("既に保有している銘柄です")
        if self.is_full:
            raise ValueError("保有銘柄数が上限です")
        return Portfolio(self.holdings + (new,))

    def contains(self, ticker: str) -> bool:
        return any(h.ticker == ticker for h in self.holdings)

    @property
    def is_full(self) -> bool:
        return len(self.holdings) == Portfolio.MAX_HOLDINGS

    @property
    def total_value(self) -> int:
        return sum(h.value for h in self.holdings)

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

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

Bad

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

内部リストの漏出
class Portfolio:
    def __init__(self) -> None:
        self._holdings: list[Holding] = []

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

# 呼び出し側: 上限も重複も検査されない追加経路ができてしまう
portfolio.holdings.append(unchecked_holding)
Good

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

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

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

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

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