【実務・中級編】【上級プロフェッショナル向け】大量テキストのFSO読み込み時に文字列結合(&)によるヒープ枯渇・低速化を回避する「配列Joinバッファリング」最適化 – VBScript (Visual Basic Scripting Edition)解析バイブル

スポンサーリンク
スポンサーリンク

【極限の最適化】VBScriptにおける文字列結合の罠を回避せよ:大量データ処理を加速させる「配列Joinバッファリング」の設計思想

業務自動化の現場において、VBScriptは今なお強力な武器だ。しかし、多くの「動くツール」が、ある一定のデータ量を超えた瞬間に、まるで泥沼に足を取られたかのように動作が重くなり、最終的にはメモリ不足(Out of Memory)で沈む。

なぜ、昨日まで完璧に動いていたスクリプトが、本番環境の数百万行のログファイルを読み込んだ途端に死ぬのか?

その答えは、プログラミングの基礎的な「計算量」と、VBScriptが扱う「文字列オブジェクトのメモリ管理」という、極めて低レイヤーな設計思想の理解不足にある。

今回は、ファイルシステムオブジェクト(FSO)を用いた大量テキスト処理において、パフォーマンスのボトルネックとなる「文字列結合(`&`)の罠」を解体し、プロフェッショナルが実践すべき「配列Joinバッファリング」による最適化手法を伝授する。

1. なぜ `strData = strData & newLine` は「悪」なのか

多くの開発者が、テキストファイルを一行ずつ読み込み、変数に結合していくコードを書く。

' --- アンチパターン:初心者が陥る致命的な書き方 ---
Dim strAllData, line
Set objFSO = CreateObject("Scripting.FileSystemObject")
Set objFile = objFSO.OpenTextFile("huge_data.txt", 1)

Do Until objFile.AtEndOfStream
    line = objFile.ReadLine
    strAllData = strAllData & line & vbCrLf ' ← ここが地獄の入り口
Loop

一見、論理的に正しい。しかし、このコードを大規模データに対して実行するのは、穴の空いたバケツで海水を汲み上げるようなものだ。

メモリ再割り当てのコスト

VBScriptにおける文字列は、いわゆる「不変(Immutable)」に近い振る舞いをする。`&` 演算子で文字列を結合するたびに、エンジン内部では以下のプロセスが発生している。

1. 現在の `strAllData` のサイズと、追加する `line` のサイズを計算する。
2. それらを収容できる「新しい、より大きなメモリ領域」をヒープ上に確保する。
3. 古い `strAllData` の内容を新しい領域にコピーする。
4. 新しい `line` を末尾にコピーする。
5. 古いメモリ領域を解放対象とする。

データ量が増えれば増えるほど、この「コピー」にかかるコストは指数関数的に増大する。計算量で言えば $O(n^2)$ だ。100万行のファイルを処理する場合、数兆回規模のメモリコピーが発生している計算になる。これが、処理が後半になるほど加速度的に遅くなる正体だ。

ヒープ断片化(Heap Fragmentation)

さらに恐ろしいのは、メモリの断片化だ。頻繁なメモリ確保と解放を繰り返すと、メモリ空間に小さな「隙間」が無数にでき、合計の空き容量は足りているはずなのに、巨大な連続領域が確保できずに `Out of Memory` を引き起こす。

2. 解決策:配列Joinバッファリングの設計思想

プロフェッショナルは、結合を「逐次」ではなく「一括」で行う。
そのための武器が、配列(Array)と `Join` 関数だ。

戦略的アプローチ

1. バッファとしての配列を用意する: 各行を文字列として結合するのではなく、配列の要素(インデックス)として格納していく。
2. 動的拡張のコストを抑える: 配列のサイズを毎回 `ReDim Preserve` で拡張するのは、それ自体がコストだ。あらかじめ大きめのサイズを確保するか、一定のチャンク(塊)ごとに拡張する設計にする。
3. 最後に一括結合する: 全ての行を格納し終えたら、`Join` 関数を用いて、メモリ上で一気に一つの文字列へ変換する。`Join` は内部的に最適化されており、メモリ確保とコピーを最小限の回数で実行できる。

3. プロダクション・グレードの最適化コード

以下に、実務でそのまま利用できる、堅牢性と速度を両立させた実装例を示す。単なるコピペで終わらせず、なぜこのように書いているのか、その意図を読み取ってほしい。

'===============================================================================
' Module: FastTextFileReader
' Description: 大容量テキストファイルを高速かつメモリ効率良く読み込む
' Optimization: Array Join Buffering Strategy
'===============================================================================
Option Explicit

' メイン処理(テスト用)
Call Main()

