2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Visual BasicAdvent Calendar 2021

Day 15

VBSでも使える文字列の高速な結合方法

Posted at

いきさつ

いままで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)です。

image.png

なんでこんなに遅いのか?

この遅さは文字列を結合する際のVBS/VBA/VB6の以下の①~③の動作に起因しています。
① 結合後の長さで(元の文字列と別の場所に)メモリを確保しなおす。
② そこに結合前の文字列をコピーする
③ にその後ろに追加分をコピーする

s = s & "あ"

いまのsが「ああああ」だとすると、この行を2回呼んだ場合の動作イメージはこうなります。
image.png

①と②がそれぞれ毎回繰り返されるのは、明らかに無駄であるというのが遅い理由ですが、それ以外にも、上記のグラフであるとこらから急激に遅くなるのは、そこまでで繰り返された小規模なメモリの確保と解放の結果、メモリが断片化したということもあるのだと思われます。(ここは推測の域をでないです。)

①と②の動作が毎回繰り返されるのは、明らかに無駄なので、VBAの場合は次のようなアプローチがよく行われました。

  • 十分に長い文字列を予め確保しておく
  • 文字列を結合する代わりに、予め確保した文字列の一部分を Midステートメントで書き換える。
  • 最後に予め確保した文字列の使わなかった部分を切り捨てる。

このやりかたで以下のコードを書き換えてみようと思います。

比較対象の遅い文字列結合

このコードは、30秒間でどれだけの文字列結合ができるかを計ります。

MsgBox "30秒で何文字結合できるかを計ります。"
Start = Timer
sAll = ""
sAdd = "a"
Do
	sAll = sAll & sAdd
Loop Until (Timer - Start) > 30
MsgBox Len(sAll) & "文字結合できました"

まずは実行してみます。 このコードはVBAでもVBSでも実行可能です。
image.png

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) & "文字結合できました"

image.png
763万文字の結合ができました。 先ほどの55万字に比べて、13倍以上の高速化になります。

VBSで実行すると

このコードをVBSで実行すると

image.png

なんと、エラーが。
そうですか、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) & "文字結合できました"

実行してみます。

image.png

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) & "文字結合できました"

2
0
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?