C#を用いたアプリケーション開発において、オブジェクトの複製は頻繁に発生する操作の一つです。

しかし、単純な代入や標準的な MemberwiseClone メソッドでは、参照型のフィールドが参照先を共有してしまう「シャローコピー(浅いコピー)」が実行されます。

これに対し、オブジェクトの階層構造をすべて辿り、完全に独立したインスタンスを生成するのが ディープコピー(深いコピー) です。

本記事では、C#におけるディープコピーの具体的な実装手法から、それぞれのパフォーマンス特性、プロジェクトに最適な手法の選び方までをプロフェッショナルな視点で徹底解説します。

ディープコピーとシャローコピーの違い

ディープコピーの実装方法を詳しく見る前に、まずはシャローコピーとの本質的な違いを正確に理解しておく必要があります。

シャローコピーの挙動とリスク

シャローコピーは、オブジェクトのトップレベルのプロパティやフィールドのみをコピーします。

値型(int, double, boolなど)は値そのものがコピーされますが、参照型(クラス、配列など)は メモリアドレス(参照)のみがコピー されます。

その結果、コピー元のオブジェクトでリストや別のクラスのインスタンスを変更すると、コピー先のオブジェクトにもその変更が反映されてしまいます。

これは意図しないサイドエフェクト(副作用)を引き起こし、バグの温床となるため注意が必要です。

ディープコピーが必要なシーン

ディープコピーは、元のオブジェクトの状態を完全に保存(スナップショット)したい場合や、マルチスレッド環境で各スレッドに独立したデータを持たせたい場合に不可欠です。

参照先のオブジェクトまですべて再帰的に複製するため、コピー元とコピー先がメモリ上で完全に分離された状態 になります。

手法1:手動実装(インターフェースとコピーコンストラクタ)

最も基本的かつ古典的な手法は、クラスごとにコピーロジックを手動で記述する方法です。

ICloneableインターフェースの利用

.NETには ICloneable インターフェースが用意されていますが、これには「戻り値が object 型であること」や「シャローかディープかが定義されていない」という欠点があります。

そのため、現代のC#開発では、独自の型安全なメソッドを定義することが推奨されます。

コピーコンストラクタによる実装

コピーコンストラクタは、自分自身の型を引数に取るコンストラクタを定義し、フィールドを一つずつコピーする方法です。

C#
using System;
using System.Collections.Generic;

public class Address
{
    public string City { get; set; }

    public Address(string city) => City = city;

    // Addressクラスのディープコピー用コンストラクタ
    public Address(Address other)
    {
        this.City = other.City;
    }
}

public class Person
{
    public string Name { get; set; }
    public Address HomeAddress { get; set; }

    public Person(string name, Address address)
    {
        Name = name;
        HomeAddress = address;
    }

    // Personクラスのコピーコンストラクタ
    public Person(Person other)
    {
        this.Name = other.Name;
        // 参照型は新しいインスタンスを生成してコピー(ディープコピー)
        this.HomeAddress = new Address(other.HomeAddress);
    }

    public void Display() => Console.WriteLine($"Name: {Name}, City: {HomeAddress.City}");
}

class Program
{
    static void Main()
    {
        var p1 = new Person("田中", new Address("東京"));
        var p2 = new Person(p1); // ディープコピー実行

        p2.HomeAddress.City = "大阪";

        Console.WriteLine("--- p1の状態 ---");
        p1.Display();
        Console.WriteLine("--- p2の状態 ---");
        p2.Display();
    }
}
実行結果
--- p1の状態 ---
Name: 田中, City: 東京
--- p2の状態 ---
Name: 田中, City: 大阪

メリットとデメリット

この手法の最大のメリットは、実行速度が極めて高速 である点です。

リフレクションやシリアライズを介さないため、オーバーヘッドが最小限に抑えられます。

一方、クラスの構造が複雑になったり、フィールドが増えたりするたびにコピーロジックを修正する必要があるため、メンテナンスコストが高い という大きなデメリットがあります。

手法2:JSONシリアライゼーション(System.Text.Json)

現在のC#開発において、最も手軽で汎用性が高いのがシリアライゼーションを利用した手法です。

