【上級】VB.NETにおけるYieldキーワードを用いたイテレータの実装:大規模データをメモリ効率よく遅延評価するカスタム列挙の極意
VBAからVB.NETへの移行、あるいはレガシーなWindowsフォームアプリケーションのモダン化において、最も見落とされがちなのが「メモリの爆発(Out of Memory)」に対する耐性だ。
数百万件の受発注データ、数ギガバイトに及ぶログファイル、あるいは肥大化したCOM相互運用オブジェクト群。これらを愚直に`List(Of T)`へ一括ロードし、LINQで処理するコードは、開発初期のテスト環境では軽快に動くかもしれない。だが、本番環境の数千ユーザーが同時接続する極限状態、あるいは貧弱な仮想環境(VM)においては、一瞬でガベージコレクタ(GC)の逆鱗に触れ、アプリケーションを死に至らしめる。
この悪夢を断ち切る唯一の武器が、遅延評価(Deferred Execution)と`Yield`キーワードによるカスタムイテレータの構築である。
本稿では、プロフェッショナルなVB.NETエンジニアとして知っておくべき、メモリ効率の限界を突破するストリーミング処理の真髄を解説する。
—
1. なぜ「全件ロード」が悪なのか:ガベージコレクションとメモリの現実
レガシーなVB6やVBA、あるいは素朴に書かれたVB.NETコードの多くは、次のようなアプローチをとる。
.net
‘ 【アンチパターン】全件メモリロード
Public Function GetHeavyData() As List(Of OrderRecord)
Dim results As New List(Of OrderRecord)()
Using conn As New SqlConnection(connectionString)
conn.Open()
Using cmd As New SqlCommand(“SELECT FROM MassiveOrders”, conn)
Using reader As SqlDataReader = cmd.ExecuteReader()
While reader.Read()
‘ 全件オブジェクト化してリストに詰める
results.Add(New OrderRecord(reader))
End While
End Using
End Using
End Using
Return results ‘ 数百万件のオブジェクトが一気にヒープへ
End Function
このコードの問題の本質は、「最初の1件目を処理する前に、全データがメモリ上に展開されていなければならない」という点にある。LOH(Large Object Heap)の断片化を引き起こし、ジェネレーション2のGCを誘発、結果としてアプリケーション全体のスループットが劇的に低下する。
真のプロフェッショナルは、「必要なときに、必要な分だけ」データを生成する。これが遅延評価の思想だ。
—
2. Iterator と Yield キーワードのメカニズム
VB.NET(およびC#)の `Iterator` 関数と `Yield` ステートメントは、コンパイラに対して「裏側で強力なステートマシン(状態機械)クラスを自動生成せよ」と命じるマクロ的糖衣構文(Syntactic Sugar)ではない。これはCLRレベルの協調的多重タスクに近い、優美な列挙の抽象化である。
`Yield` を使った関数は、通常のメソッドのように一度にすべての結果を返さない。呼び出し側が `For Each` で次の要素を要求するたびに、前回の停止位置(State)から実行を再開し、次の値を「返却(Yield)」して再び眠りにつく。
実装例:DBストリーミング読み取りの極限最適化
以下のコードは、数百万件のレコードをメモリ消費量$O(1)$(定数オーダー)で安全にストリーミング処理するカスタムイテレータの実装である。
.net
Imports System.Data.SqlClient
Imports System.Collections.Generic
Public Class OrderStreamer
Private ReadOnly _connectionString As String
Public Sub New(connectionString As String)
_connectionString = connectionString
Namespace
”’
”’
Public Iterator Function StreamOrders() As IEnumerable(Of OrderRecord)
‘ Usingブロックにより、イテレータが途中で破棄された場合でも確実にリソースを解放する
Using conn As New SqlConnection(_connectionString)
conn.Open()
Using cmd As New SqlCommand(“SELECT OrderID, CustomerName, Amount, OrderDate FROM MassiveOrders”, conn)
‘ SqlDataReaderの性能を引き出すため、必要に応じてCommandBehaviorなどを調整
Using reader As SqlDataReader = cmd.ExecuteReader(CommandBehavior.SequentialAccess)
While reader.Read()
‘ オブジェクトのインスタンス化を最小限に抑え、その場でYieldする
Dim record As New OrderRecord(
reader.GetInt32(0),
reader.GetString(1),
reader.GetDecimal(2),
reader.GetDateTime(3)
)
‘ 制御を呼び出し元に一時的に渡し、次の要求を待つ
Yield record
End While
End Using
End Using
End Using
End Function
End Class
Public Class OrderRecord
Public Property OrderID As Integer
Public Property CustomerName As String
Public Property Amount As Decimal
Public Property OrderDate As Date
Public Sub New(id As Integer, name As String, amount As Decimal, dateVal As Date)
OrderID = id
CustomerName = name
Amount = amount
OrderDate = dateVal
End Sub
End Class
この実装が持つ圧倒的な優位性
1. メモリの定数化: 数百万件あっても、メモリ上に存在する `OrderRecord` は常に「現在処理中の1件(あるいはバッファリングされた数件)」のみである。
2. 早期脱出(Short-circuiting)の保証: 呼び出し側で `Where` や `Take(10)` などのLINQを組み合わせた際、10件見つかった時点で `Reader` の読み取りとDB接続が即座に切断される。全件走査の無駄が発生しない。
3. 確実なリソース管理: `Iterator` 関数内であっても `Using` ブロックは完全に機能する。`For Each` の途中で `Exit For` や例外が発生し、列挙子が破棄(Dispose)された瞬間に、SQL ConnectionやDataReaderが安全に閉じられる。
—
3. Windows API との融合:低水準バッファリングとの組み合わせ
業務システムにおいては、データベースだけでなく、巨大なバイナリファイルやWindows API(例:未管理メモリ、メモリマップトファイルなど)からデータを読み込む際にも `Yield` が絶大な威力を発揮する。
以下は、Win32 API(または .NET の `MemoryMappedFiles`)の思想を応用し、巨大なテキストファイルをチャンク(行単位の遅延バッファ)ごとに効率よくストリーミングする例だ。
.net
Imports System.IO
Public Class LogStreamer
”’
”’
Public Shared Iterator Function ReadLargeLogFile(filePath As String) As IEnumerable(Of String)
‘ 共有モードを指定してファイルロック競合を防ぐ
Using fs As New FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, FileOptions.SequentialScan)
Using sr As New StreamReader(fs, System.Text.Encoding.UTF8)
While Not sr.EndOfStream
Dim line As String = sr.ReadLine()
‘ 条件に合致する行だけを上流へ流す(フィルタリングのインライン化)
If Not String.IsNullOrWhiteSpace(line) Then
Yield line
End If
End While
End Using
End Using
End Function
End Class
—
4. アーキテクトからの警告:Yield使用時の落とし穴
`Yield` は魔法の杖ではない。その内部構造を理解していない者が使用すると、かえって深刻なバグやパフォーマンス低下を招く。
1. 例外の送出タイミングの乖離
遅延評価の性質上、`Iterator` 関数内のコードは、`For Each` が回るまで1行も実行されない。そのため、SQLの構文エラーやファイル不存在例外が、「メソッドを呼び出した瞬間」ではなく、「ループの最初の要素を取り出した瞬間」に発生する。エラーハンドリング(Try-Catch)のスコープ設計には細心の注意が必要である。
2. マルチスレッドとスレッドアフィニティ(UIスレッドの保護)
WindowsフォームやWPFのUIスレッドから重い `Yield` イテレータを直接回すと、アプリケーションがフリーズする。バックグラウンドスレッド(`Task.Run` や `Async/Await`)と組み合わせる必要があるが、VB.NETでは `Iterator` 関数自体を直接 `Async` にすることはできない(C#では `IAsyncEnumerable` が導入されているが、レガシーなVB.NET環境や古い.NET Frameworkでは工夫が必要)。非同期と組み合わせる場合は、バッファリングキュー (`BlockingCollection(Of T)`) を経由するなどのアーキテクチャ設計が求められる。
—
5. 結び:レガシーの呪縛を断ち切れ
VBAの `Collection` や `Recordset`、あるいはVB.NET初期の `DataSet` 文化に毒されたコードは、現代のクラウド時代、そしてビッグデータの時代においては「負債」でしかない。
`Iterator` と `Yield` を使いこなし、データを「所有」するのではなく「流す(Stream)」アーキテクチャへシフトすること。それこそが、過酷なエンタープライズの現場を支えるシニアエンジニアに求められる絶対的な素養である。
メモリを制する者が、システムを制する。今日から君のコードから `List(Of MassiveData)` の全件抱え込みを排除し、優美なる遅延評価の世界へ踏み出してほしい。
