配列&コレクション

【C#】LinkedListをforeachで安全に操作するコツと逆順列挙の実装例

LinkedList<T>はforeachで直感的に要素を列挙できる反面、ループ中に要素を追加・削除するとInvalidOperationExceptionが発生するため要注意です。

変更が必要なら事前に対象ノードを保存してから操作するか、whileでLastやNextを明示的にたどる方法が安全です。

逆順処理はLastからPreviousを追うと実現できます。

LinkedListとforeachの基礎知識

LinkedList<T>とは

C#のLinkedList<T>は、双方向連結リストを実装したデータ構造です。

これは、各要素(ノード)が前後のノードへの参照を持つことで、リストの先頭や末尾への要素の追加・削除を効率的に行える特徴があります。

LinkedList<T>は、特に頻繁に要素の挿入や削除が発生するシナリオで活躍します。

連結リストの構造

連結リストは、ノードと呼ばれる要素の集合で構成されており、各ノードはデータ本体と、前後のノードを指す参照(リンク)を持っています。

LinkedList<T>の場合は双方向連結リストであるため、各ノードはPreviousNextという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は以下のような流れで動作します。

  1. GetEnumerator()を呼び出してIEnumeratorを取得します。
  2. MoveNext()を呼び出して次の要素があるか確認します。
  3. Currentで現在の要素を取得し、ループ内で処理します。
  4. これを要素がなくなるまで繰り返します。

この仕組みにより、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

このコードでは、linkedListAddLastメソッドで要素を追加し、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

このように、RemoveAddAfterを使う際も、次のノードを事前に保持しておくことでループの安全性を確保できます。

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

AddFirstAddLastは、それぞれリストの先頭と末尾に要素を追加するメソッドです。

LinkedList<T>は先頭と末尾のノードを直接参照しているため、これらの操作は定数時間(O(1))で実行できます。

具体的には、新しいノードを作成し、先頭または末尾のノードの参照を更新するだけで済みます。

リストのサイズに関係なく高速に処理できるため、頻繁に先頭や末尾に要素を追加する用途に適しています。

RemoveFirstとRemoveLast

RemoveFirstRemoveLastは、リストの先頭と末尾の要素を削除するメソッドです。

これらもAddFirstAddLastと同様に、先頭や末尾のノードを直接参照しているため、定数時間(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ループ中にAddBeforeAddAfterを呼び出して要素を追加すると、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文やMutexReaderWriterLockSlimなどを使い、列挙中および変更中の排他制御を行います。

  • スレッドセーフなコレクションを使う

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参照に起因する例外が発生しやすいポイントがあります。

特に、FirstLastプロパティ、LinkedListNode<T>NextPreviousnullになる場合があるため、これらを扱う際は注意が必要です。

例えば、空のリストでFirstLastを参照すると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("リストは空です");
}

また、NextPreviousをたどるループでも、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オブジェクトを使ってAddItemsEnumerateItemsの処理を排他制御しています。

これにより、同時にリストを変更したり列挙したりすることによる競合や例外を防げます。

lockはシンプルで効果的ですが、長時間ロックを保持すると他のスレッドが待機し、パフォーマンスに影響を与える可能性があります。

必要な範囲だけをロックすることが重要です。

ConcurrentQueue等との比較

.NETにはスレッドセーフなコレクションが標準で用意されており、ConcurrentQueue<T>ConcurrentBag<T>などがあります。

これらは内部で高度な同期機構を持ち、複数スレッドからの同時アクセスに対応しています。

LinkedList<T>と比較すると、以下のような特徴があります。

特徴LinkedList<T> + lockConcurrentQueue<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

このように、AddLastRemoveFirstを組み合わせることで、キューとして効率的に動作します。

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 10

Stack<T>クラスもありますが、LinkedList<T>はノードの挿入や削除を柔軟に行えるため、スタックの動作に加えて他の操作も必要な場合に適しています。

双方向検索が必要なケース

LinkedList<T>は双方向連結リストであるため、前方向だけでなく後方向にもノードをたどることができます。

これにより、双方向検索や双方向のトラバースが必要なシナリオに適しています。

例えば、あるノードから前後に特定の条件で探索したい場合、NextPreviousプロパティを使って効率的に移動できます。

以下は、特定の値を見つけて、その前後の要素を表示する例です。

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>は挿入・削除が高速で双方向のトラバースが可能なため、用途に応じて適切に使い分けることが重要です。

安全な列挙やスレッド同期の工夫で、効率的かつ安定したプログラムを実現できます。

関連記事

Back to top button
目次へ