オブジェクトを一度文字列(またはバイト列)に変換し、それを新しいオブジェクトとして復元することで、強制的にディープコピーを実現します。

System.Text.Jsonによる実装

.NET Core 3.0以降、標準搭載された System.Text.Json を使用するのが一般的です。

C#
using System;
using System.Text.Json;

public static class ObjectExtensions
{
    public static T DeepCopyByJson<T>(this T source)
    {
        if (source == null) return default;
        
        // オブジェクトをJSON文字列に変換
        string json = JsonSerializer.Serialize(source);
        // JSON文字列から新しいオブジェクトを生成
        return JsonSerializer.Deserialize<T>(json);
    }
}

public class Company
{
    public string Name { get; set; }
    public List<string> Departments { get; set; }
}

class Program
{
    static void Main()
    {
        var comp1 = new Company 
        { 
            Name = "TechCorp", 
            Departments = new List<string> { "開発部", "人事部" } 
        };

        // 拡張メソッドでディープコピー
        var comp2 = comp1.DeepCopyByJson();

        comp2.Departments.Add("営業部");

        Console.WriteLine($"comp1の部署数: {comp1.Departments.Count}");
        Console.WriteLine($"comp2の部署数: {comp2.Departments.Count}");
    }
}
実行結果
comp1の部署数: 2
comp2の部署数: 3

メリットとデメリット

この手法のメリットは、コードが非常にシンプル になり、どんなに深い階層構造を持つオブジェクトでも一行でコピーできる点です。

しかし、内部で文字列変換とパース処理が行われるため、大量のオブジェクトを処理する場合にはパフォーマンスが低下 します。

また、シリアライズ対象外のプロパティ(privateメンバや、JsonIgnore属性が付いたもの)はコピーされないという制約があります。

手法3:リフレクションを用いた汎用実装

動的にオブジェクトの構造を解析する「リフレクション」を用いることで、特定のクラスに依存しない汎用的なディープコピー関数を作成できます。

リフレクションによる実装コード

この手法では、フィールドを再帰的に走査し、参照型を見つけるたびに新しいインスタンスを作成します。

C#
using System;
using System.Reflection;

public static class ReflectionDeepCopier
{
    public static T DeepCopy<T>(T original)
    {
        if (original == null) return default;

        Type type = original.GetType();

        // 値型または文字列の場合はそのまま返す
        if (type.IsValueType || type == typeof(string))
        {
            return original;
        }

        // 配列の場合の処理
        if (type.IsArray)
        {
            Type elementType = type.GetElementType();
            var array = original as Array;
            var arrayCopy = Array.CreateInstance(elementType, array.Length);
            for (int i = 0; i < array.Length; i++)
            {
                arrayCopy.SetValue(DeepCopy(array.GetValue(i)), i);
            }
            return (T)Convert.ChangeType(arrayCopy, type);
        }

        // インスタンスの生成
        object copy = Activator.CreateInstance(type);

        // すべてのフィールドをコピー(非公開フィールドを含む)
        FieldInfo[] fields = type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
        foreach (var field in fields)
        {
            object fieldValue = field.GetValue(original);
            if (fieldValue == null) continue;
            field.SetValue(copy, DeepCopy(fieldValue));
        }

        return (T)copy;
    }
}

メリットとデメリット

リフレクション方式は、属性の付与や特定のインターフェース実装が不要 であるため、既存のコードを変更せずに導入できる柔軟性があります。

ただし、実行時に型の解析を行うため、コピーコンストラクタよりも低速です。

また、循環参照(オブジェクトAがBを持ち、BがAを持つ状態)がある場合、無限ループに陥るリスクがあるため、実際の運用では「コピー済みオブジェクトのキャッシュ」を辞書等で管理するなどの工夫が必要になります。

手法4:式ツリー(Expression Trees)による高速化

リフレクションの柔軟性と、コンパイル済みコードの高速性を両立させる手法が、式ツリー(Expression Trees) を用いた動的コード生成です。

式ツリーの仕組み

