C#を用いたアプリケーション開発において、データの集合を管理するList<T>は最も頻繁に利用されるクラスの一つです。

このリスト内に格納された要素を一つずつ取り出して処理を行う際、最も一般的かつ直感的な方法がforeach文によるループ処理です。

foreachは、インデックスの管理を意識することなく、簡潔で読みやすいコードを記述できるという大きなメリットがあります。

しかし、単純に要素を回すだけではなく、実行速度が求められるパフォーマンスチューニングや、ループ内での要素削除といった特定の条件下では、foreachの特性を正しく理解しておく必要があります。

本記事では、C#におけるList<T>foreach処理について、基本構文から内部的な仕組み、他のループ手法との性能比較、そして実務で直面しやすい注意点までをプロフェッショナルな視点で詳しく解説します。

Listをforeachで処理する基本構文

C#のforeach文は、IEnumerable<T>インターフェースを実装しているコレクションに対して使用可能です。

List<T>はこのインターフェースを実装しているため、非常にシンプルな記述でループ処理を実現できます。

まずは、最も基本的な数値リストの反復処理を確認しましょう。

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

class Program
{
    static void Main()
    {
        // リストの初期化
        List<int> numbers = new List<int> { 10, 20, 30, 40, 50 };

        Console.WriteLine("--- リストの要素を表示 ---");

        // foreach文によるループ処理
        foreach (int number in numbers)
        {
            // 各要素に対する処理
            Console.WriteLine($"値: {number}");
        }
    }
}
実行結果
--- リストの要素を表示 ---
値: 10
値: 20
値: 30
値: 40
値: 50

上記のコードでは、numbersリスト内の各要素が一時的な変数numberに代入され、ループ内で処理されます。

インデックス変数(iなど)を手動で管理する必要がないため、「インデックスの範囲外アクセス(IndexOutOfRangeException)」を物理的に防止できるという安全上の利点があります。

foreach文の内部動作とEnumerator

なぜforeach文はこれほど簡単にリストを扱えるのでしょうか。

その理由は、コンパイラが裏側で列挙子(Enumerator)を利用したコードに変換しているからです。

List<T>クラスにはGetEnumerator()メソッドが定義されており、これを通じてリスト内の要素を順次参照する仕組みが提供されています。

foreach文をコンパイルすると、概念的には以下のようなコードと同等の処理が行われます。

C#
// foreachの実体に近い等価コード
List<int>.Enumerator enumerator = numbers.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int number = enumerator.Current;
        // 処理内容
        Console.WriteLine(number);
    }
}
finally
{
    enumerator.Dispose();
}

ここで注目すべき点は、List<T>.Enumerator構造体(struct)として実装されている点です。

通常、インターフェースを介した列挙はヒープメモリへのアロケーション(割り当て)を発生させますが、C#の具象型としてのList<T>を直接foreachに渡すと、コンパイラはこの構造体列挙子を最適化して使用します。

これにより、ガベージコレクション(GC)の負荷を抑えつつ高速な反復が可能になっています。

性能比較:foreach vs for vs List.ForEach

C#でリストをループ処理する方法はforeachだけではありません。

代表的な手法としてfor文とList<T>.ForEachメソッドがあります。

それぞれの性能と特徴を比較してみましょう。

1. for文

インデックス(添字)を使用して直接要素にアクセスします。

  • 利点: 最も高速であり、インデックスが必要な処理に適している。
  • 欠点: コードが冗長になりやすく、境界値エラーのリスクがある。

2. foreach文

列挙子を使用して要素にアクセスします。

  • 利点: 読みやすく安全。現代の.NET(.NET 5以降や.NET 8/9など)では、コンパイラの最適化によりfor文に匹敵する速度が出る。
  • 欠点: ループ内で要素の追加・削除ができない。

3. List.ForEachメソッド

デリゲート(Action)を渡して各要素を処理します。

  • 利点: メソッドチェーンのように記述でき、1行で書ける。
  • 欠点: デリゲートの呼び出しコストが発生するため、他の手法に比べてわずかに遅い傾向がある。
手法可読性速度柔軟性
foreach最高高速(最適化時)
for普通最高速
List.ForEach低(呼び出しオーバーヘッド)

現代の.NET環境では、JITコンパイラが非常に強力なため、List<T>に対するforeachforとほぼ同等のパフォーマンスを発揮します。

そのため、基本的には可読性を重視してforeachを選択し、極限のパフォーマンスが必要な箇所のみforを検討するのがベストプラクティスです。

注意点:反復中のリスト操作は厳禁

foreachを使用する際に最も注意しなければならないのが、ループの実行中に元のリストのサイズを変更(要素の追加や削除)してはいけないというルールです。

もしループ内でAdd()Remove()を呼び出すと、実行時にInvalidOperationExceptionがスローされます。

