【テクニカル・上級編】初心者向け:VB.NETにおけるFor EachループとForループのパフォーマンスと安全性:コレクション走査中の要素削除バグを防ぐ – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

コレクション走査の罠:For EachとForループの深淵、そして安全なる破壊の作法

レガシーなVB 6.0時代のVBAコードから最新の.NET 8に至るまで、我々業務システム開発者が最も頻繁に書く処理、それが「コレクションの走査」だ。
画面グリッドの行走査、メモリ上のデータテーブル、あるいは外部APIから取得したJSONをデシリアライズしたリスト。データを舐め、条件に合致するものを加工あるいは削除する。この極めてプリミティブな処理において、未だに多くのプログラマが「見えない地雷」を踏み抜いている。

今回は、VB.NETにおける`For Each`ループと`For`ループの挙動の差異、そして「コレクション走査中の要素削除」という、百戦錬磨のエンジニアですら一瞬の油断でシステムをクラッシュさせる悪名高いバグのメカニズムと、その極限の回避策について解説する。

1. なぜ「走りながら足場を壊す」と崩壊するのか?

まず、大前提として認識すべきは、コレクション(`List(Of T)`や`Dictionary`など)の内部構造である。
これらは連続したメモリ領域、あるいはハッシュ構造のポインタ配列によって保持されている。

ループを回している最中に、その場で要素を削除(Remove)または追加(Add)すると何が起きるか。
想像してほしい。10人並んだ人間の足元から、順番に床板を抜き取っていくようなものだ。インデックスのズレが生じるか、あるいはイテレータが監視しているバージョン情報(Version)と実体の不整合が発生する。

`For Each`の鉄壁の防衛:InvalidOperationException

VB.NETの`For Each`(内部的には`IEnumerable` / `IEnumerator`)は、極めて安全に設計されている。
コレクションが走査されている最中に要素数が変更されたことを検知すると、容赦なく以下の例外を吐いてプロセスを強制停止させる。

> `System.InvalidOperationException: コレクションが変更されました。列挙操作は実行され続けない可能性があります。`

これは一見すると迷惑なエラーに見えるかもしれないが、アーキテクチャの観点からは「未定義動作によるメモリ破壊や暴走を防ぐための極めて正しい安全装置」である。

2. 禁忌:逆順ループ(Backward Loop)というレガシーなハック

「じゃあ、`For Each`ではなく、インデックスを指定する`For`ループを使えばいいじゃないか。それも後ろから(逆順で)回せば、インデックスはズレないから安全だ」

――業務システムの保守現場では、いまだにこのようなレガシーな知見がドヤ顔で語り継がれている。
確かに、`List(Of T)`のインデックスベースの逆順走査であれば、要素を削除しても「それより前のインデックス」には影響を与えないため、例外を出さずに削除を完遂できる。

しかし、チーフアーキテクトとして言わせてもらえば、これは「メモリ最適化の観点や、モダンなオブジェクト指向の設計思想に反する技術的負債の先送り」に他ならない。

以下のコードを見てほしい。これが現場でよく見かける「逆順ループによる削除」のアンチパターンだ。

‘ 【アンチパターン】逆順Forループによる要素削除
‘ 動的なインデックス操作は、コードの意図を曖昧にし、メモリ効率や可読性を著しく低下させる。
Dim targetList As New List(Of Integer) From {10, 15, 20, 25, 30}

‘ 逆順でループを回す
For i As Integer = targetList.Count – 1 To 0 Step -1
If targetList(i) Mod 2 = 0 Then
‘ 偶数を削除
targetList.RemoveAt(i)
End If
Next

この手法の何が問題か。
1. 可読性の欠如: なぜ逆順で回さなければならないのか、初見のプログラマには意図が伝わらない。
2. 抽象度の低下: `List(Of T)`などのインデックスを持つコレクションにしか適用できず、`HashSet`や`Dictionary`などの非インデックス型コレクションに変更した瞬間、コード全体が完全に破綻する。
3. カプセル化の破壊: 呼び出し側がコレクションの内部構造(インデックス)に依存しすぎており、保守性が最悪のスパゲッティコードへと変貌する。

