Java | オブジェクト指向:不変オブジェクトの考え方

Java Java
スポンサーリンク

不変オブジェクトとは何か

不変オブジェクトは「生成後に状態が一切変わらない」オブジェクトです。フィールドは初期化時に確定され、その後は再代入されません。外から見ても中から見ても、値が変化しないので、並行実行・共有・テストで極めて扱いやすく、安全性が高いのが最大の強みです。


なぜ不変が強いのか(重要ポイントの深掘り)

予測可能性と安全な共有

一度作った値が変わらないため、「いつ、誰が、どこで」触っても結果が同じです。複数スレッドから同じインスタンスを参照しても、競合しません。キャッシュやメモ化、再利用に非常に向きます。

バグの発生源を減らす

状態が変わると「変更の順序」「途中状態」「整合性崩壊」が問題になります。不変にすると「途中状態」が存在しないため、バグの種類が大幅に減ります。テストも「作って比べる」だけで済む場面が増えます。

APIの単純化と意図の明確化

変更がないので、公開メソッドは「問い合わせ(getter)と変換(新インスタンスを返す)」に集中します。副作用がなくなり、使い方(契約)が明確になり、学習コストが下がります。


Java における不変オブジェクトの作り方

基本の作法(final とカプセル化)

  • フィールドをすべて private final にする: コンストラクタで一度だけ代入し、その後は変更不可。
  • セッターを作らない: 書き換え用の公開メソッドを置かず、変更は新インスタンスを返す「with スタイル」にする。
  • コレクションは防御的コピー: 受け取った可変データはコピーし、外へ返す際も不変ビューで包む。
public final class Money {
    private final int amount;
    private final String currency;

    public Money(int amount, String currency) {
        if (amount < 0) throw new IllegalArgumentException("amount>=0");
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("currency");
        this.amount = amount;
        this.currency = currency.trim();
    }

    public int amount() { return amount; }
    public String currency() { return currency; }

    // 変更は新しいインスタンスを返す
    public Money addTax(double rate) {
        int taxed = (int) Math.round(amount * (1 + rate));
        return new Money(taxed, currency);
    }
}
Java

防御的コピー(可変入力・出力の扱い)

public final class Tags {
    private final java.util.List<String> list;

    public Tags(java.util.List<String> input) {
        // 外から渡された可変リストをそのまま持たない
        this.list = java.util.List.copyOf(
            input == null ? java.util.List.of() : input.stream()
                .map(s -> s == null ? "" : s.trim())
                .filter(s -> !s.isEmpty())
                .toList()
        );
    }

    public java.util.List<String> asList() {
        // 不変コピー(変更不能ビュー)を返す
        return list;
    }
}
Java

equals/hashCode と値の同一性

不変なら equals/hashCode を安心して実装できます。ハッシュ値が変わらないため、ハッシュマップのキーにも安全に使えます。

public final class Email {
    private final String value;

    public Email(String raw) {
        var v = raw == null ? "" : raw.trim().toLowerCase(java.util.Locale.ROOT);
        if (!v.contains("@")) throw new IllegalArgumentException("invalid email");
        this.value = v;
    }

    public String value() { return value; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Email e)) return false;
        return value.equals(e.value);
    }
    @Override public int hashCode() { return value.hashCode(); }
}
Java

record の活用(不変値オブジェクトを簡潔に)

record の基本

record は「不変のデータキャリア」を簡潔に書ける構文です。フィールドは自動で private final になり、等価・ハッシュ・toString が生成されます。検証が必要なら「コンパクトコンストラクタ」を使います。

public record Point(int x, int y) {
    public Point {
        if (x < 0 || y < 0) throw new IllegalArgumentException("non-negative");
    }
}
Java

record と防御的コピー

record に可変データ(配列や可変リスト)を持たせるなら、コンストラクタで防御的コピーにするのが不変の流儀です。

public record Bytes(byte[] data) {
    public Bytes {
        data = (data == null) ? new byte[0] : java.util.Arrays.copyOf(data, data.length);
    }
    public byte[] data() { return java.util.Arrays.copyOf(data, data.length); }
}
Java

不変設計の適用範囲と置き換え方(重要ポイントの深掘り)

どこまで不変にするか

  • 値オブジェクト(ID、金額、期間、座標、Email等): 原則不変。メリットが最大化されます。
  • エンティティ(長いライフ、同一性重視、状態変化有り): 変更操作が必要なら全不変は難しい。外部公開は不変に近づけ、内部で検証付きに最小変更、または「値オブジェクトを差し替え」方式に寄せる。

可変設計から不変へのリファクタ

  • セッターを削る: 必要最小限の「意味のある操作」に置換。
  • with メソッドに置き換える: 新インスタンス返しに変更。
  • 防御的コピーを導入する: 外部との境界で必ず不変化。
  • テストを増やす: 不変遷移(入力→出力)を比較する純粋テストに切り替える。

例題で身につける

例 1: 不変の期間オブジェクト

public final class Period {
    private final java.time.LocalDate start;
    private final java.time.LocalDate end;

    public Period(java.time.LocalDate start, java.time.LocalDate end) {
        if (start == null || end == null || end.isBefore(start)) {
            throw new IllegalArgumentException("end >= start");
        }
        this.start = start;
        this.end = end;
    }
    public java.time.LocalDate start() { return start; }
    public java.time.LocalDate end() { return end; }

    public Period extendDays(long days) {
        if (days < 0) throw new IllegalArgumentException("days>=0");
        return new Period(start, end.plusDays(days));
    }
}
Java

例 2: 可変から不変への置き換え(with スタイル)

public final class Config {
    private final String region;
    private final int timeoutMs;

    public Config(String region, int timeoutMs) {
        if (region == null || region.isBlank() || timeoutMs <= 0) throw new IllegalArgumentException();
        this.region = region.trim();
        this.timeoutMs = timeoutMs;
    }

    public String region() { return region; }
    public int timeoutMs() { return timeoutMs; }

    // 変更は新インスタンスで表す
    public Config withTimeout(int ms) { return new Config(region, ms); }
    public Config withRegion(String r) { return new Config(r, timeoutMs); }
}
Java

例 3: 不変コレクションで整合性を保つ

public final class Catalog {
    private final java.util.List<String> items;

    public Catalog(java.util.List<String> items) {
        this.items = java.util.List.copyOf(items == null ? java.util.List.of() : items);
    }
    public java.util.List<String> items() { return items; } // 変更不能ビュー
}
Java

よくあるつまずきと回避

「性能のために可変にしたい」

多くの場合、getter/with のコストは極小で、JITが最適化します。可変に伴うバグや同期コストの方が高くつきます。まず不変で設計し、プロファイルして問題がある箇所だけ慎重に最適化しましょう。

可変入力をそのまま保持

外から渡された配列やリストをそのまま持つと外側から書き換えられます。必ずコピーして持ち、返すときも不変ビューを返す癖を。

セッター前提のフレームワーク

JPA/Beansが必要な場合は「DTOを可変」「ドメインは不変」に分離するか、プロパティアクセスを最小にして、ドメインでは不変を守ります。


仕上げのアドバイス(重要部分のまとめ)

不変オブジェクトは「壊れない・共有に強い・テストしやすい」を同時に叶えます。private final とセッター排除、防御的コピー、変更は新インスタンス返し(with)を徹底すれば、不変の基礎は完成します。値オブジェクトは原則不変に、エンティティは境界で不変に寄せる。まず不変で設計し、必要なら点で可変にする——この順序が、堅牢でわかりやすいコードへの近道です。

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