Java | オブジェクト指向:コレクションをフィールドに持つ設計

Java Java
スポンサーリンク

「コレクションをフィールドに持つ」とは何を意味するか

コレクションをフィールドに持つ設計というのは、
クラスの中に ListSetMap などを「メンバー」として抱える設計のことです。

例えば、注文クラスが「複数の明細」を持つ。

final class Order {
    private final List<OrderLine> lines;
}
Java

ユーザクラスが「複数の住所」や「複数のロール(権限)」を持つ。

final class User {
    private final Set<Role> roles;
}
Java

こういう、「1つのオブジェクトが、他のオブジェクトの“集まり”を持つ」という状態が、
「コレクションをフィールドに持つ設計」です。

オブジェクト指向ではとてもよく出てくるパターンですが、
扱い方を間違えると「不正な状態になりやすい」「外から直接いじられて壊れやすい」危険な部分にもなります。


典型例:注文と注文行(Order と OrderLine)

素朴な実装例

まず、よくある「注文と明細」の例から見てみます。

final class OrderLine {

    private final String itemName;
    private final int unitPrice;
    private final int quantity;

    OrderLine(String itemName, int unitPrice, int quantity) {
        this.itemName = itemName;
        this.unitPrice = unitPrice;
        this.quantity = quantity;
    }

    int lineTotal() {
        return unitPrice * quantity;
    }
}
Java
final class Order {

    private final List<OrderLine> lines;

    Order(List<OrderLine> lines) {
        this.lines = lines;
    }

    int totalPrice() {
        int sum = 0;
        for (OrderLine line : lines) {
            sum += line.lineTotal();
        }
        return sum;
    }

    List<OrderLine> lines() {
        return lines;
    }
}
Java

一見シンプルですが、このまま使うと危険なポイントがあります。

Order のコンストラクタが「空のリスト」や null を許してしまっているかもしれないこと。
lines() で内部のリストをそのまま返しているので、外から好き放題いじれてしまうこと。

ここが、「コレクションをフィールドに持つ設計」で特に気をつけるべき部分です。


不変条件をどう守るか(ここが一番重要)

「空の注文は存在していいのか?」をコードに写す

例えば業務上、「明細が 1 件もない注文はありえない」と決まっているなら、
それをコード(コンストラクタ)で表現すべきです。

final class Order {

    private final List<OrderLine> lines;

    Order(List<OrderLine> lines) {
        if (lines == null || lines.isEmpty()) {
            throw new IllegalArgumentException("明細が1件もない注文は無効です");
        }
        this.lines = new ArrayList<>(lines);
    }

    int totalPrice() {
        int sum = 0;
        for (OrderLine line : lines) {
            sum += line.lineTotal();
        }
        return sum;
    }
}
Java

このようにコンストラクタでチェックしておけば、
「明細 0 件の Order インスタンス」がシステム内に入り込むことはありません。

コレクションをフィールドに持つときは必ず
「空・null・重複・サイズ上限などのルール」をはっきりさせて、
コンストラクタや専用の追加メソッドで守るのが大事です。

内部のコレクションをそのまま外に出さない

さきほどの例では lines() がそのまま lines を返していました。
これはかなり危険です。

Order order = ...;
order.lines().clear();           // 外から全部消せてしまう
order.lines().add(...);          // 好き放題追加できてしまう
Java

こうなると、せっかくコンストラクタで不変条件を守っても、
後から外部コードが好き勝手に壊せてしまいます。

これを避けるために、よくやるのは「防御的コピー」か「読み取り専用ビュー」です。

final class Order {

    private final List<OrderLine> lines;

    Order(List<OrderLine> lines) {
        if (lines == null || lines.isEmpty()) {
            throw new IllegalArgumentException("明細が1件もない注文は無効です");
        }
        this.lines = new ArrayList<>(lines);          // 外から渡されたリストをコピー
    }

    List<OrderLine> lines() {
        return List.copyOf(lines);                    // 変更不可のビューを返す
    }
}
Java

これで、呼び出し側からは Order の内部リストを書き換えられなくなります。
コレクションをフィールドに持つとき、「外から直接書き換えさせない」ことはかなり重要です。


追加や削除をどう設計するか

「単なる List のラッパ」ではなく「意味のある操作」にする

明細を追加したいとき、
単に getLines().add(...) のようなスタイルを許してしまうと、
不変条件を守るのが難しくなります。