Sub Main()
    Dim targetFile, resultText
    targetFile = "C:\temp\huge_log_file.txt" ' ターゲットファイルパス
    
    On Error Resume Next
    resultText = ReadFileFast(targetFile)
    
    If Err.Number <> 0 Then
        WScript.Echo "Error: " & Err.Description
        Err.Clear
    Else
        WScript.Echo "Success: " & Len(resultText) & " characters read."
        ' ここでresultTextに対して加工処理を行う
    End If
    On Error GoTo 0
End Sub

'-------------------------------------------------------------------------------
' Function: ReadFileFast
' Purpose: 配列バッファリングを用いた高速ファイル読み込み
' Returns: 読み取った全テキスト文字列
'-------------------------------------------------------------------------------
Function ReadFileFast(ByVal strFilePath)
    Dim objFSO, objFile
    Dim arrBuffer()
    Dim strLine, i, iCount
    Dim intChunkSize
    
    ' 初期設定
    intChunkSize = 10000 ' 配列の拡張間隔(チャンクサイズ)
    iCount = 0
    
    Set objFSO = CreateObject("Scripting.FileSystemObject")
    
    ' ファイルの存在確認
    If Not objFSO.FileExists(strFilePath) Then
        Err.Raise 1, "ReadFileFast", "File not found: " & strFilePath
        Exit Function
    End If

    Set objFile = objFSO.OpenTextFile(strFilePath, 1) ' 1 = ForReading
    
    ' 最初にチャンクサイズ分だけ配列を確保
    ReDim arrBuffer(intChunkSize - 1)

    On Error GoTo ErrorHandler

    Do Until objFile.AtEndOfStream
        strLine = objFile.ReadLine
        
        ' 配列がいっぱいになったら拡張
        If iCount >= intChunkSize Then
            ' チャンクサイズを倍にするか、一定量追加することでReDimの回数を減らす
            intChunkSize = intChunkSize * 2 
            ReDim Preserve arrBuffer(intChunkSize - 1)
        End If
        
        arrBuffer(iCount) = strLine
        iCount = iCount + 1
    Loop

    ' 最後に、実際に使用した要素数分だけに切り詰めて結合
    ' Joinは非常に高速なネイティブ処理である
    If iCount > 0 Then
        ' ※ReDim Preserveで余剰分が空の要素として残っているため、
        ' 厳密には結合前に必要な分だけに切り詰める必要がある。
        ' ただし、Joinの引数に配列全体を渡すと空要素が混じるため、
        ' ここでは一旦「使用した分だけの配列」を再構築する。
        
        Dim arrFinal()
        ReDim arrFinal(iCount - 1)
        Dim j
        For j = 0 To iCount - 1
            arrFinal(j) = arrBuffer(j)
        Next
        
        ReadFileFast = Join(arrFinal, vbCrLf)
    Else
        ReadFileFast = ""
    End If

CleanUp:
    If Not objFile Is Nothing Then objFile.Close
    Set objFile = Nothing
    Set objFSO = Nothing
    Exit Function

ErrorHandler:
    Err.Raise Err.Number, "ReadFileFast", Err.Description
    Resume CleanUp
End Function

プロフェッショナルとしての設計ポイント

1. チャンクベースの `ReDim Preserve`:
毎行 `ReDim Preserve` を行うのは、結局メモリ再割り当てを頻発させる。上記コードでは `intChunkSize` を用いて、10,000行ごとに拡張を行うことで、再割り当ての回数を劇的に減らしている。
2. エラーハンドリングの徹底:
ファイルが存在しない、あるいはファイルが他のプロセスにロックされているといった実務上のトラブルを想定し、`On Error GoTo ErrorHandler` による適切なクリーンアップ(オブジェクトの解放)を実装している。
3. リソースの解放(Lifecycle Management):
`Set objFile = Nothing` などの明示的な解放は、大規模なループや長時間稼働するスクリプトにおいて、メモリリークを防ぐための鉄則だ。

4. さらなる高みへ:アーキテクチャの選択肢

もし、読み込むファイルが数GBを超えるような「超巨大ファイル」である場合、上記の「一度に全テキストを文字列として保持する」手法自体がメモリを圧迫する。

その場合の設計指針は以下の通りだ。

  • ストリーム処理(Stream Processing)への転換:

全データを一つの変数に格納するのではなく、`ReadLine` で読み込んだ一行ごとに、データベースへのインサートや、別のファイルへの書き出しを行う。「メモリ上に全データを載せない」ことこそが、究極の堅牢性への道である。

  • ADODB.Stream の検討:

文字コード(UTF-8など)の制御が必要な場合は、FSOではなく `ADODB.Stream` を使用せよ。FSOは古い技術であり、Unicodeの扱いに限界がある。

結論

VBScriptにおけるパフォーマンスの差は、言語の機能差ではなく、「コンピュータがいかにメモリを管理しているか」に対する理解の差から生まれる。

`&` による結合を避け、配列によるバッファリングを選択する。この一歩が、単なる「動くスクリプト」を、プロフェッショナルが信頼する「堅牢なシステム」へと昇華させるのだ。

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