java-recipes

ホーム コピーパターン › Cop-03

Cop-03: コピー実装パターン

実務でよく使われるコピー実装パターンをまとめます。 コピーコンストラクタ・record の with パターン・シリアライズによるディープコピーをそれぞれの特徴と一緒に紹介します。

3つのコピー実装パターン

Java には標準で clone() メソッドが用意されていますが、設計上の問題点が多く現在は推奨されていません。 代わりに以下の3つのパターンが広く使われています。

各パターンの比較

コピーコンストラクタ(推奨)

new Employee(other) のように、 同じクラスのインスタンスを引数に取るコンストラクタを定義します。 何をコピーするかが明示的で、ネストしたオブジェクトも再帰的にコピーできます。最も安全で推奨されるパターンです。

record の with パターン(Java 17+)

record は不変なため「変更」は新しいインスタンスの作成を意味します。withName() のような メソッドを定義することで、一部フィールドだけ変えた新しい record を作れます。

シリアライズによるディープコピー

オブジェクトをバイト列にシリアライズして、そこからデシリアライズすることで深いコピーを得ます。 全フィールドが自動的にコピーされますが、クラスがSerializable を実装している必要があり、 パフォーマンスも劣ります。

サンプルコード

CopyPatternSampleAdv.java
import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class CopyPatternSampleAdv {

    // パターン 1: コピーコンストラクタ(推奨)
    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;
        }

        // ✅ パターン 1: コピーコンストラクタ
        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 + "}";
        }
    }

    // パターン 2: シリアライズによるディープコピー(全フィールドを自動コピー)
    @SuppressWarnings("unchecked")
    static <T extends Serializable> T deepCopyBySerialization(T original) {
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
                oos.writeObject(original);
            }
            try (ObjectInputStream ois = new ObjectInputStream(
                    new ByteArrayInputStream(baos.toByteArray()))) {
                return (T) ois.readObject();
            }
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException("ディープコピー失敗", e);
        }
    }

    public static void main(String[] args) {
        Employee original = new Employee(
                "田中太郎", 30,
                new Address("東京都", "渋谷区"),
                new ArrayList<>(Arrays.asList("Java", "Python")));
        System.out.println("元: " + original);

        // パターン 1: コピーコンストラクタ
        System.out.println("\n=== パターン 1: コピーコンストラクタ ===");
        Employee copy1 = new Employee(original);
        copy1.name = "鈴木次郎";
        copy1.address.city = "新宿区";
        copy1.skills.add("Go");
        System.out.println("元(変化なし): " + original);
        System.out.println("コピー1:       " + copy1);

        // パターン 2: シリアライズによるディープコピー
        System.out.println("\n=== パターン 2: シリアライズによるディープコピー ===");
        Employee copy2 = deepCopyBySerialization(original);
        copy2.name = "佐藤三郎";
        copy2.address.prefecture = "大阪府";
        System.out.println("元(変化なし): " + original);
        System.out.println("コピー2(シリアライズ): " + copy2);
    }
}

Java 8 ではコピーコンストラクタが最も推奨されるパターンです。ネストしたオブジェクトも再帰的に new で作ることで完全なディープコピーになります。シリアライズによるコピーは実装が簡単ですが、クラスに Serializable の実装が必要で、パフォーマンスも劣るため通常はコピーコンストラクタを使います。

よくあるミス・注意点

⚠️ コピーコンストラクタでネストしたオブジェクトをコピーし忘れる

コピーコンストラクタでthis.address = other.address;のように代入するだけだと、address オブジェクトは共有されたままです。 ネストしたオブジェクトもthis.address = new Address(other.address);のように新規作成する必要があります。

⚠️ シリアライズコピーは transient フィールドがコピーされない

シリアライズによるコピーでは、transient 修飾子が付いたフィールドはシリアライズされないため、 コピー後は null(またはデフォルト値)になります。 また static フィールドもシリアライズされません。重要なフィールドを transient にしていないか確認しましょう。

⚠️ record のコンパクトコンストラクタで List の防御コピーを忘れる

record のコンポーネントに List などの可変オブジェクトを含む場合、 コンパクトコンストラクタでskills = List.copyOf(skills);のように防御コピーを行わないと、外部から渡したリストを後から変更することで record の内容を書き換えられてしまいます。

テストする観点

  • コピーコンストラクタでコピーした後、コピー先の name を変更しても元の name が変わらないこと
  • コピーコンストラクタでコピーした後、コピー先の address.city を変更しても元の address.city が変わらないこと(ネストされたオブジェクト)
  • コピーコンストラクタでコピーした後、コピー先の skills に要素を追加しても元の skills が変わらないこと
  • シリアライズコピーした後に元のオブジェクトを変更しても、コピー先に影響しないこと
  • record の withName() を呼び出した後、元の record の name が変わらないこと(境界値)
  • 空のスキルリスト(サイズ 0)を持つオブジェクトをコピーしても例外が発生しないこと(境界値)

GitHub でソースコードを見る →