【Java】Javaの不変オブジェクト設計:recordとfinalで安全なクラスを作る

Java

こんにちは、かつコーチです。
今回は不変オブジェクト(イミュータブルオブジェクト)の設計を扱います。
finalrecordequals/hashCodeの実装は理解している前提で進めます。

不変オブジェクトとは何か、なぜ必要か

不変オブジェクトの定義

不変オブジェクトとは、生成後に内部状態が一切変更できないオブジェクトのことです。
JavaのStringはその代表例で、str.concat("abc")は新しいStringインスタンスを返すだけで、元のstrは変わりません。

可変オブジェクトが引き起こす事故

筆者が実際に遭遇したバグに、Listを戻り値としてそのまま返してしまい、呼び出し側で意図せず内容を書き換えられてしまったケースがあります。

// ❌Before:内部の可変な状態をそのまま公開してしまう
class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    void addItem(String item) {
        items.add(item);
    }

    List<String> getItems() {
        return items; // 内部のリストへの参照をそのまま返している
    }
}
ShoppingCart cart = new ShoppingCart();
cart.addItem("りんご");

List<String> items = cart.getItems();
items.clear(); // カート内部のリストまで空になってしまう

getItems()が返したリストを呼び出し側でclear()すると、ShoppingCart内部のリストまで空になります。
呼び出し側は「参照を受け取っただけ」のつもりでも、実際には内部状態を直接操作できてしまうのが原因です。

// ✅After:防御的コピーで内部状態を保護する
class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    void addItem(String item) {
        items.add(item);
    }

    List<String> getItems() {
        return List.copyOf(items); // 変更不可なコピーを返す
    }
}

List.copyOf()(Java 10以降)は変更不可なリストのコピーを生成するため、呼び出し側がaddclearを呼ぶとUnsupportedOperationExceptionが発生し、事故を早期に検知できます。

不変クラスを設計する4つのルール

不変クラスの条件

不変クラスとして安全と言えるためには、以下の4条件を満たす必要があります。

  1. すべてのフィールドをfinalにする
  2. クラス自体をfinalにするか、サブクラスでの変更を防ぐ設計にする
  3. コンストラクタで受け取った可変オブジェクトは防御的コピーを取る
  4. ゲッターで内部の可変フィールドをそのまま返さない
// ❌Before:一見finalフィールドだが、可変オブジェクトを外部に渡している
final class Event {
    private final String name;
    private final Date date; // Dateは可変オブジェクト

    Event(String name, Date date) {
        this.name = name;
        this.date = date; // コンストラクタ引数の参照をそのまま保持
    }

    Date getDate() {
        return date; // 内部のDateをそのまま返す
    }
}
Date original = new Date();
Event event = new Event("リリース会", original);

original.setTime(0); // Eventの外からEvent内部の日付を書き換えられてしまう
event.getDate().setTime(0); // ゲッター経由でも書き換えられる

dateフィールドはfinalですが、Date自体が可変オブジェクトのため、参照を共有している限り外部から中身を書き換えられます。

// ✅After:不変な型(LocalDateTime)を使い、防御的コピーの必要をなくす
final class Event {
    private final String name;
    private final LocalDateTime date;

    Event(String name, LocalDateTime date) {
        this.name = name;
        this.date = date;
    }

    LocalDateTime getDate() {
        return date;
    }
}

Java 8以降のjava.timeパッケージ(LocalDateTimeLocalDate等)はそもそも不変オブジェクトとして設計されているため、これらを使う限り防御的コピーを意識する必要がありません。
古いjava.util.DateCalendarを避け、java.time系のクラスを使うことも不変オブジェクト設計の一部です。

recordで不変オブジェクトを簡潔に作る

recordが解決する冗長さ

Java 16で正式導入されたrecord(Java21では標準機能)は、不変なデータの入れ物を簡潔に定義できる機能です。

// ❌Before:不変クラスを手動で書くと冗長
final class Point {
    private final int x;
    private final int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    int getX() { return x; }
    int getY() { return y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}
// ✅After:recordなら1行で同等の不変クラスができる
record Point(int x, int y) {}

recordequalshashCodetoString・各フィールドのアクセサ(x()y())を自動生成し、フィールドは暗黙的にfinalになります。
不変オブジェクトを作りたいだけなら、手書きのクラスよりrecordを第一候補にするべきです。

recordでも防御的コピーは必要になる場合がある

recordはフィールドをfinalにしてくれますが、フィールドの型自体が可変オブジェクトの場合は注意が必要です。

// ❌Before:recordでもList型フィールドは可変な参照を共有してしまう
record Team(String name, List<String> members) {}
List<String> memberList = new ArrayList<>(List.of("田中", "佐藤"));
Team team = new Team("開発チーム", memberList);

memberList.add("鈴木"); // Teamの外から内部のリストが変更されてしまう
// ✅After:コンパクトコンストラクタで防御的コピーを行う
record Team(String name, List<String> members) {
    Team {
        members = List.copyOf(members); // コンパクトコンストラクタで不変化
    }
}

recordコンパクトコンストラクタ(フィールドリストの再宣言を省略した専用コンストラクタ構文)を使うことで、受け取ったListを不変なコピーに変換し、フィールド代入前に検証・加工を挟めます。

不変オブジェクトを使うメリットと注意点

スレッドセーフティが自動的に手に入る

不変オブジェクトは状態が変化しないため、複数スレッドから同時にアクセスされても、同期処理(synchronized)なしで安全に共有できます。
並行処理を扱う設計では、可変な状態をできる限り減らし、不変オブジェクトに置き換えることが定石とされています。

注意点:大量オブジェクト生成によるメモリ負荷

状態を変更するたびに新しいインスタンスを生成するため、頻繁に更新が発生する処理では不向きな場合があります。
文字列連結でStringBuilderが推奨されるのと同じ理由で、「更新頻度が高い処理」には可変なビルダー型を併用するのが実務的な判断です。

まとめ

この記事のポイント

  • 不変オブジェクトは生成後に状態変更できず、内部の可変な参照をそのまま外部に渡さない設計が必須
  • java.time系のような不変な型を使うことで防御的コピーの手間を減らせる
  • recordは不変クラスを簡潔に定義でき、可変フィールドはコンパクトコンストラクタで防御的コピーする
  • 不変オブジェクトはスレッドセーフだが、更新頻度が高い処理には向かない場合がある

次に読むべき記事

  • 代表的デザインパターン(Singleton・Factory・Strategy)の記事へ
  • record(Java21)でシンプルにデータを表現するの記事へ

タグ: Java, 上級者向け, 設計

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