いきさつ
いままでVBAが好きだったのですが、最近、ローカルのテキストファイルを扱う機会がいろいろあり、VBAの知識が応用できるVBSに興味が湧いています。
Excelを起動しなくても、Explorer上で、VBSに処理対象のファイルをドラッグしたら、加工処理ができたりするのが、とても便利だと思っています。VBAだとXLSに処理対象のファイルをドラッグしてということは無理なので。
でも、VBS / VBA / VB6は文字列の結合がとても遅いです。
なにかのログファイルを読み込んで、1行づう読み込んで加工して、1つの出力用文字列変数に結合していくようなときに致命的な欠陥となります。
(1つの出力用文字列変数に結合せずに、逐次出力するという解決策もありえますが、それは横に置きます。)
この遅さを回避するよく知られた手法があったのですが、なんとVBSではこの手法が使えませんでした。
そこでこの問題をVBSでも回避できる方法を探るとともに、VBAでよく行われた手法もおさらいしようと思います。
VBAのことなんか知らないよという場合は、後述の「VBSでの解決方法」まで飛んでください。
どんな感じに遅くなるのか?
ExcelのVBAでこんなコードを書いて、文字列結合1万回ごとの経過時間を計測して、グラフにしてみました。
Sub main()
Start = Timer
For i = 1 To 1000000
s = s & "a"
'1万回ごとに経過秒数をDebugウィンドウに記録
If i Mod 10000 = 0 Then
Debug.Print i & vbTab & CLng(Timer - Start)
End If
'すぐに応答なしになるので、5分たったらもうやめとく
If Timer - Start > 5 * 60 Then
Exit Sub
End If
Next
End Sub
あるところから急に遅くなりますが、この変節点は端末のメモリなどによって、異なるものと思われます。横軸は結合回数(変数i)、縦軸は経過秒数(Timer-Start)です。
なんでこんなに遅いのか?
この遅さは文字列を結合する際のVBS/VBA/VB6の以下の①~③の動作に起因しています。
① 結合後の長さで(元の文字列と別の場所に)メモリを確保しなおす。
② そこに結合前の文字列をコピーする
③ にその後ろに追加分をコピーする
s = s & "あ"
いまのsが「ああああ」だとすると、この行を2回呼んだ場合の動作イメージはこうなります。
①と②がそれぞれ毎回繰り返されるのは、明らかに無駄であるというのが遅い理由ですが、それ以外にも、上記のグラフであるとこらから急激に遅くなるのは、そこまでで繰り返された小規模なメモリの確保と解放の結果、メモリが断片化したということもあるのだと思われます。(ここは推測の域をでないです。)
①と②の動作が毎回繰り返されるのは、明らかに無駄なので、VBAの場合は次のようなアプローチがよく行われました。
- 十分に長い文字列を予め確保しておく
- 文字列を結合する代わりに、予め確保した文字列の一部分を Midステートメントで書き換える。
- 最後に予め確保した文字列の使わなかった部分を切り捨てる。
このやりかたで以下のコードを書き換えてみようと思います。
比較対象の遅い文字列結合
このコードは、30秒間でどれだけの文字列結合ができるかを計ります。
MsgBox "30秒で何文字結合できるかを計ります。"
Start = Timer
sAll = ""
sAdd = "a"
Do
sAll = sAll & sAdd
Loop Until (Timer - Start) > 30
MsgBox Len(sAll) & "文字結合できました"
まずは実行してみます。 このコードはVBAでもVBSでも実行可能です。
30秒で 55万字強の結合ができました。
これをより高速にします。
改善後のコード
MsgBox "30秒で何文字結合できるかを計ります。"
Start = Timer
sAll = ""
sAdd = "a"
'文字列長の拡張が時間がかかる原因なので予めある程度大きい文字列を確保します。
sTemp = String(1000, " ")
'予め確保した文字列の使用済み部分の長さを覚える変数です。
LastPos = 0
Do
'予め確保した文字列の未使用部分の長さ < これから足す文字列の長さ なら
'確保した文字列がもう不足しているので、再びある程度拡張します。
If Len(sTemp) - LastPos < Len(sAdd) Then
sTemp = sTemp & String(1000 + Len(sAdd), " ")
End If
'ここを通るときは、常に文字列は十分おおきな大きさで確保されていますので
'文字列の部分置換のみをします。
Mid(sTemp, LastPos + 1, Len(sAdd)) = sAdd
LastPos = LastPos + Len(sAdd)
Loop Until (Timer - Start) > 30
sAll = Mid(sTemp, 1, LastPos)
MsgBox Len(sAll) & "文字結合できました"
763万文字の結合ができました。 先ほどの55万字に比べて、13倍以上の高速化になります。
VBSで実行すると
このコードをVBSで実行すると
なんと、エラーが。
そうですか、Mid関数はあっても、Midステートメントはないのですね。
困りました。
では、VBSでも高速に結合できる方法を考えたいと思います。
VBSでの解決方法
VBSではMidステートメントがないので、Midステートメントで文字列の解放・確保を回避して、文字列の部分置換を行うということができません。
そこで、考え方を変えて、予め大きく確保した文字列を部分的に置き換えるのではなく、予め大きく確保した配列を部分的に置き換えて、最後に配列をJoinで結合するというアプローチをとります。
コードはこうなります。
MsgBox "30秒で何文字結合できるかを計ります。"
StartTime = Timer
sAll = ""
sAdd = "a"
'文字列の解放・確保を繰り返さないように、配列を予め確保し、そこに代入します。
ReDim Str(10000)
'確保済み配列の利用済み配列を覚える変数です。
LastPos = -1
Do
LastPos = LastPos + 1
'予め確保した配列が足りなくなったら、10000件分拡張します。
'UBoundは配列の上限を返します。
If UBound(Str) < LastPos Then
'Redim Preserveはすでにある配列の内容を維持して配列を拡張します。
ReDim Preserve Str(UBound(Str) + 10000)
End If
'確保済み配列の利用済み配列の次の部分に追加文字列を代入します。
Str(LastPos) = sAdd
Loop Until (Timer - StartTime) > 30
'確保済み配列の利用済み配列部分までに配列上限を縮小することで
'確保済み配列の未利用部分を切り捨てます。
ReDim Preserve Str(LastPos)
'配列をいっきに結合します。これは一瞬で終わります。
sAll = Join(Str, "")
MsgBox Len(sAll) & "文字結合できました"
実行してみます。
697万文字結合できました。
Midステートメントを使った場合の763万文字には若干劣りますが、単純結合の場合の55万文字よりはだいぶいい結果だと思います。
扱いが難しいのでクラス化します。
C#やVB.netのネーミングになぞらえて、StringBuilderクラスとして、上記の文字列操作をクラス化したいと思います。
VBSは以下のコードがそのまま使えます。
(VBAの場合は、Classモジュールを追加して、その名前をStringBuilderにして
先頭の「Class StringBuilder」の行と最後の「End Class」の2つの行以外の部分を貼り付けます。)
Class StringBuilder
Private AllString()
Private CurrentIndex
Private Sub Class_Initialize()
CurrentIndex = -1
End Sub
Public Property Get Value
If CurrentIndex = -1 Then
Value = ""
Exit Property
End If
ReDim Temp(CurrentIndex)
For i = 0 To CurrentIndex
Temp(i) = AllString(i)
Next
Value = Join(Temp,"")
End Property
Public Property Let Value(StringToLet)
CurrentIndex = -1
Append StringToLet
End Property
Public Sub Append(StringToAdd)
If CurrentIndex = -1 Then
ReDim AllString(10000)
End If
CurrentIndex = CurrentIndex + 1
If Ubound(AllString) < CurrentIndex Then
Redim Preserve AllString(CurrentIndex + 10000)
End If
AllString(CurrentIndex) = StringToAdd
End Sub
End Class
このクラスの使い方
このようにします。
VBSの場合、上記のクラスのコードと同じファイルに記述してください。
MsgBox "30秒で何文字結合できるかを計ります。"
StartTime = Timer
sAll = ""
sAdd = "a"
Set sb = New StringBuilder
Do
sb.Append sAdd
Loop Until (Timer - StartTime) > 30
sAll = sb.Value
MsgBox Len(sAll) & "文字結合できました"