3. 極限の解:安全かつエレガントに要素を削除する作法

では、我々はどのようにコレクションを走査し、要素を排除すべきなのか。
プロフェッショナルなVB.NETエンジニアが実践すべき、状況別の3つのアプローチを提示する。

アプローチ A:LINQの `RemoveAll` メソッド(最も推奨されるモダンな方法)

`List(Of T)`を使用している場合、最も高速かつ安全なのは、LINQのプレディケート(条件式)ベースの`RemoveAll`メソッドを使うことだ。
内部最適化されており、無駄なループオーバーヘッドが発生しない。

‘ 【推奨アプローチ A】RemoveAllによる一括削除
Dim dataList As New List(Of Integer) From {10, 15, 20, 25, 30}

‘ 偶数(2で割り切れる要素)を条件に一括削除
‘ 内部的にメモリコピーとインデックス再配置が最小限のコストで実行される。
dataList.RemoveAll(Function(x) x Mod 2 = 0)

‘ 結果の確認
For Each item In dataList
Console.WriteLine(item) ‘ 出力: 15, 25
Next

アプローチ B:退避用コレクション(別リスト)の構築

複雑な条件判定や、削除に伴い外部リソースの解放(例:`IDisposable`なオブジェクトの`Dispose()`呼び出し)が必要な場合は、削除対象を別リストに溜めておく、あるいは残すものだけを新しいリストに抽出するのが鉄則だ。

‘ 【推奨アプローチ B】Where句を用いたフィルタリング(イミュータブルなアプローチ)
Dim processedList As New List(Of CustomerData)
‘ (中略: データの詰め込み)

‘ 削除ではなく「残すものだけを抽出して新しいインスタンスに置き換える」
‘ メモリの割り当てが発生するが、マルチスレッド環境や複雑な業務ロジックにおいて最も安全。
Dim activeList As List(Of CustomerData) = processedList.Where(Function(c) c.IsActive = True).ToList()

‘ ※もし古いリストのオブジェクト群がIDisposableを実装しているなら、
‘  漏れた要素に対して明示的にDisposeを呼び出すこと。

アプローチ C:`Where` との組み合わせによるメモリとガベージコレクション(GC)の意識

大量のデータを扱うバッチ処理やシステム間連携において、無駄なオブジェクト生成はGC(ガベージコレクション)の頻発を招き、アプリケーション全体のパフォーマンス(スループット)を致命的に低下させる。

特にレガシーなWindowsフォームアプリケーションや、長時間稼働するWindowsサービスにおいて、ループ内で安易に`New`キーワードを使ったり、巨大なコレクションのコピーを作り続けることは御法度だ。

もしパフォーマンスが極限まで求められるループ処理内で要素を操作する必要があるならば、コレクション構造そのものを見直し、最初から固定長配列や、スレッドセーフなコレクション(`ConcurrentBag`など)の採用を検討すべきである。

4. チーフアーキテクトからの提言

「動いているから触るな」――レガシーシステムの現場でよく耳にする言葉だ。しかし、コレクションの走査と要素削除におけるバグは、ある日突然、負荷が高まった本番環境で「再現性のない例外」として牙をむく。

`For Each`の例外を回避するために、安易な逆順ループや、その場しのぎのインデックス操作逃れに走るな。
コレクションを操作する際は、以下の鉄則をチーム全体のコーディング規約として徹底せよ。

1. 走査中のコレクション構造の変更(Add / Remove)は絶対に直で行わない。
2. 一括削除には `List.RemoveAll()` を活用し、フレームワークの最適化恩恵を受ける。
3. データ構造の特性(インデックス型か、ハッシュ型か)に依存した実装を避け、抽象化されたAPIを使用する。

コードの美しさは、そのままシステムの堅牢性に直結する。
あなたの書くその一本のループが、数百万件のトランザクションを支える基幹システムの命運を握っていることを忘れてはならない。

タイトルとURLをコピーしました