概要

業務システムでは、従業員情報や住所録のように複数のフィールドがネストした構造を「一部だけ変えたコピー」として扱う場面がよくあります。Cloneable を実装して clone() を呼ぶ手法は古くからありますが、浅いコピーしか行わず契約も曖昧なため、Effective Java でも推奨されていません。この記事では、clone() に頼らない3つのコピー手法を整理します。コピーコンストラクタでネストも含めて再帰的に複製する方法、record の with パターンで不変オブジェクトの一部を差し替える方法、そしてシリアライズによる汎用ディープコピーです。それぞれの適用場面とトレードオフを、動くコード付きで示します。

使いどころ

顧客マスタの住所変更時に、変更前のスナップショットを履歴テーブルへ保存するためにコピーコンストラクタで複製する

受注明細の一部フィールド(数量・単価)だけを修正した改定版を with パターンで作成し、元の明細は変更しない

テスト用のフィクスチャオブジェクトをシリアライズ経由でディープコピーし、テストケース間の状態汚染を防ぐ

コード例

CopyConstructorDemo.java
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);
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record で不変オブジェクトを簡潔に定義でき、with パターンで一部フィールドの差し替えも自然に書ける。var + try-with-resources でシリアライズコードも読みやすい。

Java 17
// 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

標準 API(コピーコンストラクタ)フィールド数が少なくネストも浅い場合。意図が明確で保守しやすい。フィールド追加時にコンストラクタの修正漏れが起きやすい。ネストが深いと再帰コピーのコードが増える。
標準 API(シリアライズ)全フィールドを自動コピーしたいとき。コピーコンストラクタの記述コストを避けたい場合。Serializable が必要。性能は数十〜数百倍遅く、大量データや高頻度呼び出しには不向き。
ModelMapper / MapStruct異なる型間のマッピングを含むコピーが大量にあるとき。同一型のディープコピーだけなら導入コストに見合わない。フレームワーク依存が増える。

注意点

コピーコンストラクタでネストしたオブジェクトのコピーを忘れると浅いコピーになる。Address を含む Employee をコピーするとき、new Address(other.address) を書き忘れやすい

record の with パターンは Java に構文として存在しないため自前でメソッドを定義する必要がある。フィールド数が多いとメソッドも増えるため、Builder パターンとの併用を検討する

シリアライズによるディープコピーは全フィールドを自動コピーできるが、Serializable 未実装のクラスには使えない。性能面でも100倍以上遅くなることがある

record のフィールドに List を持つ場合、コンパクトコンストラクタで List.copyOf を呼んで防御コピーしないと外部から変更される可能性がある

FAQ

clone() ではなくコピーコンストラクタを使うべき理由は何ですか。

clone() は Cloneable の契約が不明確で、常に浅いコピーです。コピーコンストラクタはコピー範囲が明示的で、ネストしたフィールドの扱いもコード上で見えるため安全です。

record の with パターンはフィールド数が多いと不便ではないですか。

フィールドが5つを超える場合は Builder パターンと組み合わせるのが現実的です。Lombok の @With を使う選択肢もありますが、依存の追加が許容できるか次第です。

シリアライズによるディープコピーの性能はどのくらい遅いですか。

コピーコンストラクタの100〜1,000倍遅いことがあります。テストコードやバッチの初期化など、頻度の低い場面に限定するのが安全です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます