【実務・中級編】VB.NETのYieldキーワードを用いたイテレータ(Iterator)の実装:大規模データをメモリ効率よく遅延評価で返すカスタム列挙の作り方 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

VB.NETのYieldイテレータ極限活用:巨大データをメモリ枯渇から救う遅延評価のアーキテクチャ

開発現場でよく見かける光景がある。数百万件もある巨大なCSVファイル、あるいは数万件のレコードを持つデータベースのテーブルに対し、愚直に全件を `List(Of T)` にロードしてループを回すコードだ。

‘ 【悪夢のアンチパターン】全件メモリロード
Dim allRecords As List(Of Record) = LoadHugeDataFromDatabase() ‘ ここでメモリが爆発する
For Each record As var In allRecords
‘ 処理…
Next

このコードが本番環境でどうなるか。データ量が膨らんだ瞬間、`OutOfMemoryException` が発生し、業務アプリケーションは沈黙する。GC(ガベージコレクタ)は悲鳴を上げ、サーバーのCPU使用率は跳ね上がり、最終的にはプロセスが強制終了する。

プログラミング初心者や「動けばいい」と妥協するエンジニアは、ここでメモリ増設という名の逃げの手段を選ぶ。しかし、私たちプロフェッショナルな業務自動化エンジニアの答えは違う。「データを一気に抱え込むな。必要な瞬間だけ生成して流せ(遅延評価)」

今回は、VB.NETが持つ最強の武器の一つである `Iterator` と `Yield` を駆使し、メモリフットプリントを極限まで抑えた堅牢なカスタム列挙の実装手法を叩き込む。

1. なぜ `Yield` なのか?(遅延評価のメカニズム)

通常のメソッドは、`Return` が実行された瞬間にすべての処理を終え、呼び出し元に制御とデータを一括で返す。これに対し、`Iterator Function`(イテレータ関数)は、`Yield` ステートメントに到達するたびに、呼び出し元へ「1件のデータ」を返却しつつ、自身の実行状態(ローカル変数の値やループのカウンタなど)を保持したまま一時停止する。

呼び出し元が `For Each` で次の要素を要求すると、イテレータは直前の停止位置から何事もなかったかのように処理を再開する。

この仕組みにより、以下の圧倒的なメリットが生まれる。

  • O(1) のメモリ空間: データが100万件あろうが1億件あろうが、メモリ上に常駐するのは「たった1件分のデータ」のみ。
  • 初動の高速化: 最初の1件が見つかった瞬間に処理を開始できるため、全件ロードを待つタイムラグがゼロになる。
  • 無限シーケンスの表現: 終わりがないデータストリーム(センサーログの監視など)すら安全に扱える。

2. 実践:巨大CSVファイルをメモリ効率よくパースするカスタム列挙

実務で最も遭遇する「巨大ファイルの走査」を例に取ろう。数GBあるCSVファイルを1行ずつ安全に読み込み、条件に合致するデータだけを遅延評価で返すプロダクションコードを提示する。

以下のコードは、コピー&ペーストしてそのままプロジェクトに組み込めるレベルの堅牢性を持たせてある。

Imports System.IO
Imports System.Collections.Generic

Public Class CsvReaderService

”’

”’ 巨大なCSVファイルを1行ずつ遅延評価で読み込み、指定条件のデータを返却する
”’

”’ 対象のファイルパス ”’ ヘッダー行をスキップするか ”’ パースされたレコードのシーケンス
Public Iterator Function ReadLargeCsv(filePath As String, skipHeader As Boolean) As IEnumerable(Of CsvRecord)

‘ ファイルが存在しない場合の早期リターン(例外の明確化)
If Not File.Exists(filePath) Then
Throw New FileNotFoundException($”指定されたファイルが見つかりません: {filePath}”)
End If

‘ StreamReaderを用いたストリーム読み込み(全件メモリロードを回避)
‘ Usingブロックにより、イテレータが途中で破棄されても確実にリソースが解放される
Using reader As New StreamReader(filePath, System.Text.Encoding.UTF8)

Dim isFirstLine As Boolean = True

While Not reader.EndOfStream
Dim line As String = reader.ReadLine()

‘ ヘッダー処理
If isFirstLine Then
isFirstLine = False
If skipHeader Then Continue While
End If

‘ 空行のスキップ
If String.IsNullOrWhiteSpace(line) Then Continue While

‘ カンマ区切りのパース(簡易実装)
Dim columns() As String = line.Split(“,”c)

‘ ドメインモデルへのマッピング
Dim record As New CsvRecordWithDetails(columns)

‘ Yieldで呼び出し元に1件ずつ「引き渡す」
‘ ここで処理が一時停止し、呼び出し元のFor Eachループが1回回る
Yield record

End While
End Using

End Function

End Class

‘ データ構造を表現するダミーのレコードクラス
Public Class CsvRecordWithDetails
Public Property Id As String
Public Property Name As String
Public Property Value As Decimal

Public Sub New(columns() As String)
If columns.Length >= 3 Then
Me.Id = columns(0).Trim()
Me.Name = columns(1).Trim()
Decimal.TryParse(columns(2), Me.Value)
End If
End Sub
End Class

このコードのアーキテクチャ的解説