代わりに、Order に「自分にとって意味のある操作」としてメソッドを用意します。

final class Order {

    private final List<OrderLine> lines = new ArrayList<>();

    Order(List<OrderLine> initialLines) {
        if (initialLines == null || initialLines.isEmpty()) {
            throw new IllegalArgumentException("明細が1件もない注文は無効");
        }
        this.lines.addAll(initialLines);
    }

    void addLine(OrderLine line) {
        if (line == null) {
            throw new IllegalArgumentException("明細は null 禁止");
        }
        this.lines.add(line);
    }

    List<OrderLine> lines() {
        return List.copyOf(lines);
    }

    int totalPrice() {
        int sum = 0;
        for (OrderLine line : lines) {
            sum += line.lineTotal();
        }
        return sum;
    }
}
Java

このように、

「そのコレクションに対する操作」をクラスのメソッドとして提供し
その中で不変条件を守るチェックを書く

という形にしておくと、コレクションを安全に扱えます。


可変コレクションか、不変コレクションか

できるだけ「中身を変えない」設計に寄せる

コレクションは基本的に可変(あとから追加・削除できる)ですが、
設計としては「できるだけ不変(immutable)に近づける」と事故が減ります。

完全に不変にする場合は、変更操作を許さない設計にします。

final class Order {

    private final List<OrderLine> lines;

    Order(List<OrderLine> lines) {
        if (lines == null || lines.isEmpty()) {
            throw new IllegalArgumentException("明細が1件もない注文は無効");
        }
        this.lines = List.copyOf(lines);   // 不変リストとして保持
    }

    List<OrderLine> lines() {
        return lines;                     // そのまま返しても中身は変更不可
    }
}
Java

Order を「作ったら中身は変えない」前提にできるなら、このスタイルはとても強力です。
「いつの間にか別の明細に変えられていた」といったバグを防げます。

業務上どうしても明細の追加・削除が必要なら、
先ほどのように専用メソッド経由でのみ変更できるようにします。


Set や Map をフィールドに持つときの注意点

Set:重複禁止の意味をドメインに合わせて考える

Set<Role> のように Set をフィールドに持つ場合、
「同じものが重複しない」ことが前提になります。

final class User {

    private final Set<Role> roles = new HashSet<>();

    void addRole(Role role) {
        roles.add(role);        // 同じ Role は二重に追加されない
    }

    Set<Role> roles() {
        return Set.copyOf(roles);
    }
}
Java

ここで効いてくるのが、Role の equals / hashCode の定義です。
どのフィールドを基準に「同じロール」とみなすかを
Role 側でしっかり決めておかないと、意図しない重複や別物扱いが発生します。

Set をフィールドに持つときは、「重複禁止」の意味もドメイン側で意識しておくのが大事です。

Map:キーと値の関係を「意味のあるモデル」にできないか考える

Map<String, Integer> のような Map をそのままフィールドに持つこともできますが、
キーや値に意味があるなら、専用の小さなクラスを作ったほうが分かりやすくなることが多いです。

例えば「商品ID → 在庫数」のような Map を、そのまま持つのではなく、

final class Inventory {

    private final Map<ItemId, StockQuantity> map;

    // 取得・追加・減算などのメソッドを提供
}
Java

というふうに、「在庫」という概念ごとクラスにして、
その中で Map をカプセル化するイメージです。

コレクションを直接フィールドに持つのではなく、
一段ラップすることで、ドメインの言葉で扱いやすくなります。


まとめ:コレクションをフィールドに持つときの考え方

コレクションをフィールドに持つ設計は、
「一対多」「多対多」の関係を表現するうえでとても強力です。

ただし、そのぶん注意点も増えます。

コレクションに対する不変条件(空OKか、上限は、重複は)を決めてコンストラクタで守る
内部のコレクションをそのまま外に出さず、防御的コピーや読み取り専用ビューにする
追加・削除は「意味のあるメソッド」を通して行い、不変条件を守る
必要であれば、不変コレクションとして持ち、外からの変更を一切許さない
Set / Map を使うときは、equals / hashCode やキー・値の意味をしっかり設計する

この辺りを意識しておくと、「ただの List フィールド」から
「ドメインをちゃんと表現するコレクション」に一段レベルアップできます。

タイトルとURLをコピーしました