C#でアプリケーションを開発している際、多くのエンジニアが一度は遭遇するのが System.InvalidOperationException という例外です。
中でも「Collection was modified; enumeration operation may not execute.」というメッセージは、コレクションの反復処理中にその内容を変更しようとした際に発生する代表的なエラーです。
このエラーは、プログラムの実行を中断させるだけでなく、一見すると正しく動いているコードの中で予期せず牙を剥くことがあります。
本記事では、この例外が発生する根本的な原因を解明し、実務で役立つ安全な回避策をコード例と共に紹介します。
System.InvalidOperationExceptionが発生する原因
この例外が発生する最大の理由は、列挙処理 (enumeration) の最中にコレクションの構造が変更されたことにあります。
C#の foreach 文は、内部的に IEnumerator を使用して要素を一つずつ取得しています。
List<T> や Dictionary<TKey, TValue> などのコレクションクラスは、内部で「バージョン番号」という管理用の値を保持しています。
要素の追加 (Add) や削除 (Remove) が行われるたびに、このバージョン番号がインクリメントされる仕組みになっています。
foreach ループが開始されると、列挙子は開始時点のバージョン番号を記録します。
ループの各ステップで MoveNext() メソッドが呼ばれる際、現在のコレクションのバージョン番号と最初に記録した番号が一致するかをチェックします。
もし番号が異なっている場合、「ループ中にデータが書き換えられた」と判断され、データの整合性を守るために例外がスローされるのです。
例外が発生する典型的なNGコード
まずは、実際に例外が発生するコードを確認してみましょう。
以下の例では、リスト内の特定の条件に合致する要素を削除しようとしています。
using System;
using System.Collections.Generic;
var numbers = new List<int> { 1, 2, 3, 4, 5 };
try
{
foreach (var number in numbers)
{
// 要素が偶数の場合に削除を試みる
if (number % 2 == 0)
{
numbers.Remove(number); // ここで例外が発生する
}
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"エラー発生: {ex.Message}");
}
エラー発生: Collection was modified; enumeration operation may not execute.
このコードでは、numbers.Remove(number) が実行された瞬間に List<int> の内部バージョンが更新されます。
次のループの判定時に列挙子が変更を検知し、安全のために処理を中断します。
回避策1:逆順のforループを利用する
最も古典的かつ効率的な解決策の一つは、foreach ではなく for ループを使用することです。
ただし、通常の for ループで先頭から削除を行うと、削除した要素以降のインデックスがずれてしまい、スキップされる要素が出てくる問題があります。
そのため、コレクションの末尾から先頭に向かってループを回すのが定石です。
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 末尾からループを回すことでインデックスのズレを防ぐ
for (int i = numbers.Count - 1; i >= 0; i--)
{
if (numbers[i] % 2 == 0)
{
numbers.RemoveAt(i);
}
}
Console.WriteLine($"残った要素の数: {numbers.Count}");
残った要素の数: 3
この方法であれば、IEnumerator を使用しないため、要素を削除しても例外は発生しません。
回避策2:ToListメソッドでコピーを作成する
LINQの ToList() メソッドを使用して、元のコレクションの「コピー」に対してループを回す方法も非常に一般的です。
コピーされたリストを列挙し、変更操作は元のリストに対して行うため、列挙子のバージョンチェックに抵触しません。
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// ToList() で現在の状態をコピーしてからループする
foreach (var number in numbers.ToList())
{
if (number % 2 == 0)
{
numbers.Remove(number);
}
}
この手法はコードが読みやすくなるというメリットがありますが、要素数が多い場合はメモリの消費量が増える点に注意が必要です。
一時的なコピーを作成するコストが許容できる範囲であれば、最も簡潔な記述方法と言えます。
回避策3:RemoveAllメソッドを活用する
リストから特定の条件に一致する要素を一括で削除したい場合、専用の RemoveAll メソッドを使用するのが最もクリーンです。
このメソッドは内部的に最適化されており、ループ処理と削除処理を安全かつ高速に実行します。
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 述語 (Predicate) を指定して条件に合うものを一括削除
numbers.RemoveAll(n => n % 2 == 0);
Console.WriteLine(string.Join(", ", numbers));
1, 3, 5
自分でループを書く必要がないため、バグが混入するリスクを大幅に低減できます。
回避策4:新しいコレクションを生成する
元のリストを書き換えるのではなく、条件に合う要素だけを抽出して新しいリストを作成する「非破壊的」なアプローチも推奨されます。
LINQの Where メソッドを使用することで、副作用のない直感的なコードが記述可能です。
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 奇数だけを残した新しいリストを作成する
var filteredNumbers = numbers.Where(n => n % 2 != 0).ToList();
関数型プログラミングの考え方に近く、データの不変性を保ちたい場合に非常に有効な手段です。
マルチスレッド環境での注意点
ここまで紹介した対策は主にシングルスレッド環境を想定したものですが、複数のスレッドから一つのコレクションを操作する場合も同様の例外が発生します。
あるスレッドが foreach で列挙している最中に、別のスレッドが要素を追加しようとすると例外がスローされます。
このようなケースでは、lock 文を使用してコレクションへのアクセスを同期するか、System.Collections.Concurrent 名前空間のクラスを検討してください。
例えば ConcurrentBag<T> や ConcurrentDictionary<TKey, TValue> は、スレッドセーフな設計がなされており、列挙中の変更に対しても比較的柔軟に設計されています。
ただし、スレッドセーフなコレクションであっても「列挙中に取得されるデータの最新性」については仕様を確認しておく必要があります。
まとめ
C#の System.InvalidOperationException は、データの不整合を防ぐための安全装置として機能しています。
このエラーに直面した際は、現在のループ処理が「どのコレクションを対象にしているか」を再確認してください。
基本的には RemoveAll メソッドの使用を第一に検討し、それが難しい場合は 逆順 for ループ や コピーの作成 を選択するのがベストプラクティスです。
適切なコレクション操作の手法を選択することで、実行時エラーのない堅牢なコードを構築できるようになります。
開発の効率を高めるためにも、これらの回避策を状況に応じて使い分けられるようにしておきましょう。
