2016-03-23

Excel VBAでStringBuilderを使う

VB.NET には StringBuilder というクラスが実装されています。このクラスは文字列連結に特化しており、ループ内での文字列連結など処理中に文字列の連結が何度も出現する場合にその力を発揮します。VB.NET は VBA のベースとなっている VB6 の後継にあたるのですが、残念ながらこのクラスは VB6 には実装されていません。当然、VBAにも実装されていません。

なので作っちゃいました。

あった方が便利ですもんね。

StringBuilder for Excel VBA

とりあえずコードです。基本的な機能のみ実装しています。

'************************************************************************
'*-----------------------------------------------------------------------
'*  Name: StringBuilder (Class Module)
'*-----------------------------------------------------------------------
'*  Descriptioin:StringBuilder for VBA
'*-----------------------------------------------------------------------
'*  Copyright: HAYs  https://dev-clips.com , 2015 All Rights Reserved.
'*-----------------------------------------------------------------------
'*  <Update>
'*  Date        Version     Author     Memo
'*-----------------------------------------------------------------------
'*  2015.11.25  1.00        HAYs       New Release
'************************************************************************
' option
Option Explicit

'************************************************************************
'*  variable
'************************************************************************
Private pCapacity As Long
Private pLength As Long

Private mBuffer As String

'************************************************************************
'*  class event
'************************************************************************
'*-----------------------------------------------------------------------
'*  constructor
'*-----------------------------------------------------------------------
Private Sub Class_Initialize()
    pCapacity = 1023
    Me.Clear
End Sub

'*-----------------------------------------------------------------------
'*  destructor
'*-----------------------------------------------------------------------
Private Sub Class_Terminate()
    'clean up
    mBuffer = vbNullString
End Sub

'************************************************************************
'*  property
'************************************************************************
'*-----------------------------------------------------------------------
'*  Capacity
'*-----------------------------------------------------------------------
Friend Property Let Capacity(ByVal NewValue As Long)
    'ignore smaller NewValue
    If NewValue > pCapacity Then
        're-allocate
        mBuffer = mBuffer & String(NewValue - pCapacity, vbNullChar)
        'save new value
        pCapacity = NewValue
    End If
End Property
Friend Property Get Capacity() As Long
    Capacity = pCapacity
End Property

'*-----------------------------------------------------------------------
'*  Length
'*-----------------------------------------------------------------------
Friend Property Let Length(ByVal NewValue As Long)
    If NewValue < pLength Then
        Mid(mBuffer, NewValue + 1, pLength - NewValue) = _
            String$(pLength - NewValue, vbNullChar)
    End If
    pLength = NewValue
End Property
Friend Property Get Length() As Long
    Length = pLength
End Property


'************************************************************************
'*  method
'************************************************************************
'*-----------------------------------------------------------------------
'*  clear
'*-----------------------------------------------------------------------
Friend Function Clear() As StringBuilder
    'initialize length
    pLength = 0
    'allocate memory
    mBuffer = String$(pCapacity, vbNullChar)
    'return me
    Set Clear = Me
End Function

'*-----------------------------------------------------------------------
'*  append
'*-----------------------------------------------------------------------
Friend Function Append(ByRef StringValue As String) As StringBuilder
    Dim pos As Long
    Dim tmpCap As Long
    'set position
    pos = pLength + 1
    'add new length
    pLength = pLength + Len(StringValue)
    'check overflow
    If pLength > pCapacity Then
        'expand capacity *doubles up
        tmpCap = pCapacity
        Do While tmpCap < pLength
            tmpCap = tmpCap * 2
        Loop
        'save new capacity
        Me.Capacity = tmpCap
    End If
    'append
    Mid(mBuffer, pos) = StringValue
    'retrun me
    Set Append = Me
End Function

'*-----------------------------------------------------------------------
'*  insert
'*-----------------------------------------------------------------------
Friend Function Insert(ByRef StringValue As String, _
                        ByVal position As Long) As StringBuilder
    Dim tmpCap As Long
    Dim tmpLen As Long
    'check position
    Select Case position
        Case 1 To pLength
        Case Is < 1: position = 1
        Case Else
            Set Insert = Append(StringValue)
            Exit Function
    End Select
    'save length
    tmpLen = pLength
    'add new length
    pLength = pLength + Len(StringValue)
    'check overflow
    If pLength > pCapacity Then
        'expand Capacity *doubles up
        tmpCap = pCapacity
        Do While tmpCap < pLength
            tmpCap = tmpCap * 2
        Loop
        'save new capacity
        Me.Capacity = tmpCap
    End If
    'slide
    Mid(mBuffer, position + Len(StringValue)) _
        = Mid$(mBuffer, position, tmpLen)
    'insert
    Mid(mBuffer, position) = StringValue
    'retrun me
    Set Insert = Me
End Function

'*-----------------------------------------------------------------------
'*  string value
'*-----------------------------------------------------------------------
Friend Function ToString() As String
    ToString = Left$(mBuffer, pLength)
End Function

各機能の使用説明

Class_Initializeイベント - コンストラクタ

クラスのインスタンス作成時に実行されるメソッド「Class_Initialize」で初期処理を行います。容量を保持する変数pCapacityに初期値(ここでは1023)を設定しClearメソッドを呼び出し初期化しています。

Class_Terminateイベント - デストラクタ

文字列バッファ変数mBuffervbNullString(値0を持つ文字列)でクリアしています。あまり必要のない処理ですが、大きな容量を保持するバッファですので明示的にクリア処理を搭載しました。

Clearメソッド - 初期化

現在の文字列長を保持する変数pLength0でクリアし、文字列バッファ変数のmBufferを指定された容量(Capacityプロパティ)で初期化します。この際、String関数を使用して容量分のNull文字(vbNullChar)で文字列を保存する領域を確保します。

String関数のリファレンスです。

機能:指定した文字を指定した数分繰り返した文字列を返します。
構文:String(number, character)

引数 指定 内容
number 必須 文字列の繰り返し回数を長整数型 (Long)で指定します。
character 必須 繰り返す文字列式を指定します。指定した文字列式が2文字以上の場合は先頭の文字を繰り返した値を返します。

String関数でCapacity分のメモリを確保しているのですが、この「あらかじめ容量を確保しておく」ってのが高速化のポイントです。なぜこれで高速処理が可能なのかは後程説明します。

Appendメソッド - 文字列追加

String型の引数StringValueで渡された文字列をバッファに追加します。追加後の文字列が容量(pCapacity)を超える場合は、現在確保されている容量を2倍に拡張します。また、文字列長を保持する変数pLengthに引数StringValueの長さを加算します。

文字列の追加はClearメソッドで確保したバッファに対して行います。厳密にいうと、追加するというよりは追加されるべき場所の文字列(String関数で追加したNull文字=vbNullChar)と追加する文字列をMidステートメントを使用して置き換えています。

Midステートメントの詳細は以下の記事を参照してください。

Mid関数とMidステートメント - https://dev-clips.com/clip/vba/mid-keyword/

Insertメソッド - 文字列挿入

基本的には文字列追加のAppendメソッドと同じですが、Appendメソッドがバッファの最後に文字列を追加するのに対し、Insertメソッドは引数positionで指定した位置に文字列を挿入します。引数position0以下の場合は1文字目の位置に挿入します。現在の文字列長を超える場合はAppendメソッドを呼び出して文字列を最後に追加します。

Clearメソッド、Appendメソッド、Insertメソッドは処理が終了したら、自分自身(Me=StringBufferクラス)を返します。

ToStringメソッド - 連結文字列

Appendメソッド、Insertメソッドで連結した文字列を返します。

Capacityプロパティ - 文字列バッファ容量

