【C#】LinkedListをforeachで安全に操作するコツと逆順列挙の実装例
LinkedList<T>はforeachで直感的に要素を列挙できる反面、ループ中に要素を追加・削除するとInvalidOperationExceptionが発生するため要注意です。
変更が必要なら事前に対象ノードを保存してから操作するか、whileでLastやNextを明示的にたどる方法が安全です。
逆順処理はLastからPreviousを追うと実現できます。
LinkedListとforeachの基礎知識
LinkedList<T>とは
C#のLinkedList<T>は、双方向連結リストを実装したデータ構造です。
これは、各要素(ノード)が前後のノードへの参照を持つことで、リストの先頭や末尾への要素の追加・削除を効率的に行える特徴があります。
LinkedList<T>は、特に頻繁に要素の挿入や削除が発生するシナリオで活躍します。
連結リストの構造
連結リストは、ノードと呼ばれる要素の集合で構成されており、各ノードはデータ本体と、前後のノードを指す参照(リンク)を持っています。
LinkedList<T>の場合は双方向連結リストであるため、各ノードはPreviousとNextという2つの参照を持ちます。
この構造により、リストの先頭や末尾に対する操作は高速に行えます。
例えば、先頭に新しいノードを追加する場合は、現在の先頭ノードのPreviousを新しいノードに設定し、新しいノードのNextを現在の先頭ノードに設定するだけで済みます。
これらの操作は一定時間(O(1))で完了します。
一方で、特定の位置にアクセスする場合は、先頭または末尾から順にノードをたどる必要があるため、ランダムアクセスは遅くなります。
これは配列やList<T>のような動的配列とは異なる点です。
LinkedListとListの違い
LinkedList<T>とList<T>はどちらもコレクションですが、内部構造と用途に違いがあります。
| 特徴 | LinkedList<T> | List<T> |
|---|---|---|
| 内部構造 | 双方向連結リスト | 動的配列 |
| 要素の追加・削除 | 先頭・末尾で高速(O(1)) | 末尾は高速(平均O(1))、先頭や途中は遅い(O(n)) |
| ランダムアクセス | 遅い(O(n)) | 高速(O(1)) |
| メモリ使用量 | ノードごとに前後参照を持つため多い | 配列のため比較的少ない |
| 用途 | 頻繁な挿入・削除が必要な場合に適する | ランダムアクセスや検索が多い場合に適する |
このように、LinkedList<T>は挿入や削除が多いシナリオに向いており、List<T>は高速なランダムアクセスが必要な場合に向いています。
用途に応じて使い分けることが重要です。
foreachの動作原理
C#のforeachは、コレクションの要素を順に処理するための構文で、コードの可読性を高めるためによく使われます。
LinkedList<T>のようなコレクションでも、foreachを使うことで簡潔に要素を列挙できます。
IEnumerableとIEnumeratorの役割
foreachは、内部的にIEnumerableインターフェースとIEnumeratorインターフェースを利用しています。
IEnumerableは「列挙可能なオブジェクト」を表し、GetEnumerator()メソッドを持っています。
このメソッドはIEnumeratorを返し、IEnumeratorは列挙の状態を管理し、MoveNext()メソッドで次の要素に進み、Currentプロパティで現在の要素を取得します。
LinkedList<T>はIEnumerable<T>を実装しているため、foreachで使うことができます。
foreachは以下のような流れで動作します。
GetEnumerator()を呼び出してIEnumeratorを取得します。MoveNext()を呼び出して次の要素があるか確認します。Currentで現在の要素を取得し、ループ内で処理します。- これを要素がなくなるまで繰り返します。
この仕組みにより、foreachはコレクションの内部構造を意識せずに要素を順に処理できます。
コンパイラが生成するコード
実際にforeach文を使うと、C#のコンパイラはこれをIEnumeratorを使ったループに変換します。
例えば、以下のコード:
foreach (var item in linkedList)
{
Console.WriteLine(item);
}は、コンパイラによってほぼ次のようなコードに変換されます。
var enumerator = linkedList.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
var item = enumerator.Current;
Console.WriteLine(item);
}
}
finally
{
if (enumerator is IDisposable disposable)
{
disposable.Dispose();
}
}この変換により、foreachは安全に列挙を行い、列挙が終わったらリソースを解放します。
LinkedList<T>のGetEnumerator()は、リストの先頭から順にノードをたどるイテレータを返します。
ただし、この列挙中にコレクションを変更すると、InvalidOperationExceptionが発生します。
これは列挙の整合性を保つための仕組みで、foreach中に要素の追加や削除を行う場合は注意が必要です。
安全に操作するためには、LinkedListNode<T>を使ったループや、別の方法で列挙と変更を分けることが推奨されます。
foreachでの列挙パターン
基本的なサンプルコード
LinkedList<T>の要素を順に処理する最もシンプルな方法は、foreachループを使うことです。
以下のサンプルコードは、整数のLinkedListを作成し、各要素を順番にコンソールに出力しています。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// LinkedListの作成と要素の追加
LinkedList<int> linkedList = new LinkedList<int>();
linkedList.AddLast(10);
linkedList.AddLast(20);
linkedList.AddLast(30);
// foreachで要素を順に列挙
foreach (var item in linkedList)
{
Console.WriteLine(item);
}
}
}10
20
30このコードでは、linkedListのAddLastメソッドで要素を追加し、foreachで順に取り出しています。
foreachは内部的にGetEnumerator()を呼び出し、リストの先頭から末尾までノードをたどっていきます。
コードがシンプルで読みやすく、要素の順序を意識せずに処理できるのが特徴です。
拡張メソッドによる列挙
LinkedList<T>の列挙をより柔軟にしたい場合、拡張メソッドを使う方法があります。
例えば、逆順に列挙したい場合や、特定の条件でフィルタリングしながら列挙したい場合に便利です。
以下は、LinkedList<T>を逆順に列挙する拡張メソッドの例です。
using System;
using System.Collections.Generic;
static class LinkedListExtensions
{
// LinkedListを逆順に列挙する拡張メソッド
public static IEnumerable<T> ReverseEnumerate<T>(this LinkedList<T> list)
{
var node = list.Last;
while (node != null)
{
yield return node.Value;
node = node.Previous;
}
}
}
class Program
{
static void Main()
{
LinkedList<string> linkedList = new LinkedList<string>();
linkedList.AddLast("apple");
linkedList.AddLast("banana");
linkedList.AddLast("cherry");
// 拡張メソッドで逆順に列挙
foreach (var item in linkedList.ReverseEnumerate())
{
Console.WriteLine(item);
}
}
}cherry
banana
appleこの拡張メソッドは、LinkedList<T>のLastノードからPreviousノードへとたどりながらyield returnで要素を返しています。
foreachで使うと、逆順に要素を処理できます。
拡張メソッドを使うことで、元のLinkedList<T>の機能を壊さずに、用途に応じた列挙方法を追加できます。
可読性と保守性のバランス
foreachを使った列挙は非常に読みやすく、保守もしやすいコードになります。
しかし、列挙中にコレクションを変更すると例外が発生するため、操作の安全性を考慮する必要があります。
例えば、foreach内で要素の削除や追加を行うとInvalidOperationExceptionが発生します。
これを避けるためには、以下のような方法があります。
- ノードを事前にキャッシュしてから操作する
LinkedListNode<T>を使い、次のノードを事前に保存してから現在のノードを削除する方法です。
これにより、列挙の整合性を保てます。
- 拡張メソッドやカスタムイテレータを使う
逆順列挙や条件付き列挙など、用途に応じて拡張メソッドを作成し、列挙と操作を分離します。
forループやwhileループでノードを直接操作する
foreachを使わずに、LinkedListNode<T>を使ったループで安全に操作します。
可読性を優先するならforeachが最適ですが、操作の安全性や複雑な処理が必要な場合は、拡張メソッドやノード操作を組み合わせることが保守性の高いコードにつながります。
状況に応じて使い分けることが重要です。
ループ中の安全な追加・削除
InvalidOperationExceptionが発生する理由
foreachループ中にLinkedList<T>の要素を追加・削除しようとすると、InvalidOperationExceptionが発生します。
これは、foreachが内部で使用するIEnumeratorがコレクションの状態変化を検知し、列挙の整合性を保つために例外を投げるためです。
具体的には、LinkedList<T>のGetEnumerator()が返すイテレータは、列挙開始時のコレクションのバージョンを記憶しています。
ループ中に要素が追加・削除されるとバージョンが変わり、イテレータは「コレクションが変更された」と判断して例外を発生させます。
これにより、列挙中の不整合や予期しない動作を防いでいます。
したがって、foreach内で直接コレクションを変更することはできません。
安全に操作するには、列挙方法や操作のタイミングを工夫する必要があります。
ノードを事前に保持してから操作する
LinkedList<T>のノードはLinkedListNode<T>型で表され、これを使うことで列挙中の安全な追加・削除が可能です。
特に、現在のノードの次のノードを事前にキャッシュしておく方法が有効です。
Currentノードの次をキャッシュする
foreachではなく、LinkedListNode<T>を使ったループで、現在のノードのNextを事前に保存してから操作します。
こうすることで、現在のノードを削除しても次のノードへの参照を失わずに済みます。
以下は、条件に合う要素を削除する例です。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<int> linkedList = new LinkedList<int>();
linkedList.AddLast(1);
linkedList.AddLast(2);
linkedList.AddLast(3);
linkedList.AddLast(4);
var node = linkedList.First;
while (node != null)
{
var nextNode = node.Next; // 次のノードをキャッシュ
if (node.Value % 2 == 0) // 偶数なら削除
{
linkedList.Remove(node);
}
node = nextNode; // 次のノードへ移動
}
foreach (var item in linkedList)
{
Console.WriteLine(item);
}
}
}1
3この方法では、foreachを使わずにwhileループでノードを直接操作し、削除しても安全に列挙を続けられます。
RemoveとAddAfterを組み合わせる
追加操作も同様に、現在のノードの次をキャッシュしてから行うと安全です。
例えば、特定の条件でノードの後ろに新しいノードを追加する場合は、AddAfterメソッドを使います。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<string> linkedList = new LinkedList<string>();
linkedList.AddLast("A");
linkedList.AddLast("B");
linkedList.AddLast("C");
var node = linkedList.First;
while (node != null)
{
var nextNode = node.Next; // 次のノードをキャッシュ
if (node.Value == "B")
{
linkedList.AddAfter(node, "X"); // "B"の後に"X"を追加
}
node = nextNode;
}
foreach (var item in linkedList)
{
Console.WriteLine(item);
}
}
}A
B
X
Cこのように、RemoveやAddAfterを使う際も、次のノードを事前に保持しておくことでループの安全性を確保できます。
whileループを併用した安全操作
LinkedListNodeを直接操作する
foreachを使わずに、LinkedListNode<T>を直接操作するwhileループは、LinkedList<T>の要素を安全に追加・削除する際の基本的な方法です。
LinkedListNode<T>はノード単位で操作できるため、列挙中のコレクション変更による例外を回避できます。
以下は、whileループでノードをたどりながら、条件に応じて要素を削除する例です。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<int> linkedList = new LinkedList<int>(new[] { 5, 10, 15, 20 });
var current = linkedList.First;
while (current != null)
{
var next = current.Next; // 次のノードをキャッシュ
if (current.Value > 10)
{
linkedList.Remove(current);
}
current = next;
}
foreach (var item in linkedList)
{
Console.WriteLine(item);
}
}
}5
10この方法は、foreachの制約を回避しつつ、ノード単位で柔軟に操作できるため、複雑な条件での追加・削除に適しています。
foreachを避ける代替アプローチ
foreachは簡潔で読みやすいですが、列挙中のコレクション変更に弱いため、以下のような代替アプローチがよく使われます。
forループやwhileループでノードを直接操作する
先述のように、LinkedListNode<T>を使ってループを制御し、操作の安全性を確保します。
- 変更対象の要素を一時的にリストに保存し、列挙後にまとめて操作する
例えば、削除したい要素をList<T>に集めておき、foreach終了後に一括削除します。
ToList()でコピーを作成してから列挙する
元のLinkedList<T>をToList()でコピーし、そのコピーをforeachで列挙。
元のリストは列挙中に変更しても例外が発生しません。
これらの方法は、foreachの制約を回避しつつ、コードの安全性と可読性を両立させるために有効です。
状況に応じて使い分けることが望ましいです。
逆順列挙の実装テクニック
LastからPreviousをたどる基本形
LinkedList<T>は双方向連結リストであるため、末尾のノードから前方向にたどることができます。
逆順に要素を列挙したい場合は、Lastプロパティで末尾のノードを取得し、Previousプロパティを使って前のノードへ移動しながら処理します。
以下は、LinkedList<int>の要素を逆順に出力する基本的なコード例です。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<int> linkedList = new LinkedList<int>();
linkedList.AddLast(100);
linkedList.AddLast(200);
linkedList.AddLast(300);
var node = linkedList.Last;
while (node != null)
{
Console.WriteLine(node.Value);
node = node.Previous;
}
}
}300
200
100この方法はシンプルで直感的です。
Lastからスタートし、Previousをたどることでリストの末尾から先頭まで逆順にアクセスできます。
ただし、この方法はforeachでは直接実現できないため、逆順列挙が必要な場合はこのようにノードを直接操作することが基本となります。
拡張メソッドReverseの注意点
LINQのEnumerable.Reverse()メソッドを使うと、任意のIEnumerable<T>を逆順に列挙できますが、LinkedList<T>に対して使う場合は注意が必要です。
Enumerable.Reverse()は内部でコレクションの要素を一旦コピーし、逆順に列挙します。
LinkedList<T>はランダムアクセスが遅いため、Reverse()を使うとパフォーマンスが低下する可能性があります。
特に大規模なリストではメモリ消費も増えます。
以下はReverse()を使った例です。
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
LinkedList<string> linkedList = new LinkedList<string>();
linkedList.AddLast("one");
linkedList.AddLast("two");
linkedList.AddLast("three");
foreach (var item in linkedList.Reverse())
{
Console.WriteLine(item);
}
}
}three
two
oneこのコードは動作しますが、Reverse()は内部で配列などにコピーしてから逆順に列挙するため、LinkedList<T>の特性を活かした効率的な逆順列挙ではありません。
パフォーマンスを重視する場合は、LastからPreviousをたどる方法やカスタムイテレータを使うほうが望ましいです。
カスタムイテレータを自作する
yield returnで実装する
LinkedList<T>の逆順列挙を簡潔に実装するには、yield returnを使ったカスタムイテレータが便利です。
これにより、呼び出し元はforeachで自然に逆順列挙を利用できます。
以下は、LinkedList<T>の逆順列挙を行う拡張メソッドの例です。
using System;
using System.Collections.Generic;
static class LinkedListExtensions
{
public static IEnumerable<T> ReverseEnumerate<T>(this LinkedList<T> list)
{
var node = list.Last;
while (node != null)
{
yield return node.Value;
node = node.Previous;
}
}
}
class Program
{
static void Main()
{
LinkedList<int> linkedList = new LinkedList<int>();
linkedList.AddLast(1);
linkedList.AddLast(2);
linkedList.AddLast(3);
foreach (var item in linkedList.ReverseEnumerate())
{
Console.WriteLine(item);
}
}
}3
2
1この拡張メソッドは、LastからPreviousをたどりながらyield returnで要素を返します。
呼び出し側はforeachで自然に逆順列挙でき、コードもシンプルで読みやすいです。
パフォーマンス最適化のポイント
カスタムイテレータを使う場合でも、パフォーマンスを意識するポイントがあります。
- コピーを避ける
Enumerable.Reverse()のように全要素をコピーしないため、メモリ使用量が抑えられます。
- ノードの参照を直接たどる
LinkedListNode<T>のPreviousを使うことで、O(n)の走査で逆順列挙が可能です。
- 遅延実行の活用
yield returnは遅延実行を実現するため、必要な要素だけを列挙し、無駄な処理を減らせます。
- スレッドセーフに注意
列挙中にリストが変更されると例外が発生するため、スレッドセーフな環境ではロックなどの同期処理が必要です。
- 例外処理の最小化
逆順列挙中に例外が発生しないよう、ノードのnullチェックを確実に行います。
これらを踏まえた実装は、LinkedList<T>の特性を活かしつつ効率的な逆順列挙を実現します。
特に大規模なデータを扱う場合は、Enumerable.Reverse()よりもカスタムイテレータのほうがパフォーマンス面で優れています。
性能とメモリ使用量
操作ごとの時間計算量
LinkedList<T>は双方向連結リストの特性を持つため、各操作の時間計算量は操作内容によって異なります。
ここでは代表的な操作の計算量を解説します。
AddFirstとAddLast
AddFirstとAddLastは、それぞれリストの先頭と末尾に要素を追加するメソッドです。
LinkedList<T>は先頭と末尾のノードを直接参照しているため、これらの操作は定数時間(O(1))で実行できます。
具体的には、新しいノードを作成し、先頭または末尾のノードの参照を更新するだけで済みます。
リストのサイズに関係なく高速に処理できるため、頻繁に先頭や末尾に要素を追加する用途に適しています。
RemoveFirstとRemoveLast
RemoveFirstとRemoveLastは、リストの先頭と末尾の要素を削除するメソッドです。
これらもAddFirstやAddLastと同様に、先頭や末尾のノードを直接参照しているため、定数時間(O(1))で実行できます。
削除時は、対象ノードの前後のノードの参照を更新し、対象ノードを切り離すだけです。
リストのサイズに依存せず高速に処理できるため、キューやスタックのようなデータ構造として利用する際に有効です。
一方で、リストの途中にある特定のノードを削除する場合は、そのノードを直接指定できればO(1)ですが、値からノードを検索する場合はO(n)の走査が必要です。
キャッシュ効率とガベージコレクション
LinkedList<T>は各ノードが独立したオブジェクトとしてヒープ上に存在し、前後のノードへの参照を持つ構造です。
このため、メモリの連続性が低く、CPUのキャッシュ効率は配列やList<T>に比べて劣ります。
CPUキャッシュは連続したメモリ領域のデータを高速に読み書きできるため、List<T>のような動的配列はキャッシュ効率が高いです。
一方、LinkedList<T>はノードが散在しているため、キャッシュミスが増えやすく、アクセス速度が低下することがあります。
また、LinkedList<T>のノードはガベージコレクションの対象となるオブジェクトが多数存在するため、GCの負荷が高くなる可能性があります。
特に大量のノードを頻繁に追加・削除する場合は、GCの発生頻度や停止時間に影響を与えることがあります。
このように、LinkedList<T>は操作の高速性と引き換えに、キャッシュ効率やメモリ管理の面でコストがかかる点に注意が必要です。
大規模データでのベンチマーク結果
大規模なデータセットでLinkedList<T>とList<T>の性能を比較すると、以下のような傾向が見られます。
| 操作内容 | LinkedList<T>の性能 | List<T>の性能 |
|---|---|---|
| 先頭への追加 | 高速(O(1)) | 遅い(O(n)) |
| 末尾への追加 | 高速(O(1)) | 高速(平均O(1)) |
| 先頭の削除 | 高速(O(1)) | 遅い(O(n)) |
| 末尾の削除 | 高速(O(1)) | 高速(O(1)) |
| ランダムアクセス | 遅い(O(n)) | 高速(O(1)) |
| メモリ使用量 | 多い(ノードごとに参照を持つ) | 少ない(連続した配列) |
例えば、100万件の要素を持つリストで先頭に要素を追加するベンチマークでは、LinkedList<T>は一定時間で処理できるのに対し、List<T>は要素のシフトが発生するため時間が大幅に増加します。
一方、ランダムアクセスや要素の検索ではList<T>が圧倒的に高速です。
LinkedList<T>はノードを順にたどる必要があるため、アクセス時間が線形に増加します。
メモリ使用量に関しては、LinkedList<T>は各ノードがオブジェクトとして独立しているため、参照の分だけ余分なメモリを消費します。
大規模データではこの差が顕著になります。
これらの結果から、用途に応じてLinkedList<T>とList<T>を使い分けることが重要です。
頻繁な先頭・末尾の追加・削除が多い場合はLinkedList<T>、高速なランダムアクセスや検索が必要な場合はList<T>が適しています。
よくある落とし穴と対策
foreach中にAddBeforeやAddAfterを呼ぶ
LinkedList<T>のforeachループ中にAddBeforeやAddAfterを呼び出して要素を追加すると、InvalidOperationExceptionが発生することがあります。
これは、foreachが内部で使用するイテレータがコレクションの状態変化を検知し、列挙の整合性を保つために例外を投げるためです。
例えば、以下のようなコードは例外を引き起こします。
LinkedList<int> list = new LinkedList<int>(new[] {1, 2, 3});
foreach (var item in list)
{
if (item == 2)
{
list.AddAfter(list.Find(item), 4); // 例外が発生する可能性あり
}
}この問題を回避するには、foreach中に直接追加しない方法を取ります。
具体的には、以下の対策が有効です。
- ノードを事前にキャッシュしてから操作する
LinkedListNode<T>を使い、ループ中はノードの参照だけをたどり、追加はループ外で行います。
whileループでノードを直接操作する
LinkedListNode<T>を使ったwhileループで、次のノードを事前に保存しながら安全に追加・削除を行います。
- 追加したい要素を一時的に別のコレクションに保存し、列挙後にまとめて追加する
これにより、列挙中のコレクション変更を避けられます。
これらの方法で、foreach中の追加操作による例外を防ぎ、安全にリストを操作できます。
列挙中に外部スレッドがリストを変更する
マルチスレッド環境でLinkedList<T>を使用する場合、あるスレッドがforeachで列挙している最中に、別のスレッドがリストを変更するとInvalidOperationExceptionが発生することがあります。
これは、イテレータがコレクションのバージョンを監視しており、変更を検知すると例外を投げるためです。
この問題を防ぐためには、以下の対策が必要です。
- 同期機構を使う
lock文やMutex、ReaderWriterLockSlimなどを使い、列挙中および変更中の排他制御を行います。
- スレッドセーフなコレクションを使う
ConcurrentQueue<T>やConcurrentBag<T>など、スレッドセーフに設計されたコレクションを検討します。
- 列挙用のコピーを作成する
ToList()やToArray()でリストのコピーを作成し、そのコピーを列挙することで、元のリストの変更による影響を避けます。
例えば、lockを使った例は以下の通りです。
private readonly object _lock = new object();
private LinkedList<int> _list = new LinkedList<int>();
void SafeEnumerate()
{
lock (_lock)
{
foreach (var item in _list)
{
Console.WriteLine(item);
}
}
}
void SafeModify()
{
lock (_lock)
{
_list.AddLast(100);
}
}このように、適切な同期を行うことで、マルチスレッド環境でも安全にLinkedList<T>を操作できます。
null参照と例外処理
LinkedList<T>を操作する際、null参照に起因する例外が発生しやすいポイントがあります。
特に、FirstやLastプロパティ、LinkedListNode<T>のNextやPreviousがnullになる場合があるため、これらを扱う際は注意が必要です。
例えば、空のリストでFirstやLastを参照するとnullが返るため、そのままアクセスするとNullReferenceExceptionが発生します。
LinkedList<int> list = new LinkedList<int>();
var firstNode = list.First;
Console.WriteLine(firstNode.Value); // NullReferenceExceptionが発生このような例外を防ぐには、nullチェックを必ず行うことが重要です。
if (firstNode != null)
{
Console.WriteLine(firstNode.Value);
}
else
{
Console.WriteLine("リストは空です");
}また、NextやPreviousをたどるループでも、nullチェックを怠ると例外が発生します。
安全なループの例は以下の通りです。
var node = list.First;
while (node != null)
{
Console.WriteLine(node.Value);
node = node.Next;
}さらに、例外処理を適切に行うことで、予期しないエラーを防止できます。
例えば、Findメソッドがnullを返す可能性がある場合は、戻り値のチェックを行い、nullの場合の処理を実装します。
var node = list.Find(10);
if (node != null)
{
list.Remove(node);
}
else
{
Console.WriteLine("指定した値はリストに存在しません");
}このように、null参照に対するチェックと例外処理を徹底することで、LinkedList<T>の操作時のトラブルを減らせます。
LINQ連携時の注意点
遅延実行と列挙の衝突
LINQの多くのメソッドは遅延実行(Deferred Execution)を採用しています。
これは、クエリの実行が実際に列挙されるまで遅延される仕組みです。
LinkedList<T>とLINQを組み合わせる際、この遅延実行が原因で列挙中にコレクションが変更されると、InvalidOperationExceptionが発生することがあります。
例えば、以下のようなコードを考えます。
LinkedList<int> linkedList = new LinkedList<int>(new[] {1, 2, 3, 4, 5});
var query = linkedList.Where(x => x % 2 == 0);
foreach (var item in query)
{
if (item == 2)
{
linkedList.AddLast(6); // ここでコレクションが変更される
}
Console.WriteLine(item);
}この場合、Whereのクエリは遅延実行されているため、foreachの列挙中にlinkedListが変更されると例外が発生します。
遅延実行のため、列挙が始まるまでクエリは実行されず、列挙中にコレクションの状態が変わると整合性が崩れるためです。
この問題を避けるには、列挙中にコレクションを変更しないか、列挙前にクエリを評価して結果を確定させる必要があります。
AsEnumerableでのメモリ確保
AsEnumerable()は、コレクションをIEnumerable<T>として扱うためのメソッドで、LINQのチェーンを続ける際に使われます。
ただし、AsEnumerable()自体は遅延実行を解除しません。
つまり、AsEnumerable()を使っても元のLinkedList<T>の列挙は遅延実行のままです。
そのため、AsEnumerable()を使っただけでは、列挙中のコレクション変更による例外は防げません。
メモリ確保やコピーは行われず、元のコレクションの状態に依存したまま列挙されます。
var enumerable = linkedList.AsEnumerable();このコードは単に型を変換するだけで、列挙の安全性やパフォーマンスには影響しません。
列挙中の変更を防ぎたい場合は、ToList()などでコピーを作成する必要があります。
ToListでコピーしてから操作する戦略
ToList()は、元のコレクションの要素を新しいList<T>にコピーし、即時実行(Eager Execution)を行います。
これにより、元のLinkedList<T>の状態に依存せずに安全に列挙や操作が可能になります。
例えば、以下のようにToList()でコピーを作成してから列挙すると、元のリストの変更による例外を回避できます。
LinkedList<int> linkedList = new LinkedList<int>(new[] {1, 2, 3, 4, 5});
var snapshot = linkedList.ToList();
foreach (var item in snapshot)
{
if (item == 2)
{
linkedList.AddLast(6); // 元のリストを変更しても例外は発生しない
}
Console.WriteLine(item);
}この方法は、列挙中に元のコレクションを変更しても安全ですが、コピーを作成するためメモリ使用量が増加します。
大規模なコレクションでは注意が必要です。
また、コピーしたリストは元のLinkedList<T>とは独立しているため、列挙後の操作は元のリストに反映されません。
元のリストを操作したい場合は、コピー後に別途処理を行う必要があります。
このように、ToList()でコピーしてから操作する戦略は、遅延実行による例外を防ぎつつ安全にLINQと連携するための有効な手段です。
用途やパフォーマンス要件に応じて使い分けてください。
スレッドセーフに扱う方法
lockで同期する基本パターン
LinkedList<T>はスレッドセーフではないため、複数のスレッドから同時にアクセスや変更を行うとデータ競合や例外が発生します。
最も基本的なスレッドセーフ化の方法は、lock文を使って排他制御を行うことです。
以下は、lockを使ってLinkedList<T>への読み書きを同期する例です。
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
private static readonly object _lock = new object();
private static LinkedList<int> _linkedList = new LinkedList<int>();
static void Main()
{
// 複数タスクで同時にリストを操作
Parallel.Invoke(
() => AddItems(),
() => EnumerateItems()
);
}
static void AddItems()
{
for (int i = 0; i < 10; i++)
{
lock (_lock)
{
_linkedList.AddLast(i);
Console.WriteLine($"Added {i}");
}
Task.Delay(50).Wait();
}
}
static void EnumerateItems()
{
for (int i = 0; i < 10; i++)
{
lock (_lock)
{
foreach (var item in _linkedList)
{
Console.Write($"{item} ");
}
Console.WriteLine();
}
Task.Delay(100).Wait();
}
}
}この例では、_lockオブジェクトを使ってAddItemsとEnumerateItemsの処理を排他制御しています。
これにより、同時にリストを変更したり列挙したりすることによる競合や例外を防げます。
lockはシンプルで効果的ですが、長時間ロックを保持すると他のスレッドが待機し、パフォーマンスに影響を与える可能性があります。
必要な範囲だけをロックすることが重要です。
ConcurrentQueue等との比較
.NETにはスレッドセーフなコレクションが標準で用意されており、ConcurrentQueue<T>やConcurrentBag<T>などがあります。
これらは内部で高度な同期機構を持ち、複数スレッドからの同時アクセスに対応しています。
LinkedList<T>と比較すると、以下のような特徴があります。
| 特徴 | LinkedList<T> + lock | ConcurrentQueue<T>等 |
|---|---|---|
| スレッドセーフ | 手動で同期が必要 | 自動でスレッドセーフ |
| 操作の柔軟性 | 高い(任意の位置に挿入可能) | キューやスタックなど特定用途向け |
| パフォーマンス | ロックの競合により低下する可能性 | 高速でスケーラブル |
| 実装の複雑さ | シンプルだが同期管理が必要 | 内部実装が複雑 |
例えば、FIFOのキューとして使うならConcurrentQueue<T>が適しており、複雑な挿入や削除が必要な場合はLinkedList<T>にlockを組み合わせる方法が現実的です。
スレッドセーフなコレクションは用途に特化しているため、LinkedList<T>のような汎用的な双方向リストの代替にはなりにくい点に注意してください。
ReaderWriterLockSlimを使うケース
lockは単純な排他制御ですが、読み取りが多く書き込みが少ない場合は、ReaderWriterLockSlimを使うことでパフォーマンスを改善できます。
これは複数のスレッドが同時に読み取りを行える一方で、書き込み時は排他制御を行う仕組みです。
以下は、ReaderWriterLockSlimを使ったLinkedList<T>のスレッドセーフな操作例です。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static LinkedList<int> _linkedList = new LinkedList<int>();
private static ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
static void Main()
{
Parallel.Invoke(
() => AddItems(),
() => EnumerateItems()
);
}
static void AddItems()
{
for (int i = 0; i < 10; i++)
{
_rwLock.EnterWriteLock();
try
{
_linkedList.AddLast(i);
Console.WriteLine($"Added {i}");
}
finally
{
_rwLock.ExitWriteLock();
}
Thread.Sleep(50);
}
}
static void EnumerateItems()
{
for (int i = 0; i < 10; i++)
{
_rwLock.EnterReadLock();
try
{
foreach (var item in _linkedList)
{
Console.Write($"{item} ");
}
Console.WriteLine();
}
finally
{
_rwLock.ExitReadLock();
}
Thread.Sleep(100);
}
}
}この例では、書き込み時にEnterWriteLockで排他制御し、読み取り時はEnterReadLockで複数スレッドの同時読み取りを許可しています。
これにより、読み取りが多いシナリオでスループットが向上します。
ただし、ReaderWriterLockSlimは使い方を誤るとデッドロックやパフォーマンス低下の原因になるため、適切なロックの取得・解放を心がける必要があります。
まとめると、スレッドセーフにLinkedList<T>を扱うには、単純なlockからReaderWriterLockSlimまで用途やパフォーマンス要件に応じて同期機構を選択し、適切に運用することが重要です。
使用シナリオ別ベストプラクティス
キューライクなデータ処理
キュー(FIFO:先入れ先出し)としてデータを処理する場合、LinkedList<T>は非常に適しています。
LinkedList<T>は先頭と末尾のノードを直接参照しているため、AddLastで末尾に要素を追加し、RemoveFirstで先頭から要素を取り出す操作がいずれもO(1)で高速に行えます。
以下は、LinkedList<T>を使った簡単なキュー処理の例です。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<string> queue = new LinkedList<string>();
// キューに要素を追加
queue.AddLast("Task1");
queue.AddLast("Task2");
queue.AddLast("Task3");
// キューから要素を取り出して処理
while (queue.Count > 0)
{
string task = queue.First.Value;
Console.WriteLine($"Processing {task}");
queue.RemoveFirst();
}
}
}Processing Task1
Processing Task2
Processing Task3このように、AddLastとRemoveFirstを組み合わせることで、キューとして効率的に動作します。
Queue<T>クラスもありますが、LinkedList<T>はノードの参照を保持しておけるため、より柔軟な操作が必要な場合に有利です。
スタックライクなデータ処理
スタック(LIFO:後入れ先出し)としてデータを扱う場合も、LinkedList<T>は有効です。
AddFirstで先頭に要素を追加し、RemoveFirstで先頭から要素を取り出す操作がいずれもO(1)で高速に行えます。
以下は、LinkedList<T>を使ったスタック処理の例です。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<int> stack = new LinkedList<int>();
// スタックに要素をプッシュ
stack.AddFirst(10);
stack.AddFirst(20);
stack.AddFirst(30);
// スタックから要素をポップして処理
while (stack.Count > 0)
{
int value = stack.First.Value;
Console.WriteLine($"Popped {value}");
stack.RemoveFirst();
}
}
}Popped 30
Popped 20
Popped 10Stack<T>クラスもありますが、LinkedList<T>はノードの挿入や削除を柔軟に行えるため、スタックの動作に加えて他の操作も必要な場合に適しています。
双方向検索が必要なケース
LinkedList<T>は双方向連結リストであるため、前方向だけでなく後方向にもノードをたどることができます。
これにより、双方向検索や双方向のトラバースが必要なシナリオに適しています。
例えば、あるノードから前後に特定の条件で探索したい場合、NextとPreviousプロパティを使って効率的に移動できます。
以下は、特定の値を見つけて、その前後の要素を表示する例です。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
LinkedList<string> list = new LinkedList<string>(new[] { "A", "B", "C", "D", "E" });
var node = list.Find("C");
if (node != null)
{
Console.WriteLine($"Current: {node.Value}");
if (node.Previous != null)
Console.WriteLine($"Previous: {node.Previous.Value}");
if (node.Next != null)
Console.WriteLine($"Next: {node.Next.Value}");
}
}
}Current: C
Previous: B
Next: Dこのように、双方向のノード参照を活用することで、前後の要素を簡単に取得でき、双方向検索や編集が必要なケースで威力を発揮します。
List<T>のような動的配列では前方向のみのアクセスが基本であり、双方向のトラバースは効率的に行えません。
双方向検索や頻繁な挿入・削除が必要な場合は、LinkedList<T>を使うことがベストプラクティスです。
まとめ
この記事では、C#のLinkedList<T>をforeachで安全に操作する方法や逆順列挙の実装テクニック、性能面の特徴、よくある落とし穴と対策、LINQ連携時の注意点、スレッドセーフな扱い方、そして使用シナリオ別のベストプラクティスを解説しました。
LinkedList<T>は挿入・削除が高速で双方向のトラバースが可能なため、用途に応じて適切に使い分けることが重要です。
安全な列挙やスレッド同期の工夫で、効率的かつ安定したプログラムを実現できます。