- JAVA
Java不変クラスの作り方|finalと防御的コピー、recordまで
不変クラス(イミュータブルなクラス)とは、インスタンスを作ったあとに中身が一切変わらないクラスです。 String や Integer、LocalDate が代表例で、どこに渡しても値が変わらないため、安心して共有できます。自分で作るときは、フィールドに final を付けるだけでは足りず、List や配列などの「中身を変更できるオブジェクト」を外と共有しない工夫(防御的コピー) が必要です。
この記事では、final を付けただけのクラスが List や配列を経由して外から書き換えられる様子を実行結果で再現し、コンストラクタと getter の両方で防御的コピーをする修正版を示します。さらに、record でも同じことが起きること、コンパクトコンストラクタと List.copyOf による対処、5条件のチェックリスト、class と record の比較表まで扱います。コードはすべて OpenJDK 21.0.10 で動かし、出力をそのまま載せています。
不変クラスの5条件チェックリスト(早見表)
| No. | 条件 | 書き方 | 満たさないとどうなるか |
|---|---|---|---|
| 1 | 状態を変えるメソッドを作らない | setter を書かない。値を変えたいときは新しいインスタンスを返す | メソッド経由で中身が変わる |
| 2 | 継承できないようにする | final class にする(record は最初から継承できない) |
サブクラスが可変の状態を足せる |
| 3 | すべてのフィールドを final にする | private final 型 名前; |
再代入で中身が変わる |
| 4 | すべてのフィールドを private にする | private final |
外から直接フィールドを触れる |
| 5 | 可変オブジェクトを外と共有しない | コンストラクタと getter の両方で防御的コピー | List や配列を経由して中身が変わる |
この5つは、不変クラスの条件として広く知られているものです。1〜4 は書き方を守れば満たせますが、5 は List・配列・Date のようなフィールドがあるときだけ必要になり、見落としやすい 条件です。フィールドの型ごとの対処をまとめます。
| フィールドの型 | コンストラクタ(入口) | getter(出口) |
|---|---|---|
int・boolean などの基本型、String、LocalDate、Integer などの不変な型 |
そのまま代入 | そのまま返す |
List |
List.copyOf(引数) |
そのまま返せる(変更できないリストのため) |
Set / Map |
Set.copyOf(引数) / Map.copyOf(引数) |
そのまま返せる |
| 配列 | 引数.clone() |
フィールド.clone() |
java.util.Date |
new Date(引数.getTime()) |
new Date(フィールド.getTime())。可能なら LocalDate や Instant に置き換える |
| 自作の可変クラス | コピーを作る、または不変クラスに作り直す | 同じ |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java不変クラスの作り方|finalと防御的コピー、recordまで」
この記事の動作環境
| 項目 | 内容 |
|---|---|
| JDK | OpenJDK 21.0.10 |
| OS | Linux(Ubuntu 24.04 系) |
| 実行方法 | java ファイル名.java(単一ファイル実行)。コンパイルエラーの例は javac で確認 |
| 使っている機能 | List.copyOf(Java 10 以降)、record(Java 16 以降) |
不変クラス(イミュータブル)とは|可変クラスとの違い
| 比べる点 | 可変(ミュータブル) | 不変(イミュータブル) |
|---|---|---|
| 作成後の変更 | できる(setter や add など) | できない |
| 値を変えたいとき | 同じインスタンスを書き換える | 新しいインスタンスを作る |
| 標準クラスの例 | ArrayList、StringBuilder、java.util.Date、配列 |
String、Integer、LocalDate、List.of(...) の戻り値 |
| 複数の場所での共有 | どこかで変更すると全員に影響する | 変わらないので、そのまま共有できる |
| HashMap のキー | 入れたあとに変更すると取り出せなくなることがある | 安心して使える |
| 複数スレッドからの利用 | 同時に書き換えないための制御が必要 | 読むだけなので、そのまま使える |
不変クラスの良さは、「渡した先で書き換えられていないか」を考えなくて済む ことです。メソッドの引数に渡しても、フィールドに保持しても、別スレッドに渡しても、値は作ったときのままです。
finalを付けただけでは不変にならない|書き換えを再現
まず、final の意味を確認します。final が止めるのは「変数への再代入」であって、「変数が指しているオブジェクトの中身の変更」ではありません。
import java.util.ArrayList;
import java.util.List;
public class FinalErr {
public static void main(String[] args) {
final List<String> members = new ArrayList<>();
members.add("佐藤"); // 中身の変更はできる
members = new ArrayList<>(); // 再代入はできない
}
}
FinalErr.java:7: error: cannot assign a value to final variable members
members = new ArrayList<>(); // 再代入はできない
^
1 error
7行目の再代入はコンパイルエラーになりますが、6行目の members.add("佐藤") はエラーになっていません。final な変数でも、リストへの追加はできるということです。
この性質が、クラスのフィールドでどう現れるかを見てみます。次の Team クラスは、final class で、フィールドはすべて private final、setter もありません。条件の1〜4を満たしています。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class FinalOnly {
// フィールドに final を付けただけのクラス
static final class Team {
private final String name;
private final List<String> members;
private final int[] scores;
Team(String name, List<String> members, int[] scores) {
this.name = name;
this.members = members; // 受け取った参照をそのまま保持
this.scores = scores;
}
String getName() { return name; }
List<String> getMembers() { return members; } // 中の参照をそのまま返す
int[] getScores() { return scores; }
@Override
public String toString() {
return name + " " + members + " " + Arrays.toString(scores);
}
}
public static void main(String[] args) {
List<String> members = new ArrayList<>(List.of("佐藤", "鈴木"));
int[] scores = {80, 90};
Team team = new Team("A", members, scores);
System.out.println("作成直後 : " + team);
// 経路1: コンストラクタに渡した元のリスト・配列を書き換える
members.add("田中");
scores[0] = 0;
System.out.println("元の変数を変更した後 : " + team);
// 経路2: getter で受け取った参照を書き換える
team.getMembers().clear();
team.getScores()[1] = -1;
System.out.println("getter経由で変更した後 : " + team);
}
}
実行結果:
作成直後 : A [佐藤, 鈴木] [80, 90]
元の変数を変更した後 : A [佐藤, 鈴木, 田中] [0, 90]
getter経由で変更した後 : A [] [0, -1]
Team のメソッドを何も呼んでいないのに、中身が2回変わりました。書き換えの経路は2つあります。
| 経路 | 何が起きたか | 原因の行 |
|---|---|---|
| 入口(コンストラクタ) | 呼び出し側が持っている members と scores を変更したら、Team の中身も変わった |
this.members = members;(同じリストを指している) |
| 出口(getter) | getMembers() で受け取ったリストを clear() したら、Team の中身が空になった |
return members;(中のリストをそのまま渡している) |
図にすると、Team の外と中が同じオブジェクトを指していることがわかります。
flowchart LR
A["呼び出し側の変数 members"] --> L["ArrayList の実体"]
B["Team のフィールド members"] --> L
C["getMembers() の戻り値"] --> L
フィールドに入っているのはオブジェクトそのものではなく「参照(どのオブジェクトかを示す情報)」 なので、同じ参照を外にも持たれていると、外から中身を変えられます。
防御的コピーで直す|コンストラクタとgetterの両方で行う
対処は、外と同じオブジェクトを共有しないよう、受け取るときと渡すときにコピーを作る ことです。これを防御的コピーと呼びます。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Defensive {
static final class Team {
private final String name;
private final List<String> members;
private final int[] scores;
Team(String name, List<String> members, int[] scores) {
this.name = name;
this.members = List.copyOf(members); // 入口: 変更できないコピーを作る
this.scores = scores.clone(); // 入口: 配列を複製する
}
String getName() { return name; }
List<String> getMembers() { return members; } // List.copyOf 済みなのでそのまま返せる
int[] getScores() { return scores.clone(); } // 出口: 配列は毎回複製して返す
@Override
public String toString() {
return name + " " + members + " " + Arrays.toString(scores);
}
}
public static void main(String[] args) {
List<String> members = new ArrayList<>(List.of("佐藤", "鈴木"));
int[] scores = {80, 90};
Team team = new Team("A", members, scores);
System.out.println("作成直後 : " + team);
members.add("田中");
scores[0] = 0;
System.out.println("元の変数を変更した後 : " + team);
team.getScores()[1] = -1;
System.out.println("配列のgetter経由の後 : " + team);
try {
team.getMembers().clear();
} catch (UnsupportedOperationException e) {
System.out.println("getMembers().clear() : " + e);
}
System.out.println("最後 : " + team);
}
}
実行結果:
作成直後 : A [佐藤, 鈴木] [80, 90]
元の変数を変更した後 : A [佐藤, 鈴木] [80, 90]
配列のgetter経由の後 : A [佐藤, 鈴木] [80, 90]
getMembers().clear() : java.lang.UnsupportedOperationException
最後 : A [佐藤, 鈴木] [80, 90]
どの操作をしても、Team の中身は作成直後のままです。直す前と後を並べます。
| 場所 | 直す前 | 直した後 |
|---|---|---|
| コンストラクタ(List) | this.members = members; |
this.members = List.copyOf(members); |
| コンストラクタ(配列) | this.scores = scores; |
this.scores = scores.clone(); |
| getter(List) | return members; |
return members;(List.copyOf 済みなので変更なし) |
| getter(配列) | return scores; |
return scores.clone(); |
ポイントは次の2つです。
- List は入口で
List.copyOfすれば、出口はそのまま返せます。List.copyOfが返すのは変更できないリストなので、受け取った側がclear()やadd()を呼ぶとUnsupportedOperationExceptionになります。 - 配列は入口と出口の両方で
clone()が必要です。 配列には「変更できない配列」という種類がないため、渡すたびにコピーを作ります。配列のままにする理由がなければ、List に置き換えると getter が簡単になります。
List.copyOf・Collections.unmodifiableList・new ArrayListの違い
リストのコピーや保護に使えるメソッドは複数あり、動きが違います。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Views {
public static void main(String[] args) {
List<String> src = new ArrayList<>(List.of("a", "b"));
List<String> view = Collections.unmodifiableList(src); // 元リストの「読み取り専用の窓」
List<String> copy = List.copyOf(src); // 変更できないコピー
List<String> arrayList = new ArrayList<>(src); // 変更できるコピー
src.add("c");
System.out.println("元リスト : " + src);
System.out.println("Collections.unmodifiableList: " + view);
System.out.println("List.copyOf : " + copy);
System.out.println("new ArrayList<>(src) : " + arrayList);
try { view.add("x"); } catch (UnsupportedOperationException e) { System.out.println("view.add -> " + e); }
try { copy.add("x"); } catch (UnsupportedOperationException e) { System.out.println("copy.add -> " + e); }
// List.copyOf は、すでに変更不可のリストなら同じインスタンスを返す
List<String> again = List.copyOf(copy);
System.out.println("List.copyOf(copy) == copy : " + (again == copy));
// null の扱い
try { List.copyOf(Arrays.asList("a", null)); } catch (NullPointerException e) { System.out.println("null要素 -> " + e); }
try { List.copyOf(null); } catch (NullPointerException e) { System.out.println("null引数 -> " + e.getClass().getSimpleName()); }
}
}
実行結果:
元リスト : [a, b, c]
Collections.unmodifiableList: [a, b, c]
List.copyOf : [a, b]
new ArrayList<>(src) : [a, b]
view.add -> java.lang.UnsupportedOperationException
copy.add -> java.lang.UnsupportedOperationException
List.copyOf(copy) == copy : true
null要素 -> java.lang.NullPointerException
null引数 -> NullPointerException
| 方法 | 元リストを後から変更したとき | 戻り値への add | 不変クラスの入口に向くか |
|---|---|---|---|
Collections.unmodifiableList(src) |
影響を受ける(元リストを見せる窓のため) | 例外 | 単独では向かない。コピーと組み合わせる |
List.copyOf(src) |
影響を受けない | 例外 | 向いている |
new ArrayList<>(src) |
影響を受けない | できる | 入口には使えるが、getter でもう一度保護が必要 |
Collections.unmodifiableList は、元のリストに「変更できないカバー」をかぶせるだけです。元リストに "c" を追加すると、カバー越しに見える中身も [a, b, c] に変わっています。コンストラクタで受け取った引数に使うなら、コピーまで行う List.copyOf が適しています。
List.copyOf には、知っておきたい性質が2つあります。
- すでに変更できないリスト(
List.ofやList.copyOfの戻り値)を渡すと、同じインスタンスをそのまま返します(出力のtrue)。毎回コピーが作られるわけではないので、気軽に使えます。 - null の要素と null の引数は受け付けず、
NullPointerExceptionになります。 null を許したい場合は、Collections.unmodifiableList(new ArrayList<>(src))のようにコピーとカバーを組み合わせます。
継承できないようにする|finalクラスにする理由
条件2の「継承できないようにする」も、実行して理由を確かめます。次の Stock は、フィールドが private final で setter もありませんが、クラスに final が付いていません。
public class SubclassLeak {
// final を付けていないクラス
static class Stock {
private final int count;
Stock(int count) { this.count = count; }
int getCount() { return count; }
}
// サブクラスが可変の状態を足して、getter を上書きする
static class ReservedStock extends Stock {
int reserved;
ReservedStock(int count) { super(count); }
@Override int getCount() { return super.getCount() - reserved; }
}
static void print(Stock p) {
System.out.println("在庫数: " + p.getCount());
}
public static void main(String[] args) {
ReservedStock p = new ReservedStock(100);
print(p);
p.reserved = 30;
print(p); // 同じインスタンスなのに値が変わって見える
}
}
実行結果:
在庫数: 100
在庫数: 70
print メソッドは Stock 型を受け取っていますが、渡されたのはサブクラスの ReservedStock です。サブクラスが可変のフィールドを足して getter を上書きしたため、Stock 型として受け取ったのに、値が変わって見えるインスタンス になりました。
クラスに final を付ければ、サブクラスを作れなくなります。record は最初から継承できません。
public class ExtendFinalErr {
static final class Stock { }
static class ReservedStock extends Stock { }
record Point(int x, int y) { }
static class Point3D extends Point {
Point3D(int x, int y) { super(x, y); }
}
public static void main(String[] args) {}
}
ExtendFinalErr.java:3: error: cannot inherit from final Stock
static class ReservedStock extends Stock { }
^
ExtendFinalErr.java:6: error: cannot inherit from final Point
static class Point3D extends Point {
^
2 errors
recordで不変クラスを短く書く|同じ書き換えが起きる例
record は、データを運ぶための不変なクラスを1行で定義できる構文 です(Java 16 以降)。record Team(String name, List<String> members) {} と書くだけで、private final フィールド、コンストラクタ、アクセサ(name() のようにフィールド名と同じ名前のメソッド)、equals、hashCode、toString が用意されます。クラスは暗黙に final で、setter を書こうとしてもフィールドに代入できません。
public class RecordSetErr {
record Point(int x, int y) {
void setX(int x) { this.x = x; }
}
public static void main(String[] args) {}
}
RecordSetErr.java:3: error: cannot assign a value to final variable x
void setX(int x) { this.x = x; }
^
つまり、record は5条件のうち 1〜4 を自動で満たします。 残るのは条件5です。List や配列を持つ record で、先ほどと同じ操作をしてみます。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class RecordLeak {
record Team(String name, List<String> members, int[] scores) {}
public static void main(String[] args) {
List<String> members = new ArrayList<>(List.of("佐藤", "鈴木"));
int[] scores = {80, 90};
Team team = new Team("A", members, scores);
System.out.println("作成直後 : " + team.members() + " " + Arrays.toString(team.scores()));
members.add("田中");
scores[0] = 0;
System.out.println("元の変数を変更した後 : " + team.members() + " " + Arrays.toString(team.scores()));
team.members().clear();
team.scores()[1] = -1;
System.out.println("アクセサ経由の後 : " + team.members() + " " + Arrays.toString(team.scores()));
// 配列を持つ record は equals も参照比較になる
Team t1 = new Team("A", List.of("佐藤"), new int[] {1, 2});
Team t2 = new Team("A", List.of("佐藤"), new int[] {1, 2});
System.out.println("中身が同じ2つのequals: " + t1.equals(t2));
System.out.println("toString : " + t1.toString().replaceAll("@[0-9a-f]+", "@(ハッシュ値)"));
}
}
実行結果:
作成直後 : [佐藤, 鈴木] [80, 90]
元の変数を変更した後 : [佐藤, 鈴木, 田中] [0, 90]
アクセサ経由の後 : [] [0, -1]
中身が同じ2つのequals: false
toString : Team[name=A, members=[佐藤], scores=[I@(ハッシュ値)]
final を付けただけのクラスとまったく同じ結果になりました。record が保証するのは「フィールドに再代入できないこと」までで、フィールドが指す List や配列の中身までは守りません。 自動生成されるコンストラクタは受け取った参照をそのまま代入し、アクセサはそのまま返すためです。
また、配列を持つ record は、中身が同じでも equals が false になり、toString も配列の中身ではなく型とハッシュ値の表示になります(配列の比較が「同じインスタンスかどうか」で行われるため)。
コンパクトコンストラクタとList.copyOfで対処する
record では、コンパクトコンストラクタ を使うと、防御的コピーを短く書けます。コンパクトコンストラクタは、引数リストを省略して レコード名 { ... } と書く形で、フィールドに代入される前の引数を検証したり、整えたりする場所 です。
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class RecordSafe {
record Team(String name, List<String> members, List<Integer> scores) {
// コンパクトコンストラクタ: 引数リストを書かず、代入前の値を整える
Team {
Objects.requireNonNull(name, "name");
if (name.isBlank()) {
throw new IllegalArgumentException("name は1文字以上で指定してください");
}
members = List.copyOf(members); // フィールドではなく「引数」に入れ直す
scores = List.copyOf(scores);
}
// 値を変えたいときは、新しいインスタンスを返す
Team withMember(String member) {
List<String> next = new ArrayList<>(members);
next.add(member);
return new Team(name, next, scores);
}
}
public static void main(String[] args) {
List<String> members = new ArrayList<>(List.of("佐藤", "鈴木"));
List<Integer> scores = new ArrayList<>(List.of(80, 90));
Team team = new Team("A", members, scores);
System.out.println("作成直後 : " + team);
members.add("田中");
scores.set(0, 0);
System.out.println("元の変数を変更した後 : " + team);
try {
team.members().clear();
} catch (UnsupportedOperationException e) {
System.out.println("members().clear() : " + e);
}
Team bigger = team.withMember("田中");
System.out.println("withMember の戻り値 : " + bigger);
System.out.println("元のインスタンス : " + team);
Team same = new Team("A", List.of("佐藤", "鈴木"), List.of(80, 90));
System.out.println("equals : " + team.equals(same));
try {
new Team(" ", members, scores);
} catch (IllegalArgumentException e) {
System.out.println("検証 : " + e.getMessage());
}
}
}
実行結果:
作成直後 : Team[name=A, members=[佐藤, 鈴木], scores=[80, 90]]
元の変数を変更した後 : Team[name=A, members=[佐藤, 鈴木], scores=[80, 90]]
members().clear() : java.lang.UnsupportedOperationException
withMember の戻り値 : Team[name=A, members=[佐藤, 鈴木, 田中], scores=[80, 90]]
元のインスタンス : Team[name=A, members=[佐藤, 鈴木], scores=[80, 90]]
equals : true
検証 : name は1文字以上で指定してください
元のリストを変更しても record の中身は変わらず、アクセサから受け取ったリストも変更できなくなりました。配列を List<Integer> に置き換えたことで、equals と toString も中身で比較・表示されています。
コンパクトコンストラクタの書き方のポイントは次のとおりです。
| ポイント | 説明 |
|---|---|
| 引数リストを書かない | Team { ... } と書く。引数は record のヘッダーと同じ名前で使える |
| 引数に入れ直す | members = List.copyOf(members); のように、引数の変数 に代入する |
| フィールドへの代入は自動 | ブロックの最後で、引数の値がフィールドへ自動的に代入される |
| 検証も書ける | 条件に合わなければ例外を投げ、不正な値のインスタンスを作らせない |
this.members = ... と書くと、コンパイルエラーになります。
import java.util.List;
public class CompactErr {
record Team(String name, List<String> members) {
Team {
this.members = List.copyOf(members);
}
}
public static void main(String[] args) {}
}
CompactErr.java:5: error: cannot assign a value to final variable members
this.members = List.copyOf(members);
^
日本語表示では「final変数membersに値を割り当てることはできません」です。コンパクトコンストラクタでは this. を付けずに引数へ代入する と覚えておきます。
recordに配列を持たせる場合
配列をそのまま持たせたい場合は、コンパクトコンストラクタでの clone() に加えて、アクセサ、equals、hashCode、toString も自分で書くことになります。
import java.util.Arrays;
public class RecordArray {
// 配列をどうしても持たせる場合: 入口と出口の両方で clone する
record Scores(String name, int[] values) {
Scores {
values = values.clone();
}
@Override
public int[] values() {
return values.clone();
}
@Override
public boolean equals(Object o) {
return o instanceof Scores s && name.equals(s.name) && Arrays.equals(values, s.values);
}
@Override
public int hashCode() {
return 31 * name.hashCode() + Arrays.hashCode(values);
}
@Override
public String toString() {
return "Scores[name=" + name + ", values=" + Arrays.toString(values) + "]";
}
}
public static void main(String[] args) {
int[] src = {80, 90};
Scores s = new Scores("A", src);
src[0] = 0;
s.values()[1] = -1;
System.out.println(s);
System.out.println(s.equals(new Scores("A", new int[] {80, 90})));
}
}
実行結果:
Scores[name=A, values=[80, 90]]
true
意図どおりに動きますが、record の短さという利点が小さくなります。record のコンポーネントは、配列より List にしておくと、コンパクトコンストラクタの1行で済みます。
値を変えたいときは新しいインスタンスを返す
不変クラスには setter がありません。値を変えた結果がほしいときは、変更後の値を持つ新しいインスタンスを返すメソッド を用意します。上の RecordSafe の withMember がその例です。
Team withMember(String member) {
List<String> next = new ArrayList<>(members);
next.add(member);
return new Team(name, next, scores);
}
実行結果のとおり、withMember の戻り値には「田中」が加わり、元のインスタンスは変わっていません。String の toUpperCase() や LocalDate の plusDays() と同じ考え方です。メソッド名は with〜 や plus〜 のように、新しい値を返すことが伝わる名前 にすると、戻り値を受け取り忘れることが減ります。
つまずきやすい箇所|実行して確かめる
Listの要素が可変だと、要素の中身は変えられる
List.copyOf が作るのは 浅いコピー です。リストそのもの(要素の追加・削除・入れ替え)は守られますが、要素になっているオブジェクトの中身までは守りません。
import java.util.ArrayList;
import java.util.List;
public class DeepLeak {
static class Member { // 可変クラス
String name;
Member(String name) { this.name = name; }
@Override public String toString() { return name; }
}
record Team(List<Member> members) {
Team { members = List.copyOf(members); }
}
record MemberR(String name) {} // 要素も不変にする
record TeamR(List<MemberR> members) {
TeamR { members = List.copyOf(members); }
}
public static void main(String[] args) {
Member m = new Member("佐藤");
Team team = new Team(new ArrayList<>(List.of(m)));
m.name = "書き換え";
System.out.println("要素が可変 : " + team);
TeamR teamR = new TeamR(List.of(new MemberR("佐藤")));
System.out.println("要素も不変 : " + teamR);
}
}
実行結果:
要素が可変 : Team[members=[書き換え]]
要素も不変 : TeamR[members=[MemberR[name=佐藤]]]
要素の型も不変(record や String など)にしておく と、浅いコピーだけで全体が不変になります。
java.util.Dateは可変、LocalDateは不変
日付を持たせるとき、java.util.Date は setTime で中身を変更できる可変クラスです。
import java.time.LocalDate;
import java.util.Date;
public class DateLeak {
record OldEvent(String title, Date date) {}
record NewEvent(String title, LocalDate date) {}
public static void main(String[] args) {
Date d = new Date(0L);
OldEvent old = new OldEvent("キックオフ", d);
long before = old.date().getTime();
d.setTime(86_400_000L); // 外から書き換え
System.out.println("Date : " + before + " -> " + old.date().getTime());
LocalDate ld = LocalDate.of(2026, 10, 3);
NewEvent ev = new NewEvent("キックオフ", ld);
LocalDate next = ld.plusDays(1); // 新しいインスタンスが返る
System.out.println("LocalDate : " + ev.date() + " / plusDays の戻り値 " + next);
}
}
実行結果:
Date : 0 -> 86400000
LocalDate : 2026-10-03 / plusDays の戻り値 2026-10-04
Date を持つ record は、外から setTime されると中身が変わりました。LocalDate は不変なので、防御的コピーなしでそのまま保持できます。日付・時刻のフィールドは java.time の型(LocalDate、LocalDateTime、Instant)にする と、条件5を気にせずに済みます。
HashMapのキーにしたあとで中身が変わると取り出せなくなる
不変にしておく効果がはっきり表れるのが、HashMap のキーや HashSet の要素として使う場合です。
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class MapKey {
record Tags(List<String> values) {}
record SafeTags(List<String> values) {
SafeTags { values = List.copyOf(values); }
}
public static void main(String[] args) {
List<String> src = new ArrayList<>(List.of("java"));
Map<Tags, String> map = new HashMap<>();
Tags key = new Tags(src);
map.put(key, "記事A");
System.out.println("変更前 get: " + map.get(key));
src.add("record"); // キーの中身が変わる = hashCode も変わる
System.out.println("変更後 get: " + map.get(key));
System.out.println("size : " + map.size());
List<String> src2 = new ArrayList<>(List.of("java"));
Map<SafeTags, String> safe = new HashMap<>();
SafeTags key2 = new SafeTags(src2);
safe.put(key2, "記事A");
src2.add("record");
System.out.println("防御的コピーあり get: " + safe.get(key2));
}
}
実行結果:
変更前 get: 記事A
変更後 get: null
size : 1
防御的コピーあり get: 記事A
防御的コピーのない Tags は、キーとして登録したあとに元のリストが変更されると hashCode の値が変わり、同じキーを渡しているのに get が null を返します(データは Map の中に残っているので size は 1 のまま)。コンパクトコンストラクタで List.copyOf している SafeTags は、元のリストを変更しても影響を受けず、値を取り出せています。
classとrecordの比較表|どちらで不変クラスを作るか
| 比べる点 | final class で書く | record で書く |
|---|---|---|
| 使えるバージョン | すべてのバージョン | Java 16 以降 |
| フィールドの private final | 自分で書く | 自動 |
| コンストラクタ | 自分で書く | 自動(コンパクトコンストラクタで検証・コピーを追加できる) |
| 値を取り出すメソッド | getName() など自分で書く |
name() が自動で用意される |
equals / hashCode / toString |
自分で書く | 自動(全コンポーネントを使う) |
| 継承の禁止 | final を自分で付ける |
最初から継承できない |
| 他のクラスの継承 | できる | できない(インターフェースの実装はできる) |
| setter | 書かないように自分で気をつける | フィールドに代入できないので書けない |
| 防御的コピー | コンストラクタと getter に自分で書く | コンパクトコンストラクタに自分で書く |
| 一部のフィールドを外に見せない | できる | できない(コンポーネントはすべてアクセサで公開される) |
| コード量 | 多い | 少ない |
選び方の目安です。
| 状況 | 向いているほう |
|---|---|
| 値をまとめて運ぶことが主な役割で、全フィールドを公開してよい | record |
| 内部の持ち方を隠したい、公開する項目を絞りたい | final class |
equals に使う項目を一部に限定したい |
final class(record でも上書きはできるが、利点が小さくなる) |
| 他のクラスを継承する必要がある | final class |
getXxx() 形式のメソッド名を前提にしたライブラリと組み合わせる |
対応状況を確認したうえで選ぶ |
どちらを選んでも、List・配列・Date などの可変なフィールドには防御的コピーが必要 という点は共通です。
コピペ用|不変クラスのひな形(classとrecord)
ここまでの内容をまとめたひな形です。上の Defensive.java と RecordSafe.java で動作を確かめた書き方を、最小の形に整理しています(このひな形はコンパイルが通ることだけを確認しています)。
import java.util.List;
import java.util.Objects;
// final class で書く場合
public final class Article {
private final String title;
private final List<String> tags;
public Article(String title, List<String> tags) {
this.title = Objects.requireNonNull(title, "title");
this.tags = List.copyOf(tags); // 入口で防御的コピー
}
public String getTitle() { return title; }
public List<String> getTags() { return tags; } // 変更できないリストなのでそのまま返す
// 値を変えたいときは新しいインスタンスを返す
public Article withTitle(String newTitle) {
return new Article(newTitle, tags);
}
}
import java.util.List;
import java.util.Objects;
// record で書く場合
public record Article(String title, List<String> tags) {
public Article {
Objects.requireNonNull(title, "title");
tags = List.copyOf(tags); // this. を付けずに引数へ入れ直す
}
public Article withTitle(String newTitle) {
return new Article(newTitle, tags);
}
}
final class のほうは、値で比較したい場合に equals と hashCode を追加します。
よくある質問
Q. フィールドを全部 final にすれば不変クラスになりますか?
A. 基本型や String だけなら、それで中身は変わらなくなります。List・配列・Date など中身を変更できる型のフィールドがある場合は、final だけでは足りず、コンストラクタと getter での防御的コピーが必要です。本記事の FinalOnly の実行結果のとおり、final を付けていても外から書き換えられます。
Q. record にすれば自動的に不変になりますか?
A. フィールドへの再代入はできなくなりますが、List や配列の中身までは守られません。コンパクトコンストラクタで List.copyOf を使って、変更できないコピーを保持するようにします。
Q. 毎回コピーを作ると遅くなりませんか?
A. List.copyOf は、すでに変更できないリストを渡された場合は同じインスタンスを返すので、不変クラスどうしで受け渡す分にはコピーが増えません(本記事では処理時間の計測は行っていません)。非常に大きな配列を頻繁に getter で返すような場面では、配列を返す代わりに「n 番目の値を返すメソッド」を用意する方法もあります。
Q. 不変クラスにすると、値を変えるたびにインスタンスが増えませんか?
A. 増えます。その代わり、どこかで書き換えられる心配がなくなり、共有やキャッシュがしやすくなります。ループの中で何度も値を組み立てる処理では、StringBuilder と String の関係のように、組み立て中は可変のオブジェクトを使い、完成したら不変のオブジェクトにする と両方の利点を活かせます。
まとめ
- 不変クラスは、作成後に中身が変わらないクラス。共有しても、Map のキーにしても、値が作ったときのまま保たれる
- 5条件は「setter なし・final class・全フィールド final・全フィールド private・可変オブジェクトを共有しない」
- final が止めるのは再代入だけ。List や配列の中身は、コンストラクタに渡した元の変数と getter の戻り値の2つの経路から変更できる
- List は入口で
List.copyOf、配列は入口と出口の両方でclone()が防御的コピーの基本形 - record は条件1〜4を自動で満たすが、条件5は自分で書く。コンパクトコンストラクタで
members = List.copyOf(members);と引数に入れ直す Collections.unmodifiableListは元リストの変更が透けて見える。入口にはコピーまで行うList.copyOfが向いている- 要素の型や日付の型も不変なもの(record、String、LocalDate)を選ぶ と、浅いコピーだけで全体を不変にできる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java不変クラスの作り方|finalと防御的コピー、recordまで」
株式会社RJCでは、Javaの不変クラスの作り方や防御的コピーの考え方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。