VB.NETの罠:For Eachループでコレクションを壊す者、生かす者
業務システムの開発現場において、リストやコレクションの走査は毎日のように書く基本中の基本だ。だが、この「基本」のチョイスを誤るだけで、生産現場で突然動かなくなる爆弾をコードに埋め込むことになる。
特に、「コレクションを走査しながら、条件に合致した要素を削除・追加する」という要件。これを`For Each`ループの中で安易にやろうとした瞬間、`.NET`のランタイムは冷酷に`InvalidOperationException`(コレクションが変更されました。操作は実行されない可能性があります。)を叩きつけてくる。
今回は、なぜこの例外が発生するのかというオブジェクトのライフサイクルとメモリ管理の裏側から、実務で絶対に破綻しない堅牢なコレクション操作の極意を、アーキテクトの視点から伝授する。
—
1. なぜ `For Each` 内での要素削除は「御法度」なのか?
まずは、何が起きているのかを把握しよう。
`For Each` ループは、裏側でコレクションの 列挙子(IEnumerator) を生成して要素を順番に取得している。この列挙子は、非常にデリケートな監視者だ。列挙子が走査を開始した時点のコレクションの「バージョン(バージョン番号)」を記憶しており、ループが回るたびに現在のコレクションのバージョンと突き合わせを行っている。
もし、ループの途中で `List.Remove()` などを実行してコレクションの構造(要素数)を変更すると、バージョン番号が書き換わる。
次の瞬間、列挙子は叫ぶ。
「おい、俺の知らないところでコレクションが書き換えられたぞ! データの整合性が保証できないから強制終了する!」
これが、`InvalidOperationException` の正体だ。
❌ やってはいけないアンチパターン(動かないコード)
.net
‘ 【注意】このコードは例外が発生してクラッシュします
Dim userList As New List(Of String) From {“田中”, “佐藤”, “鈴木”, “高橋”, “佐藤”}
For Each name As String In userList
If name = “佐藤” Then
‘ ここでコレクションの構造が変わるため、次のループで例外発生!
userList.Remove(name)
End If
Next
実務の現場で、CSVファイルのインポート処理や、データベースから取得したマスターデータのクリーニング処理で、この書き方をしてテストをすり抜け、本番環境の大量データで爆発したインシデントを私は幾度となく見てきた。
—
2. 逆から回す(逆順 `For` ループ)という古典にして最強の解
「じゃあ、`For Each` を捨てて、昔ながらのインデックスを使った `For` ループを使えばいいじゃないか」
その通り。インデックスベースの `For` ループであれば、バージョン管理の監視者はいない。ただし、ここでも順方向(先頭から末尾)に回すと、要素を削除した瞬間に後ろの要素が前に詰まり、インデックスのズレ(要素のスキップ)という別のバグを生む。
ここでプロの技だ。「末尾から先頭に向かって(逆順に)ループを回す」。これこそが、メモリ効率を落とさず、新たなオブジェクトを生成せずにインプレースで安全に削除を行うための定石である。
⭕ 安全な実装パターン:逆順 `For` ループ
.net
‘ データベースから取得したトランザクションデータの仮リスト
Dim transactions As New List(Of Decimal) From {100, -50, 200, -10, 300}
‘ 【極意】末尾 (Count – 1) から 0 に向かって逆順で走査する
For i As Integer = transactions.Count – 1 To 0 Step -1
‘ 負の値(不正データ)をその場で安全にパージする
If transactions(i) < 0 Then
transactions.RemoveAt(i)
' 削除しても、すでにチェック済みの「前方」のインデックスにしか影響しないため安全
End If
Next
このアプローチは、GC(ガベージコレクション)に余計な負荷をかけず、メモリをバッファリングもしないため、パフォーマンスが極めてシビアなバッチ処理やファイルI/Oのストリーム処理において最強の武器となる。
---
3. LINQ(`RemoveAll` / `Where`)による宣言型アプローチ
現代の VB.NET (.NET Core / .NET 5以降) においては、手続き型でインデックスをいじるよりも、LINQや専用メソッドを使って「どうしたいか(What)」を宣言的に書く方が、コードの保守性と可読性の観点から圧倒的に推奨される。
パターンA: `List(Of T).RemoveAll` を使う(インプレース削除)
リスト自体のインスタンスを維持したまま条件に合うものを一括削除したい場合、`RemoveAll` メソッドが内部で最適なアルゴリズム(O(N)の高速処理)を実行してくれる。
.net
Dim logs As New List(Of String) From {“INFO: Start”, “DEBUG: 1”, “INFO: End”, “DEBUG: 2”}
‘ ラムダ式で「DEBUG」から始まるものを一網打尽に削除
logs.RemoveAll(Function(log) log.StartsWith(“DEBUG:”))
パターンB: `Where` で新しいコレクションを生成する
元のコレクションを汚染せず、条件に合致したクリーンなデータだけを新しく抽出する場合は、`Where` 拡張メソッドを使うのがモダンな .NET の作法だ。
.net
Dim rawData As New List(Of Integer) From {1, 2, 3, 4, 5, 6}
‘ 条件に合うものだけを抽出し、新しい List として射影する
Dim activeData As List(Of Integer) = rawData.Where(Function(x) x Mod 2 = 0).ToList()
—
4. 実務で遭遇するファイル・DB連携時の設計指針
業務アプリケーションでは、このコレクション操作は単なるメモリ上の処理にとどまらない。
1. ファイル読み込み・一括処理のケース
巨大なCSVファイルを読み込んでメモリ上に展開し、バリデーションエラーのある行をコレクションから排除して後続のデータベースへバルクインサートするようなシーン。ここで `For Each` 内での削除を行えば即座にクラッシュする。必ず `RemoveAll` か、有効なレコードだけを格納する「新しいリスト」を構築するパイプライン設計にすること。
2. データベースから取得したエンティティのキャッシュ管理
メモリ上に保持しているマスターデータのキャッシュをスレッドセーフに更新する場合、単純な `List(Of T)` ではなく `ConcurrentBag(Of T)` や `BindingList(Of T)`(UIバインド時)など、用途に応じたコレクションクラスの選定が不可欠となる。
—
まとめ:アーキテクトからの提言
コードの美しさは、単に行数が短いことではない。「予期せぬ例外を絶対に起こさない堅牢性」と「誰が読んでも意図が一瞬で伝わる可読性」のバランスの上に成り立っている。
- コレクションを走査しながら要素を消すときは、`For Each` を使ってはいけない。
- パフォーマンスと省メモリを重視するなら 逆順 `For` ループ。
- 保守性とコードの簡潔さを重視するなら `RemoveAll` や `Where` による LINQ。
この判断基準をチームの標準とせよ。あなたの書くコードが、明日の安定稼働を生み出すのだ。
