C# でオブジェクトをコピーするときや比較するときに、単に = や == 演算子を使うと**参照のコピー(参照の比較)**になります。「コピーしたはずなのに、片方を変更したらもう片方も変わってしまった」という経験がある方も多いのではないでしょうか。
この記事では、内部の実体まで複製する DeepCopy と、中身の値どうしを比較する DeepCompare を、JSON シリアライズを使って数行で実装する方法を記載します。
そもそも何が起きるのか(参照コピーの罠)
クラスのインスタンスを = で代入しても、コピーされるのは「同じ実体を指す参照」だけです。
var original = new Setting { Name = "設定A", Count = 1 };
var copy = original; // 参照のコピー
copy.Count = 999;
Console.WriteLine(original.Count); // → 999(元まで変わってしまう)
私がこれで痛い目を見たのは、設定編集ダイアログです。編集用に設定オブジェクトを「コピー」して渡したつもりが参照コピーだったため、ユーザーがキャンセルしたのに元の設定が書き換わっている、というバグになりました。編集用に渡すオブジェクトは、実体ごと複製した「別物」でなければいけません。
下準備
以前の記事のシリアライズ関数を用意します。System.Text.Json を使った、オブジェクト⇔JSON 文字列の変換です。
using System.Text.Json;
public static string SerializeJson<T>(T obj)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
return JsonSerializer.Serialize(obj, jsonOptions);
}
public static T DeserializeJson<T>(string jsonString)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
return JsonSerializer.Deserialize<T>(jsonString, jsonOptions);
}
オブジェクトコピー(DeepCopy)
public static void DeepCopy<T>(T src, out T dst)
{
string json = SerializeJson(src);
dst = DeserializeJson<T>(json);
}
一度 JSON 文字列に変換してから復元することで、元のオブジェクトとは完全に独立した複製が手に入ります。文字列を経由するので、ネストしたクラスやリストの中身まで丸ごと複製されるのがポイントです。
DeepCopy(original, out Setting copy);
copy.Count = 999;
Console.WriteLine(original.Count); // → 1(元は変わらない)
オブジェクト比較(DeepCompare)
public static bool DeepCompare<T>(T obj1, T obj2)
{
string json1 = SerializeJson(obj1);
string json2 = SerializeJson(obj2);
return json1 == json2;
}
両方を JSON 文字列にして、文字列どうしを比較しています。参照が別のインスタンスでも、中身の値がすべて同じなら true になります。
これも設定ダイアログと相性がよく、「開いたときの状態」と「今の状態」を DeepCompare して、差があるときだけ『保存しますか?』の確認を出す、といった使い方ができます。
ハマりポイント:コピー・比較されるのは public プロパティだけ
この方式は JSON に変換できたものしか複製・比較できません。System.Text.Json の既定では、シリアライズ対象は public なプロパティのみです。
- private フィールドや public フィールドはコピーされない(フィールドは既定で対象外)
- getter しかないプロパティは、復元時に値を書き戻せない
[JsonIgnore]を付けたプロパティは当然コピー・比較から外れる
「DeepCopy したのに一部の値が初期値に戻っている」というときは、そのメンバーがシリアライズ対象になっているかを疑ってください。フィールドも対象にしたい場合は JsonSerializerOptions の IncludeFields = true で含められます。
そのほかの注意点も挙げておきます。
- 循環参照があると例外になる(親子で互いに参照し合うモデルなど)。
ReferenceHandler.Preserveの設定が必要 - 性能は控えめ。文字列化を経由するぶん、手書きのコピーより遅い。設定オブジェクト程度なら全く問題ないが、ループ内で大量に呼ぶ用途には向かない
代替手段:record 型という選択肢
C# 9.0 以降なら、そもそもクラスの代わりに record 型を使う手もあります。record は with 式で複製が作れて、== が最初から値の比較になります。
public record Setting(string Name, int Count);
var a = new Setting("設定A", 1);
var b = a with { }; // 複製(シャローコピー)
Console.WriteLine(a == b); // → true(値で比較される)
ただし with はシャローコピーなので、ネストした参照型のメンバーまでは複製されません。深い階層を持つオブジェクトを丸ごと複製したい場合は、今回の JSON 方式のほうが手軽です。
まとめ
=は参照コピー、==は参照比較。「コピーしたのに元が変わる」のはこれが原因- JSON シリアライズを経由すれば、DeepCopy も DeepCompare も数行で実装できる
- コピー・比較されるのは public プロパティだけ(フィールド・private は対象外・最重要)
- 循環参照は例外になる。性能が要る場面には向かない
- 浅い階層なら record 型の
with式と値比較も検討する
シリアライズ関数の流用でコピーも比較も片付くお手軽な方法ですが、「何がシリアライズされるか」を意識していないと静かに値が抜け落ちます。同じところで詰まった方の参考になれば幸いです。