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

スポンサーリンク

【VB.NET極限講座】For Eachの罠:コレクション走査中の要素削除バグとメモリ構造の真実

レガシーなVBAシステムの近代化、あるいは堅牢なデスクトップアプリケーションの構築において、VB.NETは今なお現場の主軸であり続けている。しかし、言語仕様の表面的なキャッチアップだけでコードを書くプログラマは、往々にして「予期せぬ例外」や「メモリリーク」という名の地雷原で足元をすくわれる。

今回は、日々のコーディングで最も頻繁に遭遇する、しかしその内部挙動まで理解している者が驚くほど少ないテーマを取り上げる。
「For EachループとForループのパフォーマンス、そしてコレクション走査中の要素削除が引き起こす破壊的バグとその回避策」である。

伝説的なチーフアーキテクトの視点から、CLR(Common Language Runtime)のメモリ管理、イテレータの裏側、そして実務で即座に使える極限の安全性を持った実装パターンを紐解く。

1. なぜ `InvalidOperationException` は発生するのか?

コレクション(`List(Of T)` や `Dictionary` など)を `For Each` で回している最中に、そのコレクションから要素を削除、または追加しようとした瞬間、以下の容赦ない例外が飛んできた経験はないだろうか?

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

このエラーの本質を理解するには、VB.NETの `For Each` が背後で何をしているのかを知らなければならない。

`For Each` の実体:IEnumerable と Enumerator の契約

VB.NETの `For Each` は、シンタックスシュガー(糖衣構文)に過ぎない。コンパイル時、それは `GetEnumerator()` メソッドを呼び出し、`IEnumerator` インターフェイスを介した走査コードに変換される。

.net
‘ 私たちが書くコード
For Each item As String in myCollection
Console.WriteLine(item)
Next

‘ CLRが裏側で実行している擬似的なC# / VB.NETコード
Dim enumerator As IEnumerator(Of String) = myCollection.GetEnumerator()
Try
While enumerator.MoveNext()
Dim item As String = enumerator.Current
Console.WriteLine(item)
End While
Finally
If enumerator IsNot Nothing Then
enumerator.Dispose()
End If
End Try

ここで重要なのが 「バージョン管理(Version Tracking)」 のメカニズムだ。
`List(Of T)` などの標準コレクションは、内部に `version` というカウンターを持っている。要素が追加・削除されるたびにこの `version` がインクリメントされる。
Enumeratorは生成された時点の `version` を記憶しており、`MoveNext()` が呼ばれるたびに「現在のコレクションのバージョン」と「記憶しているバージョン」を比較する。もし一致しなければ、「他のスレッドまたはコードによってコレクションが破壊的に変更された」と判断し、即座に `InvalidOperationException` をスローするのだ。

これはマルチスレッド環境でのデータ競合を防ぐための「フェイルファスト(Fail-Fast)」設計であるが、単一スレッド内のループ処理であっても容赦なく発動する。

2. 禁忌:For Each 内での直接削除

次のようなコードは、絶対に書いてはならないアンチパターンである。

.net
‘ 【絶対にやってはいけない例】
Dim targets As New List(Of Integer) From {1, 2, 3, 4, 5}

For Each num As Integer In targets
If num Mod 2 = 0 Then
targets.Remove(num) ‘ ここで InvalidOperationException が発生する!
End If
Next

「じゃあ、昔ながらの `For` ループで後ろから回せばいいだろう」というベテラン(あるいはVBA上がり)の開発者の声が聞こえてきそうだ。確かに、インデックスを使った逆順ループであれば例外は回避できる。

.net
‘ 【従来の逆順Forループによる回避策】
For i As Integer = targets.Count – 1 To 0 Step -1
If targets(i) Mod 2 = 0 Then
targets.RemoveAt(i) ‘ 例外は起きないが……
End If
Next

この手法は `List(Of T)` や配列であれば機能する。しかし、これは根本的な解決ではなく、単なるその場しのぎのテクニックに過ぎない。
もし対象のコレクションが `LinkedList(Of T)` や `Dictionary(Of TKey, TValue)`、あるいはネットワーク経由のストリーム系コレクションであった場合、インデックスアクセス(`targets(i)`)自体がサポートされていないか、O(N^2) の悲劇的なパフォーマンス劣化を引き起こす。

3. シニアエンジニアが選ぶべき「真の解決策」

実務において、コレクションを安全かつ高速にフィルタリング・要素削除するためのアプローチはいくつか存在する。システムのアーキテクチャやパフォーマンス要件に応じて使い分けるべきである。