文字列バッファの容量を保存します。値の設定と取得が可能です。新しい容量を設定する際、設定済みの容量よりも大きい場合のみプロパティの値を更新し、文字列バッファを拡張します。

Lengthプロパティ - 文字列長

現在の文字列長を保持します。値の設定と取得が可能です。新しい値を設定する際、現在の文字列長より短い長さを指定した場合は指定した長さを超過した部分をNull文字(vbNullChar)でクリアします。

StringBuilderが高速な理由

文字列の不変性

文字列は不変です。いきなり何言ってんだとか思われるかもしれませんが、StringBuilderが高速な理由(というか文字列連結が低速な理由)を知るには文字列の不変性について理解しとかなければなりません。

MSDNにある Visual Basic のString型に関する記事では以下のように明記されています。

文字列は不変です。つまり、その値は作成後に変更することができません。

String Basics in Visual Basic
- https://msdn.microsoft.com/ja-jp/library/ms234766.aspx

(え?String型の変数は"ABC"って文字列を入れた後に"DEF"って文字列格納できるじゃん...)

とか思われて何言ってんだコイツ状態の方もいらっしゃるかもしれませんので、String型の特性についてちょっとだけおさらいしときましょう。

String型は参照型です。String型の変数に何らかの文字列を代入すると、(文字列長×2)バイト + 6バイトをメモリ上のヒープ領域に確保します。

※正確には、というかメモリレイアウト的には 4バイト + (文字列長×2)バイト + 2バイト です。

String型の変数は実際に代入した文字列が格納されるわけではなく、確保したメモリのアドレスを参照します。

例えば、String型の変数Sに文字列"ABC"を代入した場合は以下のようになります。ここで検証のためにString型の確保したメモリのアドレスを返す関数 StrPtr関数を使用しています。

    Dim S As String
    S = "ABC"
    Debug.Print StrPtr(S)

僕の環境ではStrPtr関数は 9257852 という値を返しました。因みにこのStrPtr関数が返す値は環境によって異なりますし、同じ環境でも実行する度に異なります。(もちろん、同じ値になる場合もありますよ)

StrPtr関数のリファレンスです。

機能:String型変数が参照するメモリアドレス(ポインタ)をLongPtr型で返す。
構文:StrPtr(Ptr as String) As LongPtr

引数 指定 内容
Ptr 必須 String型の変数を指定します。

※LongPtr型は64bit環境ではLongLong型(8バイト)となり、32bit環境ではLong型(4バイト)となります。これは VBA が自動で判別するのでユーザーが意識する必要はありません。

ここで、変数Sに別の文字列"DEF"を代入し、StrPtr関数でメモリアドレスを表示させてみます。

    S = "DEF"
    Debug.Print StrPtr(S)

ここではStrPtr関数は 108956956 を返しました。初めに変数Sに”ABC”という文字列を代入したときは 9257852 を返したので、この時点で参照するメモリアドレスが異なっています。この事からわかるように、一度文字列を代入した変数に新たに文字列を代入すると、メモリ上に新しい領域が確保され、変数Sは新しく確保した”DEF”のアドレスを参照します。"ABC"と"DEF"という同じ3文字(3バイト)でもその領域は再利用されることはありません。そして今まで確保していた”ABC”の領域は(適当なタイミングで)破棄されてしまいます。この仕組みが上述のMSDNの引用にある文字列の不変性です。

Midステートメントを使用した文字列操作

「文字列の不変性」については理解して頂けたでしょうか?String型の変数は異なる文字列を代入する度に新しいメモリ領域を確保します。つまり、今まで使用した領域を再利用することはありません。このため、文字列の代入には文字列の長さに比例したオーバーヘッドが発生します。

ただし、これは代入演算子(=)で文字列を代入した場合の話です。

