【極限の設計】VB.NETにおけるIteratorとYieldの掌握:巨大データ処理でOutOfMemoryExceptionを根絶する「遅延評価」の技術
業務自動化ツールの開発において、エンジニアが最初に直面する「壁」がある。それは、「開発環境では完璧に動いたツールが、本番環境の巨大なデータ(数百万件のログや数GBのCSV)を投入した瞬間に、`OutOfMemoryException`(OOM)を吐いて沈黙する」という事態だ。
もし君が、データをすべて `List(Of T)` に詰め込んでから処理しようと考えているなら、今すぐその設計を捨てなさい。それは「動くコード」ではあっても、「堅牢なプロダクト」ではない。
本稿では、VB.NETの真髄の一つである `Iterator` と `Yield` キーワードを使いこなし、メモリ消費を極限まで抑えながら、膨大なデータを流れるように処理するための「遅延評価(Lazy Evaluation)」の設計思想を伝授する。
1. なぜ君のコードはメモリを食いつぶすのか?
多くの初学者が陥る罠は、「全件ロード(Eager Loading)」という設計思想だ。
致命的なアンチパターン
例えば、100万行あるCSVファイルを読み込む際、以下のようなコードを書いていないか?
' --- 破綻する設計の典型例 ---
Public Function LoadAllData(filePath As String) As List(Of String)
Dim lines As New List(Of String)()
' ファイルの全行を一度にメモリに展開してしまう
Dim allLines = File.ReadAllLines(filePath)
For Each line In allLines
lines.Add(line)
Next
Return lines ' ここでメモリが爆発する
End Function
このコードの何が問題か。`File.ReadAllLines` は、ファイル内の全データをメモリ上に確保しようとする。1GBのファイルがあれば、少なくとも1GB(あるいはそれ以上)のメモリが瞬時に占有される。さらに、それを `List(Of String)` に詰め替えることで、メモリ消費は倍増し、ガベージコレクション(GC)の負荷も極限に達する。これが OOM の正体だ。
プロフェッショナルは、「データは、必要な時に、必要な分だけ、その場で生成する」という思想を持つ。
2. IteratorとYieldによる「遅延評価」のメカニズム
ここで登場するのが `Iterator` と `Yield` だ。これらを使うことで、「イテレータ(反復子)」を実装できる。
`Yield` キーワードの真価は、メソッドの実行を「一時停止」し、呼び出し側に値を一つ返した後、「その状態を保持したまま、次の要求を待つ」という点にある。
遅延評価の概念図
1. 呼び出し側: 「次のデータが欲しい」
2. Iterator: 「(処理を再開して)はい、次のデータです」 `Yield`
3. 呼び出し側: 「(処理を終えて)よし、次だ」
4. Iterator: 「(中断した場所から再開して)はい、次です」 `Yield`
このように、メモリ上には「現在処理中の1件」しか存在しない状態を作り出せるのだ。
3. 実践:巨大CSVファイルをメモリ効率良く読み込む
では、実務でそのまま使える、堅牢なCSV読み込み実装を見てみよう。ここでは `StreamReader` を使い、ファイルハンドルを適切に管理しながら、一行ずつ `Yield` で返す設計にする。
Imports System.IO
Imports System.Text
''' <summary>
''' 大規模データ処理に特化したファイル操作クラス
''' </summary>
Public Class LargeFileProcessor
''' <summary>
''' CSVファイルを一行ずつ遅延評価で読み込む
''' </summary>
''' <param name="filePath">対象ファイルのパス</param>
''' <returns>一行ずつの文字列の列挙</returns>
Public Function ReadLinesLazy(filePath As String) As IEnumerable(Of String)
' Iteratorキーワードを指定することで、このメソッドは状態マシンとしてコンパイルされる
Iterator Function
' Using句により、ループ終了時または例外発生時に確実にファイルハンドルを解放する
Using reader As New StreamReader(filePath, Encoding.UTF8)
While Not reader.EndOfStream
Dim line As String = reader.ReadLine()
If line IsNot Nothing Then
' Yieldによって、現在の行を返し、メソッドの実行状態を一時停止する
' 次の呼び出し(MoveNext)が来るまで、メモリは消費されない
Yield line
End If
End While
End Using
End Function()
' ※VB.NETの関数内Iteratorは、関数定義そのものにIteratorを付与する
' 上記は構造を分かりやすくするための擬似的な記述。
' 正しくは以下の形式となる。
End Function
' --- 正確な構文での実装 ---
Public Iterator Function StreamCsvLines(filePath As String) As IEnumerable(Of String)
If Not File.Exists(filePath) Then
Throw New FileNotFoundException("対象ファイルが見つかりません。", filePath)
End If
Using reader As New StreamReader(filePath, Encoding.UTF8)
While Not reader.EndOfStream
Dim line = reader.ReadLine()
If Not String.IsNullOrWhiteSpace(line) Then
Yield line
End If
End While
End Using
End Function
End Class
利用側のコード(メインロジック)
呼び出し側は、`For Each` を使うだけで良い。データが1億行あっても、メモリ使用量はほぼ一定だ。
Public Sub ProcessLargeFile()
Dim processor As New LargeFileProcessor()
Dim targetPath As String = "C:\Data\HugeLogFile.csv"
Try
' IEnumerableとして受け取るため、この時点ではまだファイルは読み込まれていない(遅延評価)
Dim lines = processor.StreamCsvLines(targetPath)
Dim count As Long = 0
For Each line In lines
' ここで一行ずつの処理を行う
' メモリには常に「現在のline」しか載っていない
ProcessData(line)
count += 1
If count Mod 10000 = 0 Then
Console.WriteLine($"{count} 件処理済み...")
End If
Next
Console.WriteLine("全処理が完了しました。")
Catch ex As Exception
Console.WriteLine($"致命的なエラーが発生しました: {ex.Message}")
End Try
End Sub
Private Sub ProcessData(line As String)
' 重い処理のシミュレーション
End Sub
4. データベース連携における「プロフェッショナルの作法」
CSVに限らず、DBからのデータ取得も同様だ。`DataTable` に `Fill` するのは素人の仕事だ。数百万件のレコードを `DataTable` に入れた瞬間、サーバーのメモリは枯渇する。
プロフェッショナルは、`SqlDataReader` を `Iterator` でラップし、レコードを「ストリーム」として扱う。
''' <summary>
''' DBから大量のレコードを低メモリ消費で取得する
''' </summary>
Public Iterator Function StreamDatabaseRecords(connectionString As String, sql As String) As IEnumerable(Of MyDataModel)
Using conn As New SqlConnection(connectionString)
Using cmd As New SqlCommand(sql, conn)
conn.Open()
' CommandBehavior.CloseConnection を指定することで、Readerを閉じると接続も閉じる
Using reader As SqlDataReader = cmd.ExecuteReader(CommandBehavior.CloseConnection)
While reader.Read()
' 1行分だけオブジェクトを生成して返す
Yield New MyDataModel With {
.Id = reader.GetInt32(0),
.Value = reader.GetString(1)
}
End While
End Using
End Using
End Using
End Function
5. アーキテクトからの警告:Iterator使用時の「落とし穴」
`Yield` は魔法ではない。設計ミスをすれば、逆にバグの温床となる。以下の3点は必ず肝に銘じておけ。
① 二重評価のコスト
`IEnumerable` は「命令のリスト」であって「データのリスト」ではない。
Dim lines = processor.StreamCsvLines("data.csv")
' 1回目のループ(ファイル読み込みが発生)
For Each line In lines : Next
' 2回目のループ(再びファイルを開いて最初から読み直す!)
For Each line In lines : Next
もし同じデータを何度も使い回したいなら、`ToList()` を呼んでメモリに展開するか、設計を見直せ。ただし、展開すればメモリ問題は再発する。
② リソース解放のタイミング
`Using` ブロックの中で `Yield` を使う場合、「呼び出し側がループを途中で抜けると、`Using` の終了処理(Dispose)が走る」という挙動を理解せよ。
`For Each` を `Exit For` で抜けた場合でも、`StreamReader` は正しく閉じられる。これは `Iterator` の優れた設計だが、もしループの外でリソースを管理しようとすると、リソースリークを引き起こす。
③ 状態の不変性
`Iterator` 内で保持している変数は、`Yield` を跨いで状態が維持される。ループの途中で外部からそのオブジェクトの状態を書き換えるような設計は、非決定的なバグを生む。`Iterator` は、純粋に「データの流れ」に集中させるべきだ。
結論
大量のデータを扱うアプリケーションにおいて、メモリ管理は「おまけ」ではない。「アプリケーションの品質そのもの」だ。
- 全件ロードするな。
- `IEnumerable(Of T)` と `Yield` を使い、データのストリームを作れ。
- `Using` を徹底し、リソースのライフサイクルを制御せよ。
この原則を守るだけで、君が作るツールは、数千件のデータでも数億件のデータでも、同じように優雅に、そして堅牢に動作し続けるだろう。