式ツリーを使用すると、実行時に「特定のクラスをディープコピーするためのロジック」を組み立て、それをメモリ上でコンパイルしてデリゲート(関数ポインタ)としてキャッシュできます。

初回実行時はコンパイルのオーバーヘッドがありますが、2回目以降はネイティブコードに近い速度で実行されます。

複雑な実装になるため、ここではコンセプトの要約に留めますが、大規模なゲームエンジンや高頻度な通信を行うサーバーサイド処理など、極限のパフォーマンスが求められる環境 ではこの手法が採用されます。

パフォーマンス比較と選択基準

これまで紹介した4つの手法について、パフォーマンスと実装コストの観点から比較表にまとめました。

実装手法実行速度実装・保守の手間汎用性特徴
手動(コピーコンストラクタ)最速非常に高い低い型ごとに書く必要があるが最も安全
JSONシリアライズ低速非常に低い高い実装が最も楽。標準ライブラリで完結
リフレクション低速中程度高い動的に何でもコピーできるが循環参照に注意
式ツリー高速非常に高い高い初回のみ重いが、リピート実行は非常に速い

状況別:最適な手法の選び方

手動実装(コピーコンストラクタ)

小規模なデータ構造や、パフォーマンスが最優先の場合に推奨されます。

コンパイラの最適化が効きやすく、もっとも予測可能な挙動を示します。

実装コストは高いものの、最高の性能と細かな制御を得られます。

JSONシリアライズ(System.Text.Json)

開発スピードを優先し、データ構造が頻繁に変わる場合に最適です。

最新の .NET では高速化が進んでおり、一般的な業務アプリケーションでは十分なケースがほとんどです。

実装が簡潔で保守性が高い反面、ネイティブ実装ほどの最小限レイテンシや細かなコピー制御は期待しにくい点に注意してください。

例: System.Text.Json を用いたシリアライズ→デシリアライズによるディープコピー。

リフレクションまたは式ツリー

フレームワークやライブラリを開発する際に検討すべきアプローチです。

ユーザーが定義する未知の型をディープコピーする必要があるため、動的なアプローチが不可欠となります。

柔軟性と汎用性は高い一方で、実装は複雑になりがちで、パフォーマンス対策(キャッシュやランタイムでのコード生成など)が必要です。

実装時の注意点:循環参照と不変型

ディープコピーを実装する際には、技術的な手法以外にも考慮すべき「罠」がいくつか存在します。

循環参照のハンドリング

グラフ構造を持つオブジェクト(親が子を持ち、子が親を参照している場合など)をコピーすると、単純な再帰アルゴリズムでは StackOverflowException が発生します。

これを防ぐには、Dictionary<object, object> を使用して、一度コピーしたインスタンスを記録し、二度目はキャッシュを返す「参照追跡」の実装が必要です。

record型とディープコピー

C# 9.0で導入された record 型には、with 式という便利なクローン機能があります。

var newRecord = oldRecord with { Name = "New" }; しかし、recordのwith式も「シャローコピー」である ことに注意してください。

record内のリストなどは参照が共有されます。

recordを使っているからといってディープコピーが自動で行われるわけではありません。

不変(Immutable)オブジェクトの活用

ディープコピーの必要性そのものを減らすアプローチとして、イミュータブルな設計 があります。

オブジェクトの状態を変更できなくすれば、参照を共有しても安全であり、高コストなディープコピーを行う必要がなくなります。

まとめ

C#におけるディープコピーは、単一の正解があるわけではなく、用途に応じたトレードオフの選択が重要です。

  • 確実性と速度 を求めるなら「手動実装」
  • 手軽さとメンテナンス性 を求めるなら「JSONシリアライズ」
  • 汎用性と自動化 を求めるなら「リフレクション」や「式ツリー」

特に現代の .NET 開発では、まずは System.Text.Json によるシリアライズを試し、パフォーマンス上のボトルネックが顕在化した箇所のみ、特定のクラスに対して手動のコピーロジックを実装するというハイブリッドな戦略が最も効率的です。

オブジェクトのライフサイクルと参照関係を正しく把握し、アプリケーションの要件に最適なディープコピー手法を選択しましょう。