はじめに (対象読者・この記事でわかること)
この記事は、Javaにおけるオブジェクトのコピー、特にclone()メソッドの挙動に疑問を感じているプログラマの方、HashMapをメンバとして持つクラスのコピーで予期せぬ動作に直面している方を対象としています。
この記事を読むことで、Javaのclone()メソッドがデフォルトでシャローコピーであること、その結果HashMapのような参照型フィールドがどのようにコピーされるかを深く理解できます。さらに、HashMapを含むオブジェクトを安全にディープコピーするための具体的な実装方法と注意点までを習得し、実際の開発で発生しがちなコピー問題に対する解決策を身につけることができます。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
* Javaの基本的な文法とオブジェクト指向プログラミングの概念
* クラス、インスタンス、参照型の基本的な理解
* HashMapの基本的な使い方
Javaにおけるオブジェクトのコピー:シャローコピーとディープコピー
Javaでオブジェクトのコピーを考える際、まず理解すべきは「シャローコピー(浅いコピー)」と「ディープコピー(深いコピー)」の違いです。これはclone()メソッドを使用する上で非常に重要な概念となります。
シャローコピーのメカニズムとclone()メソッドの限界
JavaのObjectクラスが提供するclone()メソッドは、デフォルトでシャローコピーを実行します。これは具体的に以下のことを意味します。
- プリミティブ型フィールド:
int,double,booleanなどのプリミティブ型フィールドは、その値が新しいオブジェクトに直接コピーされます。これは直感的なコピー動作です。 - 参照型フィールド:
String,List,HashMap、あるいは自作のクラスインスタンスなどの参照型フィールドは、参照先オブジェクト自体がコピーされるわけではなく、その参照(メモリアドレス)が新しいオブジェクトにコピーされます。 結果として、元のオブジェクトとコピーされたオブジェクトは、同じ参照型オブジェクトを共有することになります。
このシャローコピーの性質が、HashMapをメンバに持つオブジェクトをclone()した際に問題を引き起こします。例えば、MyDataというクラスがHashMap<String, String>をメンバとして持っているとします。myOriginalDataをclone()してmyClonedDataを作成した場合、myOriginalData.getMap()とmyClonedData.getMap()は異なるHashMapインスタンスを参照しますが、それらのHashMapインスタンスが持つ値オブジェクトの参照は、元のHashMapとコピー先のHashMapで同じものを指し続けてしまう可能性があります。特に、HashMapの値がさらに参照型オブジェクトである場合は、この問題が顕著になります。
HashMapを含むオブジェクトのディープコピー実装
clone()メソッドを使ってHashMapを含むオブジェクトを完全にディープコピーするには、clone()メソッドのオーバーライド時に、参照型フィールド(特にHashMap)に対して手動でディープコピーのロジックを記述する必要があります。
ステップ1:シャローコピーの問題を再現する
まず、HashMapをメンバに持つクラスを作成し、clone()メソッドをデフォルトのシャローコピーのまま実装した場合に何が起こるかを確認してみましょう。
Javaimport java.util.HashMap; import java.util.Map; // HashMapの値となるオブジェクト (ここではシンプルにStringとするが、参照型の場合に問題となる) class ValueObject { private String data; public ValueObject(String data) { this.data = data; } public String getData() { return data; } public void setData(String data) { this.data = data; } @Override public String toString() { return "ValueObject{" + "data='" + data + '\'' + '}'; } } // HashMapをメンバに持つクラス class MyDataObject implements Cloneable { private String name; private Map<String, ValueObject> configMap; // HashMapをメンバに持つ public MyDataObject(String name) { this.name = name; this.configMap = new HashMap<>(); } public String getName() { return name; } public Map<String, ValueObject> getConfigMap() { return configMap; } // デフォルトのclone() (シャローコピー) を実装 @Override public MyDataObject clone() { try { return (MyDataObject) super.clone(); // ここでシャローコピーが行われる } catch (CloneNotSupportedException e) { throw new AssertionError(); // Cloneableを実装しているので発生しないはず } } @Override public String toString() { return "MyDataObject{" + "name='" + name + '\'' + ", configMap=" + configMap + '}'; } } public class ShallowCopyDemo { public static void main(String[] args) { // 元のオブジェクトを作成 MyDataObject original = new MyDataObject("Original Object"); original.getConfigMap().put("key1", new ValueObject("Initial Value 1")); original.getConfigMap().put("key2", new ValueObject("Initial Value 2")); System.out.println("--- Original Object ---"); System.out.println(original); // MyDataObject{name='Original Object', configMap={key1=ValueObject{data='Initial Value 1'}, key2=ValueObject{data='Initial Value 2'}}} // シャローコピーを実行 MyDataObject cloned = original.clone(); System.out.println("\n--- Cloned Object (Shallow Copy) ---"); System.out.println(cloned); // MyDataObject{name='Original Object', configMap={key1=ValueObject{data='Initial Value 1'}, key2=ValueObject{data='Initial Value 2'}}} // クローンしたオブジェクトの名前を変更 (プリミティブ型/Stringは問題なし) cloned.name = "Cloned Object"; System.out.println("\n--- After modifying cloned object's name ---"); System.out.println("Original: " + original.getName()); // Original: Original Object System.out.println("Cloned: " + cloned.getName()); // Cloned: Cloned Object // ここが問題:クローンしたオブジェクトのHashMapに要素を追加してみる // この操作は両方のオブジェクトに影響を与えない (HashMapインスタンス自体は別) cloned.getConfigMap().put("key3", new ValueObject("Cloned New Value")); System.out.println("\n--- After adding item to cloned object's HashMap ---"); System.out.println("Original configMap: " + original.getConfigMap()); // {key1=ValueObject{data='Initial Value 1'}, key2=ValueObject{data='Initial Value 2'}} System.out.println("Cloned configMap: " + cloned.getConfigMap()); // {key1=ValueObject{data='Initial Value 1'}, key2=ValueObject{data='Initial Value 2'}, key3=ValueObject{data='Cloned New Value'}} // しかし、HashMapの「中身」の参照を書き換えてみる // "key1"のValueObjectを変更するとどうなるか cloned.getConfigMap().get("key1").setData("Modified by Cloned!"); System.out.println("\n--- After modifying a ValueObject in cloned object's HashMap ---"); System.out.println("Original configMap: " + original.getConfigMap()); // {key1=ValueObject{data='Modified by Cloned!'}, key2=ValueObject{data='Initial Value 2'}} System.out.println("Cloned configMap: " + cloned.getConfigMap()); // {key1=ValueObject{data='Modified by Cloned!'}, key2=ValueObject{data='Initial Value 2'}, key3=ValueObject{data='Cloned New Value'}} // 結果として、元のオブジェクトのHashMap内のValueObjectも変更されてしまった! System.out.println("\nOriginal's 'key1' Value: " + original.getConfigMap().get("key1").getData()); // Modified by Cloned! System.out.println("Cloned's 'key1' Value: " + cloned.getConfigMap().get("key1").getData()); // Modified by Cloned! } }
上記の出力を見ると、cloned.getConfigMap().get("key1").setData("Modified by Cloned!"); を実行した結果、original.getConfigMap().get("key1") のデータも変更されてしまっていることが分かります。これは、configMap自体は新しいインスタンスにコピーされましたが、そのHashMapが内部に保持しているValueObjectインスタンスの参照は、元のHashMapとコピー先のHashMapで同じものを指し続けていたためです。これがシャローコピーの典型的な問題点です。
ステップ2:HashMapを安全にディープコピーする実装
この問題を解決するには、clone()メソッド内でHashMapフィールドも手動でディープコピーする必要があります。さらに、HashMapのキーや値が参照型オブジェクトである場合、それらのオブジェクト自身もディープコピーが必要です。
ValueObjectクラスをCloneableにする必要があります。
Javaimport java.util.HashMap; import java.util.Map; // HashMapの値となるオブジェクト。Cloneableを実装する。 class ValueObject implements Cloneable { private String data; public ValueObject(String data) { this.data = data; } public String getData() { return data; } public void setData(String data) { this.data = data; } @Override public ValueObject clone() { try { return (ValueObject) super.clone(); // ここではStringは不変なのでシャローコピーでも問題ないが、 // 参照型フィールドを持つ場合はここでもディープコピーが必要 } catch (CloneNotSupportedException e) { throw new AssertionError(); } } @Override public String toString() { return "ValueObject{" + "data='" + data + '\'' + '}'; } } // HashMapをメンバに持つクラス class MyDataObject implements Cloneable { private String name; private Map<String, ValueObject> configMap; public MyDataObject(String name) { this.name = name; this.configMap = new HashMap<>(); } public String getName() { return name; } public Map<String, ValueObject> getConfigMap() { return configMap; } // ディープコピーを実装したclone()メソッド @Override public MyDataObject clone() { try { MyDataObject cloned = (MyDataObject) super.clone(); // まずは親クラスのシャローコピーを実行 // configMapのディープコピー cloned.configMap = new HashMap<>(); // 新しいHashMapインスタンスを作成 for (Map.Entry<String, ValueObject> entry : this.configMap.entrySet()) { // キーがStringであればimmutableなのでそのままコピーでOK // 値がValueObject(参照型)なので、そのValueObject自身もclone()する cloned.configMap.put(entry.getKey(), entry.getValue().clone()); } return cloned; } catch (CloneNotSupportedException e) { throw new AssertionError(); } } @Override public String toString() { return "MyDataObject{" + "name='" + name + '\'' + ", configMap=" + configMap + '}'; } } public class DeepCopyDemo { public static void main(String[] args) { // 元のオブジェクトを作成 MyDataObject original = new MyDataObject("Original Object"); original.getConfigMap().put("key1", new ValueObject("Initial Value 1")); original.getConfigMap().put("key2", new ValueObject("Initial Value 2")); System.out.println("--- Original Object ---"); System.out.println(original); // ディープコピーを実行 MyDataObject cloned = original.clone(); System.out.println("\n--- Cloned Object (Deep Copy) ---"); System.out.println(cloned); // クローンしたオブジェクトの名前を変更 cloned.name = "Cloned Object"; // クローンしたオブジェクトのHashMapに要素を追加 cloned.getConfigMap().put("key3", new ValueObject("Cloned New Value")); // クローンしたオブジェクトのHashMapの中身を書き換える cloned.getConfigMap().get("key1").setData("Modified by Cloned!"); System.out.println("\n--- After modifications to cloned object ---"); System.out.println("Original: " + original); System.out.println("Cloned: " + cloned); // 変更が元のオブジェクトに影響しないことを確認 System.out.println("\nOriginal's 'key1' Value: " + original.getConfigMap().get("key1").getData()); // Initial Value 1 (変更されていない!) System.out.println("Cloned's 'key1' Value: " + cloned.getConfigMap().get("key1").getData()); // Modified by Cloned! } }
上記のDeepCopyDemoの実行結果では、cloned.getConfigMap().get("key1").setData("Modified by Cloned!"); を実行しても、originalオブジェクトのconfigMap内のValueObjectは変更されずに「Initial Value 1」のままとなっています。これは、clone()メソッド内でconfigMapだけでなく、そのconfigMapが保持するValueObjectもclone()して新しいインスタンスを生成したためです。
ハマった点やエラー解決
clone()メソッドを使ったディープコピー実装でよくハマる点とその解決策をいくつか紹介します。
-
CloneNotSupportedExceptionの発生:- 原因:
Cloneableインターフェースを実装していないクラスでclone()メソッドを呼び出した場合や、super.clone()を呼び出す前にCloneNotSupportedExceptionをキャッチせずにスローしている場合。 - 解決策: ディープコピーをしたいクラスには必ず
Cloneableインターフェースを実装してください。また、Object.clone()はCloneNotSupportedExceptionをスローする可能性があるため、オーバーライドするclone()メソッド内で適切にtry-catchブロックで処理するか、AssertionErrorなどにラップしてスローし直す必要があります(Cloneableを実装していれば通常は発生しないため)。
- 原因:
-
HashMapのclone()だけで安心してしまう:- 原因:
HashMapクラス自体にもclone()メソッドが存在するため、this.myMap = (HashMap<K, V>) this.myMap.clone();のように記述すればディープコピーになると誤解してしまうことがあります。しかし、HashMapのclone()はHashMapインスタンス自体は新しくしますが、そのHashMapが保持する要素の参照は元のままコピーされます(シャローコピー)。 - 解決策:
HashMap内の各エントリ(キーと値)を個別に取得し、それぞれをディープコピー(参照型であればそのオブジェクトのclone()を呼び出すなど)して新しいHashMapに詰め直す必要があります。
- 原因:
-
ネストされた参照型オブジェクトのディープコピー忘れ:
- 原因:
HashMapの値がさらに複雑な参照型オブジェクトである場合、その「値オブジェクト」自身もCloneableを実装し、そのclone()メソッドをオーバーライドしてディープコピーロジックを記述しないと、そこから先がシャローコピーになってしまいます。 - 解決策: ディープコピーの連鎖を意識し、オブジェクトグラフの末端まで、参照型フィールドはすべて
clone()または別のディープコピー手法(コピーコンストラクタ、シリアライゼーションなど)で処理するように設計する必要があります。
- 原因:
解決策
上記の「ステップ2」で示したように、HashMapを含むオブジェクトをディープコピーする最も確実な方法は、親クラスのclone()でシャローコピーを行った後、参照型フィールドに対して手動でディープコピーのロジックを追加することです。
具体的には、HashMapフィールドに対しては以下の手順を踏みます。
- 新しい空の
HashMapインスタンスを作成します。 - 元のオブジェクトの
HashMapの各エントリをイテレートします。 - 各エントリのキーと値について、以下のように処理します。
- キーが
Stringやプリミティブのラッパークラスなど、不変(immutable)なオブジェクトであれば、そのまま新しいHashMapにputして問題ありません。 - キーが可変(mutable)な参照型オブジェクトであれば、そのキーオブジェクト自身も
clone()するか、新しいインスタンスを作成してコピーする必要があります。 - 値が
Stringやプリミティブのラッパークラスなど不変なオブジェクトであれば、そのまま新しいHashMapにputして問題ありません。 - 値が可変な参照型オブジェクトであれば、その値オブジェクト自身も
clone()(その値クラスがCloneableを実装している場合)するか、コピーコンストラクタなどで新しいインスタンスを作成してコピーし、新しいHashMapにputする必要があります。
- キーが
この手法により、オブジェクトグラフ内のすべての参照が新しいオブジェクトを指すようになり、元のオブジェクトと完全に独立したコピーが作成されます。
より複雑なケースや、Cloneableの実装が難しい場合には、以下の代替案も検討できます。
- コピーコンストラクタ: オブジェクトのディープコピーのためだけに専用のコンストラクタを用意し、その中で各フィールドをディープコピーする。
- シリアライゼーション: オブジェクトをバイトストリームにシリアライズし、それをデシリアライズすることで完全に新しいオブジェクトグラフを生成する。ただし、対象のオブジェクトとその内部の全オブジェクトが
Serializableインターフェースを実装している必要があります。
しかし、clone()メソッドのテーマに沿って最も直接的に問題を解決するには、上記の手動によるディープコピーロジックの実装が適切です。
まとめ
本記事では、Javaのclone()メソッドがHashMapのような参照型メンバに対してデフォルトでシャローコピーを行うことで生じる問題と、それを解決するためのディープコピー実装方法について解説しました。
clone()はデフォルトでシャローコピーであり、プリミティブ型は値がコピーされる一方、参照型は参照(メモリアドレス)のみがコピーされるため、元のオブジェクトとコピーが同じ内部オブジェクトを共有する危険性があることを理解しました。HashMapをメンバに持つオブジェクトをディープコピーするには、clone()メソッド内でHashMap自体を新しいインスタンスに再構築し、そのHashMapが保持する各エントリ(特に参照型のキーや値)も個別にディープコピーする必要があることを具体的なコード例で示しました。- 実装中に遭遇しがちな
CloneNotSupportedExceptionやHashMap.clone()の挙動に関する誤解、ネストされた参照型オブジェクトのディープコピー忘れといったハマりポイントとその解決策を確認しました。
この記事を通して、Javaにおけるclone()の挙動を正しく理解し、HashMapを含む複雑なオブジェクトのディープコピーを安全に、そして意図通りに実装できるようになったことでしょう。
今後は、コピーコンストラクタやシリアライゼーションを用いた、より汎用的なディープコピー手法についても記事にする予定です。
参考資料
- Java Platform, Standard Edition & Java Development Kit Documentation
- Object (Java SE 11 & JDK 11 ) - clone() method
- HashMap (Java SE 11 & JDK 11)
- 【Java】ディープコピーとシャローコピーを徹底解説! (一般的な技術記事)
