C#を用いたアプリケーション開発において、リスト構造(List<T>)のデータを別の変数にコピーする操作は非常に頻繁に発生します。
しかし、単純に代入演算子(=)を使用するだけでは、期待通りに「実体」がコピーされず、思わぬバグを引き起こす原因となります。
特に、参照型を扱う際の「浅いコピー(Shallow Copy)」と「深いコピー(Deep Copy)」の違いを理解することは、堅牢なプログラムを書く上で避けては通れない道です。
本記事では、C#でList<T>をコピーするためのさまざまな手法を、初心者から中級者向けに徹底解説します。
最新のC#構文から、パフォーマンスを重視した方法、そして複雑なオブジェクト構造を完全に複製する方法まで、具体的なソースコードを交えて詳しく見ていきましょう。
C#におけるListコピーの基本概念
C#でリストをコピーする際、まず理解しなければならないのが「参照のコピー」と「インスタンスのコピー」の違いです。
代入演算子による挙動(参照のコピー)
C#のList<T>はクラスであり、参照型です。
そのため、以下のように単純に代入を行うと、データのコピーではなく「メモリ上の場所(参照)」が共有されるだけになります。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<string> original = new List<string> { "Apple", "Banana" };
// 参照をコピーしているだけ
List<string> copy = original;
copy.Add("Cherry");
Console.WriteLine($"Original count: {original.Count}");
Console.WriteLine($"Copy count: {copy.Count}");
}
}
Original count: 3
Copy count: 3
この結果からわかる通り、copyを変更するとoriginalも変更されます。
これは両者が同じメモリ領域を指しているためです。
これを避けるためには、新しいList<T>のインスタンスを作成する必要があります。
浅いコピー(Shallow Copy)の実装方法
浅いコピーとは、新しいリストのインスタンスを作成し、そこに元の要素をコピーすることを指します。
要素が値型(intやboolなど)や不変型(stringなど)であれば、この方法で十分です。
1. コンストラクタを使用する方法
最も一般的でパフォーマンスも良好な方法が、List<T>のコンストラクタに元のリストを渡す方法です。
List<int> original = new List<int> { 1, 2, 3 };
List<int> copy = new List<int>(original);
この方法は非常にシンプルで、リストの容量(Capacity)が適切に確保されるため効率的です。
2. LINQのToListメソッドを使用する方法
LINQ(Language Integrated Query)のToList()拡張メソッドを使用する方法も非常に一般的です。
using System.Linq;
List<int> original = new List<int> { 10, 20, 30 };
List<int> copy = original.ToList();
using System.Linq;が必要ですが、コードが直感的で読みやすいというメリットがあります。
ただし、コンストラクタを直接呼ぶよりもわずかにオーバーヘッドが発生する場合がありますが、通常は無視できる範囲です。
3. コレクション式(C# 12 以降)
最新のC# 12からは、コレクション式(Collection Expressions)という新しい構文が導入されました。
非常に簡潔にコピーを作成できます。
List<int> original = new List<int> { 1, 2, 3 };
// スプレッド演算子「..」を使用
List<int> copy = [.. original];
この記法は今後C#の標準的な書き方になっていくと考えられます。
可読性が高く、配列や他のコレクションへの変換も容易です。
4. List.AddRangeメソッドを使用する方法
既存の空のリストに対して要素を追加したい場合は、AddRangeを使用します。
List<int> original = new List<int> { 1, 2, 3 };
List<int> copy = new List<int>();
copy.AddRange(original);
浅いコピーの限界と注意点
浅いコピーには重大な注意点があります。
それは、リストの要素が参照型(カスタムクラスなど)である場合、要素自体の参照は共有されるという点です。
| コピーの種類 | リストのインスタンス | 要素のインスタンス |
|---|---|---|
| 代入(=) | 共有される | 共有される |
| 浅いコピー | 新しく作成される | 共有される |
| 深いコピー | 新しく作成される | 新しく作成される |
以下のコードで、浅いコピーの問題点を確認してみましょう。
using System;
using System.Collections.Generic;
class Person
{
public string Name { get; set; }
}
class Program
{
static void Main()
{
List<Person> original = new List<Person> { new Person { Name = "Alice" } };
// 浅いコピー
List<Person> copy = new List<Person>(original);
// コピー側の要素のプロパティを書き換える
copy[0].Name = "Bob";
// originalの名前も書き換わってしまう!
Console.WriteLine($"Original[0].Name: {original[0].Name}");
}
}
Original[0].Name: Bob
このように、リストそのものは別物になっても、中身のオブジェクトが同じものを指しているため、一方を変更すると他方にも影響が出ます。
これを解決するのが「深いコピー」です。
深いコピー(Deep Copy)の実装方法
深いコピーとは、リストの要素となっているオブジェクト自体もすべて新しく複製することを指します。
1. 手動でループ回してコピーする方法(確実かつ高速)
最も確実な方法は、ループ内で各オブジェクトの新しいインスタンスを生成することです。
class Person
{
public string Name { get; set; }
// コピー用のメソッドを用意するのが一般的
public Person Clone() => new Person { Name = this.Name };
}
// 実行コード
List<Person> copy = original.Select(p => p.Clone()).ToList();
この方法は、どのプロパティをコピーするかを明示的に制御できるため、パフォーマンスと安全性のバランスが良い手法です。
2. シリアライズを利用する方法(手軽)
オブジェクトを一度シリアライズ(データ化)し、再度デシリアライズすることで、強制的に全く別のインスタンスを作成する方法です。
要素が多い場合にコードが簡潔になります。
System.Text.Json を使用する(推奨)
現代のC#では、標準のSystem.Text.Jsonを使用するのが一般的です。
using System;
using System.Collections.Generic;
using System.Text.Json;
public static class ListExtensions
{
public static List<T> DeepCopy<T>(this List<T> source)
{
// 一度JSON文字列に変換し、再度オブジェクトに戻す
string json = JsonSerializer.Serialize(source);
return JsonSerializer.Deserialize<List<T>>(json);
}
}
注意点: シリアライズによるコピーは、プロパティにget/setが必要であったり、シリアライズ対象外のフィールドがコピーされなかったりする制約があります。
また、リフレクションを利用するため、ループ処理に比べると実行速度は遅くなります。
特殊なケース:読み取り専用リストとしてのコピー
「元のリストを変更されたくないが、コピーを作るコストを抑えたい」という場合は、コピーを作るのではなく「読み取り専用のビュー」を作成することを検討してください。
ReadOnlyCollection
AsReadOnly()メソッドを使用すると、元のリストをラップした読み取り専用のラッパーを取得できます。
List<int> original = new List<int> { 1, 2, 3 };
var readOnlyCopy = original.AsReadOnly();
// readOnlyCopy.Add(4); // コンパイルエラーまたは実行時エラー
ただし、これは「コピー」ではなく「覗き窓」であるため、originalを変更するとreadOnlyCopyの内容も変わって見えることに注意が必要です。
パフォーマンスの比較と使い分け
コピー手法を選択する際の基準を以下の表にまとめました。
| 手法 | 適したケース | 速度 | メモ |
|---|---|---|---|
new List<T>(orig) | 値型・不変型のリスト | 最速 | 最も標準的な選択 |
[.. orig] | モダンな記述をしたい場合 | 高速 | C# 12以降の推奨 |
ToList() | LINQ操作のついでにコピー | 普通 | 可読性が高い |
Select(x => x.Clone()) | 参照型の深いコピー | 中速 | 安全かつ柔軟 |
JsonSerializer | 複雑な階層構造の深いコピー | 低速 | 実装が非常に楽 |
大規模なデータを扱うゲーム開発やリアルタイムシステムでは、JsonSerializerによるコピーは避け、可能な限り構造体(struct)を利用した浅いコピーや、手動でのループ処理を採用すべきです。
実践的なサンプルコード:完全に独立したリストを作る
最後に、参照型のリストを完全に独立させるための実践的なクラス設計の例を紹介します。
using System;
using System.Collections.Generic;
using System.Linq;
public class Employee
{
public int Id { get; set; }
public string Name { get; set; }
public List<string> Skills { get; set; }
// 深いコピーのためのメソッド
public Employee DeepCopy()
{
return new Employee
{
Id = this.Id,
Name = this.Name,
// ネストされたリストもコピーが必要!
Skills = new List<string>(this.Skills)
};
}
}
class Program
{
static void Main()
{
List<Employee> developers = new List<Employee>
{
new Employee { Id = 1, Name = "Alice", Skills = new List<string> { "C#", "SQL" } }
};
// 深いコピーの実行
List<Employee> clonedDevelopers = developers.Select(e => e.DeepCopy()).ToList();
// コピー先を変更しても元には影響しない
clonedDevelopers[0].Skills.Add("Azure");
Console.WriteLine($"Original skills: {string.Join(", ", developers[0].Skills)}");
Console.WriteLine($"Cloned skills: {string.Join(", ", clonedDevelopers[0].Skills)}");
}
}
Original skills: C#, SQL
Cloned skills: C#, SQL, Azure
このように、クラスが内部にさらにリストを持っているような場合は、階層を辿ってすべて新しくインスタンス化する必要があります。
まとめ
C#でList<T>をコピーする方法は多岐にわたりますが、最も重要なのは「今扱っているデータは値型か、それとも参照型か」を正しく判断することです。
- 値型(int, double等)やstringのリストであれば、
new List<T>(original)や[.. original]による浅いコピーで十分です。 - 自作クラスのリストであれば、要素の参照が共有されないよう、ループやシリアライズを用いた深いコピーを検討する必要があります。
- 最新の C# 12 コレクション式 を活用することで、コードの簡潔性を高めることができます。
プログラムの要件(パフォーマンス、メンテナンス性、データの複雑さ)に合わせて、最適なコピー手法を選択してください。
正しいコピー手法を選択することは、バグの混入を防ぎ、予期せぬ副作用のないクリーンなコードを書くための第一歩となります。