C#
List<string> tags = new List<string> { "C#", "Java", "Python", "C++" };

try
{
    foreach (var tag in tags)
    {
        if (tag == "Java")
        {
            // 実行時に例外が発生する
            tags.Remove(tag); 
        }
    }
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"エラー: {ex.Message}");
}

なぜ例外が発生するのか?

前述の通り、foreachは内部的にEnumeratorを使用しています。

この列挙子は、生成された時点のリストの状態を監視するための「バージョン情報」を保持しています。

リストに変更が加えられるとこのバージョンが更新され、列挙子は「データが不整合な状態になった」と判断して例外を投げる仕組みになっています。

回避策1:逆順のfor文を使用する

要素を削除する場合は、リストの末尾からインデックスを減らしながらfor文で回すのが定石です。

C#
for (int i = tags.Count - 1; i >= 0; i--)
{
    if (tags[i] == "Java")
    {
        tags.RemoveAt(i);
    }
}

回避策2:LINQのRemoveAllを使用する

特定の条件に合致する要素をすべて削除したい場合は、ループを書くよりもRemoveAllメソッドを使用する方が効率的かつ簡潔です。

C#
tags.RemoveAll(tag => tag == "Java");

モダンC#における高速化:Span<T>の活用

.NET Core以降、特に近年のアップデートにより、メモリ効率と速度を両立させるSpan<T>という型が登場しました。

List<T>を直接foreachで回すよりもさらに高速化したい場合、CollectionsMarshal.AsSpanを使用してリストの内部配列をSpanとして参照し、それをループさせる手法があります。

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

public class PerformanceDemo
{
    public void ProcessList(List<int> data)
    {
        // ListをSpanに変換してループ(.NET 5以降で有効)
        // 境界チェックの一部をスキップできるため、非常に高速
        Span<int> dataSpan = CollectionsMarshal.AsSpan(data);
        
        foreach (ref int value in dataSpan)
        {
            // refを使用すれば要素の書き換えも可能
            value *= 2;
        }
    }
}

この手法は、数百万件規模のデータを扱うハイパフォーマンスなライブラリ開発などで威力を発揮します。

ただし、CollectionsMarshalは「安全ではない」操作を含むため、通常のビジネスロジックでは標準のforeachで十分です。

非同期ストリームとawait foreach

C# 8.0以降では、非同期的なデータ取得を伴うループ処理のためにawait foreachが導入されました。

データベースやAPIから少しずつデータを取得しながらリスト化せずに処理する場合に有効です。

C#
public async Task ProcessAsyncData(IAsyncEnumerable<int> dataStream)
{
    // 非同期的に流れてくるデータを一つずつ処理
    await foreach (var item in dataStream)
    {
        await Task.Delay(10); // 何らかの非同期処理
        Console.WriteLine(item);
    }
}

List<T>そのものはメモリ上に全データがあるため通常のforeachを使いますが、大量のデータをListに詰め込んでから処理するのではなく、ストリームとして処理することでメモリ節約を図るという考え方は、現代のC#開発において非常に重要です。

並列処理:Parallel.ForEachによる高速化

もしループ内の各処理が独立しており、マルチコアCPUの性能をフルに活用したい場合は、System.Threading.Tasks.Parallel.ForEachの使用を検討してください。

C#
List<int> largeList = new List<int>(); // 大量データが入っているとする

Parallel.ForEach(largeList, item =>
{
    // 重い計算処理などを並列で実行
    DoHeavyWork(item);
});

ただし、並列処理は「スレッド間の競合」や「オーバーヘッド」が発生するため、処理内容が極めて軽量な場合は、通常のforeachよりも遅くなる可能性がある点に注意が必要です。

また、リストへの書き込みを行う場合はスレッドセーフなコレクション(ConcurrentBag<T>など)を使用するか、ロック制御を行う必要があります。

まとめ

C#のList<T>におけるforeach処理は、単なる反復構文以上の洗練された仕組みを持っています。

  • 基本的な使い方: インデックスを意識せず、安全かつ簡潔にリストの要素を走査できる。
  • 内部メカニズム: Enumerator構造体を利用しており、無駄なヒープ割当を抑える工夫がなされている。
  • パフォーマンス: 現代の.NETではfor文と遜色ない速度が出るため、原則としてforeachの使用が推奨される。
  • 注意点: ループ内の要素の追加・削除は例外の原因となる。削除が必要な場合は逆順のfor文やRemoveAllを活用する。
  • 高度な手法: パフォーマンスがクリティカルな場面ではSpan<T>、大量データにはParallel.ForEachや非同期ストリームを検討する。

これらの特性を理解し、状況に応じて適切なループ手法を選択することで、保守性が高くパフォーマンスに優れたC#プログラムを記述できるようになります。

日々の開発ではまず可読性の高いforeachを第一選択肢とし、必要に応じて最適化を施していくアプローチを心がけましょう。