既にString型の変数に文字列が代入されている場合、Midステートメントを使用すると新しい領域を確保することなく、つまり既に確保した領域を再利用して文字列を変更することが可能になります。Midステートメントは文字列の一部を置き換えます。文字列の一部を取り出すMid関数と同じ名前で紛らわしいですが、2つは異なります。

例として、先ほどの変数SにMidステートメントを用いて文字列"GHI"を代入してみます。ここでも検証のためにStrPtr関数を使用します。

    Mid(S, 1, 3) = "GHI"
    Debug.Print StrPtr(S)

StrPtr関数は  108956956 を返しました。この値は"GHI"代入前と同じ値です。つまり、変数Sは"DEF"を格納した時と同じメモリアドレスを参照し、一度確保した領域を再利用している事を意味します。この場合、メモリ領域を新たに確保するコストがかからないためパフォーマンスの向上が期待できます。

サンプルで使用した3文字程度の文字列ならメモリ割り当てのオーバーヘッドも小さく体感できるほどのパフォーマンスの差はありませんが、繰り返し処理の中や大きい文字列の連結などの処理でMidステートメントを使用した文字列の代入(正確には置換)は大きな効力を発揮します。

冒頭のStringBuilderクラスでは、インスタンス作成時に大きな容量をあらかじめ確保しています。そして、AppendメソッドやInsertメソッドで文字列が追加されると確保した領域の一部をMidステートメントを使用して置換します。先述の通り、この方法ではあらかじめ確保していた領域を再利用するためメモリ割り当てのコストがかかりません。

これがStringBuilderクラスが高速な理由です。

パフォーマンスの検証

StringBuilderが高速に処理をするのは既に説明しましたが、一体どれだけのパフォーマンス向上が見込めるのでしょうか。

以下のサンプルで検証してみます。まずは、普通に&演算子で変数Sに文字列"ABC"を100,000回連結します。実行時間の計測には午前0時からの秒数をSingle型で返すTimer関数を使用します。

Sub Ampersand_Benchmark()
    Dim start As Single
    Dim i As Long
    Dim S As String
    '計測開始
    start = Timer
    '"ABC"を100000回連結する
    For i = 1 To 100000
        S = S & "ABC"
    Next
    '計測終了
    Debug.Print Timer - start
End Sub

次に同条件でStringBuilderを利用した文字列連結の検証です。コンストラクタ(初期処理)での容量確保もパフォーマンスに影響するはずなので、実行時間計測はループに入る前のインスタンス作成時点から始めます。

Sub StringBuilder_Benchmark()
    Dim start As Single
    Dim i As Long
    Dim S As String
    Dim sb As StringBuilder
    '計測開始
    start = Timer
    'インスタンス作成
    Set sb = New StringBuilder
    '"ABC"を100000回連結する
    For i = 1 To 100000
        sb.Append "ABC"
    Next
    '計測終了
    Debug.Print Timer - start
End Sub

それぞれ5回計測した結果は以下の表のとおりです。

※計測時間の単位は秒です。

実行回数 &演算子 StringBuilder
1回目 5.94604500 0.06005859
2回目 6.22595200 0.05200195
3回目 6.33190900 0.05407715
4回目 6.20996100 0.05200195
5回目 6.18493700 0.04003906
平均 6.17976080 0.05163574

&演算子での連結が平均で6秒以上かかっているのに対し、StringBuilderでの連結はわずか50ミリ秒程です。StringBuilderで連結した方が断然高速に処理できることがわかります。

まとめ。

開発していると必ずと言っていいほど文字列連結処理ってありますよね。小さい文字列で連結回数が少なければいいんですが、ループ内での連結などがあるとパフォーマンスに大きく影響します。そんな時はこのStringBuilderクラスを使ってみてください。文字列連結にかかるコストをカットしているのでかなりのパフォーマンスアップが期待できますよ:)

何かご質問等ございましたらお気軽にお問合せくださいまし。

HAYs

関連記事

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です