1. はじめに
C#を始めたばかりの人なら、ほぼ全員が一度は同じ問題にぶつかるんだよね。コレクション(例えばオブジェクトのリスト)があって、そこからいらない要素を条件で削除したい。簡単そうに見えるし、つい慣れ親しんだforeachループを使いたくなる。だって一番“安全”で“フレンドリー”な方法に思えるから。でも、ある瞬間に謎の実行時エラーが出て、今まで動いてたサンプルでは起きなかったのに、プログラムが突然止まっちゃうんだよね。
なんでこうなるのか、コレクションやイテレーターの“中身”で何が起きてるのか、そしてどうやって賢く要素を削除してバグやサプライズを避けるか、一緒に見ていこう!
foreachで要素削除がダメな理由
イメージしやすいように、コレクションを人の列だと思ってみて。君はその列を順番に歩いて「君は残す?消す?」って聞いていく。もし歩きながら誰かを消しちゃうと、列がズレて人が動いちゃうから、「次の人=次のリストの人」って計画がすぐ崩れる。誰かをスキップしたり、同じ人を2回聞いちゃうかも。
C#の例:
List<string> names = new List<string> { "アントン", "ボリス", "ヴィカ", "グリシャ" };
foreach (string name in names)
{
if (name.StartsWith("ヴ"))
names.Remove(name); // バーン!InvalidOperationException
}
プログラムが"ヴィカ"に来て削除しようとすると、内部のイテレーターが“現実との接点”を失って、こんなエラーが出るよ:
InvalidOperationException: Collection was modified; enumeration operation may not execute.
これはただの気まぐれじゃなくて、C#が君を見えにくいバグやデータ構造の破損から守ってくれてるんだ。
2. なんでこんなシンプルなコードが動かないの?
内部で何が起きてるの?
foreachループを書くと、コンパイラは特別なオブジェクト――イテレーター(IEnumerator)を作るんだ。これはコレクションの今の位置を覚えてて、最初にいくつ要素があったか、今どれを見てるかを管理してる。そして、列挙中にコレクションが変わらないように厳しくチェックしてる。
foreachの中で要素を削除したり追加したりすると、この約束が壊れちゃう。なんでかっていうと、削除した後でインデックスがズレると、イテレーターは次の要素に正しく進めなくなる。誰かをスキップしたり、2回カウントしたり、もうグチャグチャ。だから.NETは最初の変更でちゃんとエラーを投げてくれるんだ。
“力技”で削除するとどうなる?
例えばこんなプログラムを書いたとしよう:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
foreach (int x in numbers)
{
if (x % 2 == 0)
numbers.Remove(x);
}
一見、全部の数字を見て偶数を消すだけに見える。でも2回目のループで「コレクションが列挙中に変更された」ってエラーが出る。
この警告を無視して“自己責任”でやってみたくなるかもしれない。でも、もしエラーが出なくても、コレクションの構造によっては結果が予測できなくなる。例えば、いくつかの要素をスキップしたり、全部消せなかったりするかも。
3. じゃあどうやるのが正解?
テクニック1:逆順forループ
要素を削除すると、後ろの要素が左にズレるから、先頭から進むとインデックスがズレて要素をスキップしやすい。だから、後ろから進むのが賢い方法。
List<string> names = new List<string> { "アントン", "ボリス", "ヴィカ", "グリシャ" };
for (int i = names.Count - 1; i >= 0; i--)
{
if (names[i].StartsWith("ヴ"))
names.RemoveAt(i);
}
この例だと、削除した後で後ろの要素がズレても、まだ見てないインデックスには影響しない。だから何もスキップしないで済むよ。
テクニック2:フィルタして新しいリストを作る
時には、コレクションを一度全部見て、残したい要素だけ集めて新しいリストにする方が簡単で速いこともある。
var names = new List<string> { "アントン", "ボリス", "ヴィカ", "グリシャ" };
names = names.Where(name => !name.StartsWith("ヴ")).ToList();
// 結果は "アントン" と "グリシャ" だけ残る
コレクションがそんなに大きくないとか、元の参照を気にしないならこの方法がオススメ。
テクニック3:コレクションの専用メソッドを使う
もしList<T>を使ってるなら、条件付き削除に便利なメソッドがあるよ:
names.RemoveAll(name => name.StartsWith("ヴ"));
内部でちゃんと処理してくれるから、コードもスッキリで分かりやすい。
テクニック4:削除リストを作る
DictionaryやHashSet、自作クラスみたいに“その場で”変更できないコレクションもある。そんな時は「削除予定リスト」を使う:
- まずコレクションを全部見て、削除したい要素を別リストに集める。
- その新しいリストを使って、元のコレクションから削除する。
Dictionary<int, string> dict = new Dictionary<int, string> { [1] = "いち", [2] = "に", [3] = "さん" };
var toDelete = new List<int>();
foreach (var kvp in dict)
{
if (kvp.Key % 2 == 0)
toDelete.Add(kvp.Key);
}
foreach (var key in toDelete)
dict.Remove(key);
4. 役立つポイント
初心者がやりがちなミスや思い込み
よくあるミスの一つは、「列挙中に要素を削除しても“なんとかなる”」と思っちゃうこと。他の言語(例えばPython)だとできる場合もあるけど、C#では絶対ダメ。これは君の安全のためで、静かなバグよりも明確なエラーの方がずっとマシだから。
もう一つのミスは、インデックスを増やしながらforループを使うこと。これだと削除後に後ろの要素がズレて、いくつかスキップしちゃう。インデックスで削除するなら必ず後ろから前に進もう。
この話の教訓
「条件でコレクションから要素を削除する」ってC#のプログラムなら2回に1回は出てくる。でもforeachループの中で直接やっちゃダメ――これはデータの整合性と予期しないエラーを防ぐための言語の設計なんだ。
このルールを覚えておけば、デバッガーと徹夜することも減るはず!
いつも正しくやるためのポイント
- foreachループの中でコレクションの要素を直接削除しないこと。実行時エラーになるよ。
- List<T>や配列なら、後ろからforループを使うか、RemoveAllやLINQのフィルタを使おう。
- 辞書やセット、他の複雑なコレクションなら、まず削除予定リストを作ってから元のコレクションから削除しよう。
- 迷ったら、「削除でコレクションはどう変わる?イテレーターはどう動く?」って考えてみて。ちょっとでも不安なら、やり方を変えた方がいいよ。
GO TO FULL VERSION