アプローチ A:LINQ `Where` による非破壊的アプローチ(推奨)

メモリに余裕があり、新しいコレクションを生成しても問題ない一般的な業務アプリケーションであれば、LINQの `Where` を使って「残したいものだけを抽出した新しいリスト」を作るのが最も安全でバグを生みにくい。

.net
Dim targets As New List(Of Integer) From {1, 2, 3, 4, 5}

‘ 偶数以外(奇数)を抽出して新しいリストを生成
Dim filteredList As List(Of Integer) = targets.Where(Function(n) n Mod 2 <> 0).ToList()

‘ 元の変数に差し替える
targets = filteredList

  • メリット: コードが宣言的で読みやすく、`InvalidOperationException` のリスクがゼロになる。
  • デメリット: 参照の付け替えが発生するため、他のオブジェクトが元の `List` インスタンスを直接参照している場合は注意が必要。

アプローチ B:`List(Of T).RemoveAll` メソッド(高速・安全)

もし `List(Of T)` を使っており、条件に合致する要素をごっそり削除したい場合は、内部で高度に最適化された `RemoveAll` 述語メソッドを使用するのがベストプラクティスだ。

.net
Dim targets As New List(Of Integer) From {1, 2, 3, 4, 5}

‘ 偶数を一括削除(内部ループでメモリコピーを最小化して高速処理)
targets.RemoveAll(Function(n) n Mod 2 = 0)

  • メリット: .NET Framework / .NET Core の内部最適化が効くため、自前でループを書くより圧倒的に高速。

アプローチ C:破壊的変更が必要な場合のコピー走査

「どうしても既存のコレクションインスタンスを維持したまま、ループ内で複雑な条件分岐とともに要素を削ぎ落としたい」という極限のケースでは、走査用のコピー(スナップショット)を作成してループを回し、削除は元のコレクションに対して行う。

.net
Dim targets As New List(Of Integer) From {1, 2, 3, 4, 5}

‘ ループ用にコレクションの複製(配列または別リスト)を作成
For Each num As Integer In targets.ToArray()
If num Mod 2 = 0 Then
‘ 削除は元のコレクションに対して行うため、Enumeratorは壊れない
targets.Remove(num)
End If
Next

  • アーキテクチャ上の注意: コレクションの要素数が数万件を超える場合、`ToArray()` によるアロケーションがGC(ガベージコレクション)のヒープ領域にプレッシャーを与える。高頻度で実行されるバッチ処理等のコアロジックでは避けるべきである。

4. パフォーマンスとメモリ管理の極意(プロフェッショナル・インサイト)

最後に、基幹システムやデスクトップ常駐型のツールを開発する上で知っておくべき、メモリ最適化の極意に触れておこう。

1. ガベージコレクション(GC)の抑制:
頻繁に `New List(Of T)` や `ToArray()` をループ内で呼び出すと、Gen 0 ヒープがゴミだらけになり、頻繁なGC発生(ストップ・ザ・ワールド)によるUIのフリーズや処理遅延を引き起こす。大量データを扱うバッチ処理では、`List(Of T)` の容量(Capacity)をあらかじめコンストラクタで指定し、不要な再割り当て(Reallocation)を防ぐこと。
2. 構造体の活用とボクシング(Boxing)の回避:
`List(Of Object)` や `ArrayList` などのレガシーなコレクションを使用すると、値型(Integerなど)がヒープに「ボクシング」され、パフォーマンスが劇的に悪化する。.NET 2.0以降は必ずジェネリック(`List(Of T)`)を使用し、型の安全性を担保すると同時にメモリオーバヘッドを排除すること。

総括

VB.NETにおける `For Each` は強力な武器である反面、その背後にあるCLRのイテレータ契約とバージョン管理の仕組みを理解していない者にとっては、予期せぬクラッシュを引き起こす諸刃の剣となる。

  • 走査中の要素の直接削除・追加は `InvalidOperationException` を招くため絶対にしない。
  • 一括削除なら `RemoveAll`、抽出なら `LINQ (Where)` を活用する。
  • パフォーマンスとメモリフットプリントを常に意識し、安易なアロケーションを避ける。

この知見を胸に、あなたのコードベースからバグの芽を徹底的に排除し、美しく堅牢なシステムを構築し続けてほしい。技術の真髄を極めたエンジニアに、妥協の二文字は存在しない。

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