概要
業務システムでは、従業員情報や住所録のように複数のフィールドがネストした構造を「一部だけ変えたコピー」として扱う場面がよくあります。Cloneable を実装して clone() を呼ぶ手法は古くからありますが、浅いコピーしか行わず契約も曖昧なため、Effective Java でも推奨されていません。この記事では、clone() に頼らない3つのコピー手法を整理します。コピーコンストラクタでネストも含めて再帰的に複製する方法、record の with パターンで不変オブジェクトの一部を差し替える方法、そしてシリアライズによる汎用ディープコピーです。それぞれの適用場面とトレードオフを、動くコード付きで示します。
使いどころ
顧客マスタの住所変更時に、変更前のスナップショットを履歴テーブルへ保存するためにコピーコンストラクタで複製する
受注明細の一部フィールド(数量・単価)だけを修正した改定版を with パターンで作成し、元の明細は変更しない
テスト用のフィクスチャオブジェクトをシリアライズ経由でディープコピーし、テストケース間の状態汚染を防ぐ
コード例
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class CopyConstructorDemo {
// ネストしたオブジェクト
static class Address implements Serializable {
private static final long serialVersionUID = 1L;
String prefecture;
String city;
Address(String prefecture, String city) {
this.prefecture = prefecture;
this.city = city;
}
// コピーコンストラクタ
Address(Address other) {
this.prefecture = other.prefecture;
this.city = other.city;
}
@Override
public String toString() {
return prefecture + " " + city;
}
}
static class Employee implements Serializable {
private static final long serialVersionUID = 1L;
String name;
int age;
Address address;
List<String> skills;
Employee(String name, int age, Address address, List<String> skills) {
this.name = name;
this.age = age;
this.address = address;
this.skills = skills;
}
// コピーコンストラクタ: ネストも再帰的にコピー
Employee(Employee other) {
this.name = other.name;
this.age = other.age;
this.address = new Address(other.address);
this.skills = new ArrayList<>(other.skills);
}
@Override
public String toString() {
return "Employee{name='" + name + "', age=" + age
+ ", address=" + address + ", skills=" + skills + "}";
}
}
// record + with パターン(Java 17+)
record ImmutableAddress(String prefecture, String city) {}
record ImmutableEmployee(String name, int age,
ImmutableAddress address, List<String> skills) {
ImmutableEmployee {
skills = List.copyOf(skills); // 防御コピー
}
ImmutableEmployee withName(String newName) {
return new ImmutableEmployee(newName, age, address, skills);
}
ImmutableEmployee withAddress(ImmutableAddress newAddr) {
return new ImmutableEmployee(name, age, newAddr, skills);
}
}
public static void main(String[] args) {
// パターン 1: コピーコンストラクタ
var original = new Employee("田中太郎", 30,
new Address("東京都", "渋谷区"),
new ArrayList<>(List.of("Java", "Python")));
var copy = new Employee(original);
copy.name = "鈴木次郎";
copy.address.city = "新宿区";
System.out.println("元(変化なし): " + original);
System.out.println("コピー: " + copy);
// パターン 2: record の with パターン
var emp = new ImmutableEmployee("田中太郎", 30,
new ImmutableAddress("東京都", "渋谷区"),
List.of("Java", "Python"));
var modified = emp.withName("鈴木次郎")
.withAddress(new ImmutableAddress("大阪府", "梅田"));
System.out.println("元: " + emp);
System.out.println("変更後: " + modified);
}
}Version Coverage
record で不変オブジェクトを簡潔に定義でき、with パターンで一部フィールドの差し替えも自然に書ける。var + try-with-resources でシリアライズコードも読みやすい。
// Java 17: record + with パターンで一部だけ差し替え
record ImmutableEmployee(String name, int age,
ImmutableAddress address, List<String> skills) {
ImmutableEmployee { skills = List.copyOf(skills); }
ImmutableEmployee withName(String newName) {
return new ImmutableEmployee(newName, age, address, skills);
}
}Library Comparison
注意点
コピーコンストラクタでネストしたオブジェクトのコピーを忘れると浅いコピーになる。Address を含む Employee をコピーするとき、new Address(other.address) を書き忘れやすい
record の with パターンは Java に構文として存在しないため自前でメソッドを定義する必要がある。フィールド数が多いとメソッドも増えるため、Builder パターンとの併用を検討する
シリアライズによるディープコピーは全フィールドを自動コピーできるが、Serializable 未実装のクラスには使えない。性能面でも100倍以上遅くなることがある
record のフィールドに List を持つ場合、コンパクトコンストラクタで List.copyOf を呼んで防御コピーしないと外部から変更される可能性がある
FAQ
clone() は Cloneable の契約が不明確で、常に浅いコピーです。コピーコンストラクタはコピー範囲が明示的で、ネストしたフィールドの扱いもコード上で見えるため安全です。
フィールドが5つを超える場合は Builder パターンと組み合わせるのが現実的です。Lombok の @With を使う選択肢もありますが、依存の追加が許容できるか次第です。
コピーコンストラクタの100〜1,000倍遅いことがあります。テストコードやバッチの初期化など、頻度の低い場面に限定するのが安全です。