概要
「コピーしたはずのリストの中身が変わっていた」「unmodifiableList を使ったのに内容が書き換わった」――Java のコピーに関するバグは、問題の発生箇所と原因箇所が離れているため、デバッグに時間がかかることで知られています。原因はほぼ3パターンに集約されます。参照のコピーと値のコピーの混同、浅いコピーの要素共有、不変コレクション API の挙動の誤解です。この記事では、それぞれの落とし穴を再現するコードを示したうえで、record と List.copyOf を組み合わせた防御コピーの実装パターンを紹介します。特に Java 8 の Collections.unmodifiableList と Java 17 の List.copyOf の決定的な違いは、保守案件で引き継いだコードを読むときにも役立ちます。
使いどころ
CSV 取込で構築したマスタデータ一覧を複数の処理に渡す際、意図しない変更を防ぐ防御コピーを入れる
DTO のコレクションフィールドに外部から渡されたリストをそのまま代入せず、コンストラクタで防御コピーする
テストで期待値と実測値を比較する前にコピーを取り、アサーション失敗時に元データが汚染されていないことを保証する
コード例
import java.util.ArrayList;
import java.util.List;
public class CopyPitfallDemo {
// record + 防御コピー
record ImmutablePerson(String name, List<String> hobbies) {
ImmutablePerson {
hobbies = List.copyOf(hobbies); // 防御コピー
}
}
// 落とし穴の説明用に可変クラスも用意
static class MutablePerson {
String name;
List<String> hobbies;
MutablePerson(String name, List<String> hobbies) {
this.name = name;
this.hobbies = hobbies;
}
@Override
public String toString() {
return "MutablePerson{name='" + name + "', hobbies=" + hobbies + "}";
}
}
public static void main(String[] args) {
// 落とし穴 1: 参照コピー(同じリストを指す)
System.out.println("=== 落とし穴 1: 参照コピー ===");
var original = new ArrayList<>(List.of("Java", "Python"));
var ref = original; // 同じリストを参照
ref.add("Kotlin");
System.out.println("original: " + original); // Kotlin も入っている
// 落とし穴 2: 浅いコピー(要素は共有)
System.out.println("\n=== 落とし穴 2: 浅いコピー ===");
var people = new ArrayList<MutablePerson>();
people.add(new MutablePerson("田中",
new ArrayList<>(List.of("サッカー"))));
var shallowCopy = new ArrayList<>(people);
shallowCopy.get(0).hobbies.add("テニス"); // 元も変わる
System.out.println("original[0]: " + people.get(0));
// 落とし穴 3: List.copyOf はスナップショット
System.out.println("\n=== List.copyOf は独立したコピー ===");
var mutable = new ArrayList<>(List.of("A", "B"));
var snapshot = List.copyOf(mutable);
mutable.add("C");
System.out.println("mutable: " + mutable); // [A, B, C]
System.out.println("snapshot: " + snapshot); // [A, B]
// 対策: record + List.copyOf で安全なコピー
System.out.println("\n=== 対策: record + List.copyOf ===");
var person = new ImmutablePerson("田中",
List.of("サッカー", "テニス"));
System.out.println("person: " + person);
// person.hobbies().add("野球");
// → UnsupportedOperationException(防御コピー済み)
}
}Version Coverage
List.copyOf で元リストから独立した不変コピーを安全に作れる。record との組み合わせで防御コピーが簡潔に書ける。
// Java 17: List.copyOf は独立したスナップショットを作る
var mutable = new ArrayList<>(List.of("A", "B"));
var snapshot = List.copyOf(mutable);
mutable.add("C");
System.out.println(snapshot); // [A, B] ← 変わらないLibrary Comparison
注意点
Collections.unmodifiableList は元リストの参照を持つだけなので、元リストが変更されると不変リスト側にも反映される。完全に独立したコピーには List.copyOf を使う
List.of() が返すリストに null を渡すと NullPointerException になる。null を含む可能性があるデータには Collections.unmodifiableList を使う
Stream.toList()(Java 16+)は不変リストを返すが、Collectors.toList() は可変リストを返す。名前が似ているため混同しやすい
record のフィールドに可変コレクションを渡すと、record 外部からリストの中身を変更できてしまう。コンパクトコンストラクタで List.copyOf を使って防御コピーすること
浅いコピーで要素が共有されるバグは、単体テストでは再現しにくい。複数スレッドやバッチの並列実行で初めて顕在化するケースがある
FAQ
List.copyOf は元リストから独立した不変コピーを作ります。unmodifiableList は元リストへの参照を持つだけなので、元が変わると不変リスト側も変わります。
Java 16 以上なら Stream.toList() が簡潔で不変リストを返します。ただし返却後に変更が必要な場合は Collectors.toCollection(ArrayList::new) を使います。
record のフィールドが不変型(String, int, LocalDate 等)のみなら不要です。List や配列など可変型を含む場合は List.copyOf で防御コピーしないと外部から書き換えられます。