VB.NET配列の呪縛を断て:ReDim Preserveのパフォーマンスの罠と、正しきモダンコレクションへの移行戦略
開発現場でこんなコードを見たことはないだろうか。
.net
‘ 悪いアンチパターンの例
Dim data() As String = Nothing
Dim count As Integer = 0
While reader.Read()
count += 1
ReDim Preserve data(count – 1)
data(count – 1) = reader.GetString(0)
End While
一見すると、動的にデータを取得して配列に格納するスマートなコードに見えるかもしれない。しかし、業務システムのバックエンドや、数万件規模のCSV・データベース連携を行うツールでこれをやられたら、私なら即座にプルリクエストを差し戻す。
今回は、VB.NETの根幹である「配列」のメモリ管理のメカニズムを紐解き、なぜ`ReDim Preserve`の多用がパフォーマンスの殺人凶器になるのか、そしてプロとしてどう設計すべきかを徹底的に解説しよう。
—
1. なぜ `ReDim Preserve` は遅いのか?(メモリの裏側)
VB.NET(というより.NETの共通言語ランタイム:CLR)において、配列(Array)は「連続したメモリ領域」として確保される。これが配列の強みであり、同時に弱みでもある。
`ReDim Preserve` を実行したとき、CLRの裏側で何が起きているか?
1. 新しいメモリ領域の確保: 現在のサイズより大きい(または小さい)連続した新しいメモリ領域をヒープ上に確保する。
2. 要素のコピー: 古い配列にあった全要素を、一言一句漏らさず新しいメモリ領域へ丸ごとコピーする。
3. 古いメモリの解放: 古いメモリ領域はガベージコレクタ(GC)の回収対象となる。
これをループの回数だけ繰り返す。つまり、要素数が $N$ 個に達するまで `ReDim Preserve` を呼び続けると、計算量は $O(N^2)$ に跳ね上がる。数万件のレコードを読み込むだけで、CPUは無駄なメモリコピーに骨を折ることになり、GCには巨大な負荷(LOH:Large Object Heapの断片化リスク含む)がかかる。これが「動的配列サイズの変更の罠」の正体だ。
—
2. 救世主:`List(Of T)` への移行
要素数が事前に分からない、あるいは変動するのであれば、素直に `System.Collections.Generic.List(Of T)` を使うべきだ。これはVB.NET/.NETにおける鉄則である。
`List(Of T)` は内部で「余裕を持ったサイズの配列」を隠し持っている。容量が足りなくなると、今の容量の2倍のサイズの新しい配列を確保し、コピーは最小限に抑えられる。これにより、要素追加の償却計算量は $O(1)$ となり、パフォーマンス劇的かつ圧倒的に向上する。
プロダクションコード例:安全かつ高速なデータ読み込み
データベースやファイルからデータを読み込み、堅牢に処理する実用的なコードパターンを見てみよう。
.net
Imports System.Collections.Generic
Imports System.Data.SqlClient
Public Class DataProcessor
‘ 読み込んだレコードを保持する構造体(またはクラス)
Public Structure TransactionRecord
Public Id As Integer
Public Amount As Decimal
Public TargetDate As Date
End Sub
”’
”’
Public Function FetchTransactions(connectionString As String) As List(Of TransactionRecord)
‘ 事前にサイズが不明な場合は、ReDimではなくList(Of T)を使う
Dim results As New List(Of TransactionRecord)
Dim query As String = “SELECT Id, Amount, TargetDate FROM Transactions WHERE Status = @Status”
Try
Using conn As New SqlConnection(connectionString)
Using cmd As New SqlCommand(query, conn)
cmd.Parameters.AddWithValue(“@Status”, “Active”)
conn.Open()
Using reader As SqlDataReader = cmd.ExecuteReader()
While reader.Read()
Dim record As New TransactionRecord With {
.Id = reader.GetInt32(0),
.Amount = reader.GetDecimal(1),
.TargetDate = reader.GetDateTime(2)
}
‘ O(1)に近い極めて高速な追加処理
results.Add(record)
End While
End Using
End Using
End Using
Catch ex As SqlException
‘ ログ出力基盤へスロー(ここでは簡易的に例外を再スロー)
Throw New ApplicationException(“データベースからのデータ取得に失敗しました。”, ex)
End Catch
Return results
End Function
”’
”’
public Sub ProcessData(connectionString As String)
Dim listData As List(Of TransactionRecord) = FetchTransactions(connectionString)
‘ もしレガシーなAPIや外部コンポーネントの仕様で「配列」を要求される場合のみ、
‘ 最後に一度だけToArray()を呼ぶ。これが最も効率的な折衷案である。
Dim finalArray() As TransactionRecord = listData.ToArray()
‘ 配列を使ったレガシー処理…
End Sub
End Class
—
3. コードレビューの視点:どうしても配列を使わなければならない時
「とはいえ、どうしてもVB.NETのネイティブ配列(`T()`)で初期化したい、あるいは初期サイズが完全に予測できる」という特例ケースもある。
もし最大要素数が事前に分かっている(あるいは見積もれる)のであれば、`ReDim Preserve` をループ内で使うのではなく、最初に必要なサイズを一発で確保しなければならない。
.net
‘ 【正しい配列の使い方】サイズが判明している、または上限が決まっている場合
Dim maxCount As Integer = GetEstimatedCount() ‘ 事前に件数を取得
Dim dataArray(maxCount – 1) As String ‘ 一度だけ確保
For i As Integer = 0 To maxCount – 1
dataArray(i) = GetData(i) ‘ コピーが発生しないため爆速
Next
しかし、実務の現場において「サイズが完全に予測できる」ケースは稀だ。ファイル読み込み、ユーザー入力、APIレスポンス解析など、モダンな業務アプリケーション開発において、可変長データを扱うなら原則として `List(Of T)` を採用し、最後に必要であれば `.ToArray()` で配列化するのが、バグを生まない最も堅牢で保守性の高いプラクティスとなる。
—
最後に:プロフェッショナルとしての設計思想
「動的ならとりあえず `ReDim Preserve`」という思考停止は、かつてのVB6や古いVBAの悪しき慣習を引きずったものに他ならない。.NETの世界には、十分に洗練されたジェネリックコレクションが用意されている。
パフォーマンスのボトルネックは、往々にしてこうした「何気ないループ内のメモリ再割り当て」に潜んでいる。コードを書くときは、その一行がCPUとメモリにどれだけの負荷をかけているか、一段上のレイヤーから俯瞰する視点を持つこと。それこそが、トラブルフリーでメンテナンス性の高い業務システムを作り上げるエンジニアの流儀である。