1. `Using` と `Iterator` の共存:
初心者によくあるバグが、`StreamReader` を `Using` で囲んだつもりが、`Yield` を使ったためにコンパイルエラーになる、あるいはリソースリークを起こすケースだ。VB.NETのコンパイラは `Iterator` 内の `Using` を適切にステートマシンに翻訳し、`For Each` のイテレーションが途中でbreakされたり例外で中断されたりした場合でも、確実に `Dispose` を呼び出す安全なコードを生成する。
2. メモリの定常化:
`File.ReadAllLines` や `File.ReadAllText` は絶対に使うな。あれらはファイル全体をメモリ上に文字列配列として展開する爆弾だ。`StreamReader.ReadLine()` と `Yield` の組み合わせこそが、メモリ効率の最適解である。

3. データベース連携における注意点:接続のライフサイクル管理

データベース(SQL Server / SQLite / Oracleなど)からデータを `Yield` で返したいという要望も非常に多い。ここで最も注意すべきは「データベース接続(Connection)とDataReaderのライフサイクル管理」である。

DBからデータを取得する場合、接続を開いたままイテレーションを回す必要があるが、接続のクローズを怠るとコネクションプールが枯渇する。

安全なデータベース遅延評価の実装パターンを以下に示す。

Imports System.Data.SqlClient
Imports System.Collections.Generic

Public Class DatabaseStreamingRepository

Private ReadOnly _connectionString As String

Public Sub New(connectionString As String)
_connectionString = connectionString
End Sub

”’

”’ データベースから大量レコードをストリー밍形式(遅延評価)で取得する
”’

Public Iterator Function GetActiveUsersStream() As IEnumerable(Of UserEntity)

Using connection As New SqlConnection(_connectionString)
connection.Open()

Using command As New SqlCommand(“SELECT Id, UserName, LastLoginDate FROM Users WHERE IsActive = 1”, connection)

‘ CommandBehavior.CloseConnectionを指定することで、
‘ DataReaderが閉じられた時に自動的にSqlConnectionもクローズされる
Using reader As SqlDataReader = command.ExecuteReader(CommandBehavior.CloseConnection)

While reader.Read()
Dim user As New UserEntityWithData(
Id:=reader.GetInt32(0),
UserName:=reader.GetString(1),
LastLoginDate:=If(reader.IsDBNull(2), CType(Nothing, DateTime?), reader.GetDateTime(2))
)

‘ 1行ごとに評価・返却
Yield user
End While

End Using
End Using
‘ Usingにより、ここで確実にコネクションとリーダーが破棄される
End Using

End Function

End Class

Public Class UserEntityWithData
Public Property Id As Integer
Public Property UserName As String
Public Property LastLoginDate As DateTime?

Public Sub New(id As Integer, userName As String, lastLoginDate As DateTime?)
Me.Id = id
Me.UserName = userName
Me.LastLoginDate = lastLoginDate
End Sub
End Class

アーキテクチャ上の極意:`CommandBehavior.CloseConnection`

`Yield` を使うイテレータ内では、メソッドが途中で「一時停止」するため、メソッドのスコープを抜けたタイミングでの自動クローズが機能しにくい。
そのため、`SqlDataReader` の生成時に `CommandBehavior.CloseConnection` を明示的に渡し、リーダーのライフサイクルとコネクションのライフサイクルを完全に同期させるのが、プロフェッショナルの実装テクニックである。

4. 現場で嵌るアンチパターンとデバッグの罠

`Yield` イテレータは強力だが、その裏で動作する「ステートマシン(状態機械)」の挙動を理解していないと、以下のような不可解なバグに直面する。

罠1: 例外の発生タイミングのズレ

通常のメソッドであれば、メソッドが呼び出された瞬間にパラメータのバリデーションエラー等の例外が発生する。しかし、`Iterator` 関数は `For Each` が回り始める(最初の `.MoveNext()` が呼ばれる)まで、関数内のコードは1行も実行されない。

‘ この時点では、ファイルが存在しなくても例外はスローされない!
Dim query = readerService.ReadLargeCsv(“non_existent.csv”, True)

Console.WriteLine(“処理を開始します…”)

‘ ここで初めて .MoveNext() が走るため、ここで例外が爆発する
For Each item In query
‘ …
Next

対策: パラメータのチェックなど、早期に検出すべきバリデーションは、イテレータ関数そのものではなく、それをラップする通常のメソッド(ラッパーメソッド)で行う設計にすること。

罠2: `ToList()` や `Count()` の安易な呼び出し

「遅延評価でメモリに優しいから」と言って、以下のようなコードを書くエンジニアがいる。

Dim query = readerService.ReadLargeCsv(“huge.csv”, True)
Dim count = query.Count() ‘ 全件スキャンが発生
Dim list = query.ToList() ‘ 結局全件メモリにロードしている!

遅延評価の恩恵を受けたいのであれば、パイプラインの最後まで `IEnumerable(Of T)` のまま保ち、必要な分だけを逐次処理(フィルタリングや集計)しなければ意味がない。`ToList()` を呼んだ瞬間に、これまでの苦労(メモリ節約)が水泡に帰すことを忘れてはならない。

5. チーフアーキテクトからの総括

VB.NETにおける `Iterator` と `Yield` は、単なる「コードを短く書くための糖衣構文(シンタックスシュガー)」ではない。それは、「巨大なリソースを安全に、美しく、そして効率的に制御するためのシステムアーキテクチャの要」である。

業務自動化ツールやバックエンドのバッチ処理において、「メモリ不足」は開発者の設計不良を指し示す恥ずべきアラートに他ならない。今回解説した遅延評価のパターンを血肉とし、どれほどデータ量が増大しようとも涼しい顔して走り続ける、堅牢で美しいシステムを構築してほしい。

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