Cop-03: コピー実装パターン
実務でよく使われるコピー実装パターンをまとめます。 コピーコンストラクタ・record の with パターン・シリアライズによるディープコピーをそれぞれの特徴と一緒に紹介します。
3つのコピー実装パターン
Java には標準で clone() メソッドが用意されていますが、設計上の問題点が多く現在は推奨されていません。 代わりに以下の3つのパターンが広く使われています。
各パターンの比較
コピーコンストラクタ(推奨)
new Employee(other) のように、 同じクラスのインスタンスを引数に取るコンストラクタを定義します。 何をコピーするかが明示的で、ネストしたオブジェクトも再帰的にコピーできます。最も安全で推奨されるパターンです。
record の with パターン(Java 17+)
record は不変なため「変更」は新しいインスタンスの作成を意味します。withName() のような メソッドを定義することで、一部フィールドだけ変えた新しい record を作れます。
シリアライズによるディープコピー
オブジェクトをバイト列にシリアライズして、そこからデシリアライズすることで深いコピーを得ます。 全フィールドが自動的にコピーされますが、クラスがSerializable を実装している必要があり、 パフォーマンスも劣ります。
サンプルコード
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)を持つオブジェクトをコピーしても例外が発生しないこと(境界値)