置換処理を自作してみる


[ExcelVBA]置換処理を自作してみる

発端

以下のツィートに対して私が返信したレスから始まって、@umesansansan さんへのレスに私が返したレスに対して、@koto218 さんが返したレスが発端です。

中略

コンピューター上での置換処理について、原始的なものを作りましょう、

という、説明資料的なものを作るお約束をしたので作ってみたわけです。

組みあがった関数の動作

実際に組みあがった関数を使った実行例を載せておきます。

大体VBAのreplace関数の「オプションを指定しない場合の」動作に近いです。

実行結果

実行結果

基本的な考え方

ツィートした以下文章が、基本的な考え方です。

「置換後文字列を配置するメモリを確保しておいて、パターンマッチするまでデータ転送・マッチしたら置換後文字列を配置、の繰り返しが妥当かと。」

 

コンピューターで扱う文字列がメモリ上に配置されるとき、あるメモリの番地から順番に文字が格納されます。

この文字列の中の部分文字列を別の文字列で置換する場合、置換前後で文字列長が異なる場合に、単純な置換ができません。

なぜなら、ツィートしたとおり、「単純に「見つけた位置に代入」すると、後方の文字が上書きされたり、置換されずに残る文字が生まれます。」からです。

ではどうするか、というときに、現在のようにPCのメモリが潤沢な環境であるならば、「置換後の文字列をすべて保管する領域を確保して置換後文字列を編集する」のが妥当な線だと考えます。

このとき問題になるのが、「確保する領域のサイズが最初は分からない」ことです。

置換前後の文字列長が等しい場合は、置換前の文字列と同じ長さになることが保障されているので問題はないですが、そうでない場合、領域確保前に、必要な領域のサイズを確認する必要があります。

ここで、確保した領域が実際の置換後文字数よりも少ない場合、VBAでは「インデックスの範囲外です」エラーになりますし、

Cだと「バッファオーバーフロー

MFCだと「バッファオーバーラン」の障害を引き起こしてしまいます。

ここまでがメモリ確保です。

 

次に、パターンマッチですが、単純に1バイトずつ比較し、検索文字列がすべてマッチした場合に「一致した」とみなし、置換後文字列を、確保したメモリ領域に追加します。

それ以外の文字列については「一致しなかった」とみなして、そのままの文字列を確保したメモリ領域に追加します。

これを、検索対象の文字列がなくなるまで繰り返します。

コード

以下が今回作成したコードになります。

なお、置換後文字列の文字数を出す処理と、実際の置換ロジックは、少し工夫をすれば1つの関数にまとめることが可能です。

ただし、今回は原始的な置換ロジックの説明がメインであるため、2つの関数に分けています。

Option Explicit

'--------------------------------------------------------------------------------
'関数名:myReplace
'引数:
'strExp:対象の文字列
'strFind:検索するサブ文字列
'strRep:置換後の文字列
'戻り値:
'strExp の長さがゼロ:長さがゼロの文字列 ("")
'strExp が Null :エラー。
'「strfind」 の長さがゼロ:「strExp」のコピー
'「strRep」 の長さがゼロ:すべての「strFind」が削除された「strExp」のコピー。
'
'参考:以下の、VBAに用意された既存関数の外部仕様を参考にコーディング
'目的は「原始的な置換ロジックのご説明」なので、必須でない引数は省略
'Replace(式、 find、 replace、[ start, [ count, [ compare ]])
'--------------------------------------------------------------------------------
Function myReplace(strExp As String, strFind As String, strRep As String) As String
    'strExpのNull検査
    If IsNull(strExp) Then
        'Nullの使い方が不正です
        Err.Raise 94
    End If
    
    'strExpの長さ0検査
    If Len(strExp) = 0 Then
        myReplace = ""
        Exit Function
    End If
    
    'strFindの長さ0検査
    If Len(strFind) = 0 Then
        myReplace = strExp
        Exit Function
    End If
    
    'strRepの長さ0検査
    '検査不要。今回作成するロジックでは置換後文字列長は0でもかまわないため。
    
    '置換後のバイト数を確認
    Dim lngByte As Long
    lngByte = getReplacedBytes(strExp, strFind, strRep)
    
    '領域確保
    Dim byteTemp() As Byte
    ReDim byteTemp(lngByte + 1)
    
    '置換実行
    Call getReplacedString(strExp, strFind, strRep, byteTemp)
    
    '戻り値に設定
    myReplace = CStr(byteTemp)
End Function

Function getReplacedBytes(strExp As String, strFind As String, strRep As String) As Long
'--------------------------------------------------------------------------------
'関数名:getReplacedBytes
'引数:
'strExp:対象の文字列
'strFind:検索するサブ文字列
'strRep:置換後の文字列
'戻り値:
' 置換後の全体文字列長
'--------------------------------------------------------------------------------
Dim lngLen As Long      '置換後の文字列長
Dim lngSub As Long      '検索文字列中のマッチング文字数
Dim lngPos As Long      '検索対象文字列中の検索位置
Dim lngWhole As Long    '検索対象文字列全体のバイト長

    '初期化
    lngLen = 0
    lngSub = 0
    lngPos = 1
    lngWhole = LenB(strExp)
    
    '対象文字列を検査し終えるまでループ
    Do Until lngPos > lngWhole
        If MidB(strExp, lngPos, 1) <> MidB(strFind, lngSub + 1, 1) Then
            'マッチングしていない場合
            '置換後の全体文字列長に、これまでマッチした文字列長+1を加算
            lngLen = lngLen + lngSub + 1
            
            '検索文字列中のマッチング文字数をリセット
            lngSub = 0
        Else
            'マッチングしている場合
            If lngSub = LenB(strFind) - 1 Then
                'マッチした文字数=検索文字列長-1(マッチングの終了)
                '置換後の全体文字列長に、「置換後の文字列」の長さを加算
                lngLen = lngLen + LenB(strRep)
                'マッチング文字数をリセット
                lngSub = 0
            Else
                'マッチング終了していない場合
                'マッチング文字数+1
                lngSub = lngSub + 1
            End If
        End If
        
        '検索位置を勧める
        lngPos = lngPos + 1
    Loop
    
    '置換後の全体文字列長に、これまでマッチした文字列長を加算
    '補足:検索対象の文字列の最後尾に、検索文字列が途中まで入っているケースへの対応
    lngLen = lngLen + lngSub
    
    '戻り値設定
    getReplacedBytes = lngLen
End Function

Sub getReplacedString(strExp As String, strFind As String, strRep As String, byteRet() As Byte)
'--------------------------------------------------------------------------------
'関数名:getReplacedString
'引数:
'strExp:対象の文字列
'strFind:検索するサブ文字列
'strRep:置換後の文字列
'byteRet():置換後の文字列(byte型配列)
'--------------------------------------------------------------------------------
Dim lngSub As Long      '検索文字列中のマッチング文字数
Dim lngPos As Long      '検索対象文字列中の検索位置
Dim lngDes As Long      '置換後全体文字列中の位置
Dim lngWhole As Long    '検索対象文字列全体のバイト長
Dim lngIdx As Long

    '初期化
    lngSub = 0
    lngPos = 1
    lngDes = 0
    lngWhole = LenB(strExp)
    
    '対象文字列を検査し終えるまでループ
    Do Until lngPos > lngWhole
        If MidB(strExp, lngPos, 1) <> MidB(strFind, lngSub + 1, 1) Then
            'マッチングしていない場合
            '置換後の全体文字列に、これまでマッチした文字列長+1を追加
            For lngIdx = 0 To lngSub
                byteRet(lngDes) = AscB(MidB(strExp, lngPos - lngSub + lngIdx))
                lngDes = lngDes + 1
            Next
            
            '検索文字列中のマッチング文字数をリセット
            lngSub = 0
        Else
            'マッチングしている場合
            If lngSub = LenB(strFind) - 1 Then
                'マッチした文字数=検索文字列長-1(マッチングの終了)
                '置換後の全体文字列長に、「置換後の文字列」を追加
                
                For lngIdx = 1 To LenB(strRep)
                    byteRet(lngDes) = AscB(MidB(strRep, lngIdx, 1))
                    lngDes = lngDes + 1
                Next
                
                'マッチング文字数をリセット
                lngSub = 0
            Else
                'マッチング終了していない場合
                'マッチング文字数+1
                lngSub = lngSub + 1
            End If
        End If
        
        '検索位置を勧める
        lngPos = lngPos + 1
    Loop
    
    '置換後の全体文字列長に、これまでマッチした文字列を追加
    '補足:検索対象の文字列の最後尾に、検索文字列が途中まで入っているケースへの対応
    For lngIdx = 1 To lngSub
        byteRet(lngDes) = AscB(MidB(strFind, lngIdx, 1))
        lngDes = lngDes + 1
    Next
End Sub

コードの組み始めについて

このあたりは個人の好みなのですが、私の場合はコメント文でロジックを記載してから肉付けをします。

なので、最初に下図のようなコメントだけのコードが出来上がります。

この状態からだと様々な言語でコーディングを開始できるため、結構重宝します。

コーディング開始

コーディング開始

追記 2019/6/18 2:26

ちなみに。

このロジックを読んで「デリートインサートで行くと思う」と言う方が居たとします。

その場合、考慮が足りないとしか言いようがないです。

ロジックの組み方にもよりますが、特定の文字列の中から特定の文字列をデリートするのに必要なデータの移動回数は、特定の文字列以降の文字数に等しいです。

そして、特定の文字列の中に特定の文字列を挿入する場合に必要なデータの移動回数は、挿入位置以降の文字数に等しく、かつ、この場合領域の拡張が必要です。

すると、複数個の文字が検索でヒットする場合、その文字が見つかった箇所以降の文字数×2回のデータ移動が発生します。

検索対象の文字列長が長ければ長い程この傾向は顕著になります。

例えば、検索対象の文字列長が10,000文字であり、その中に10個の検索文字列が一様な密度で分散しているとします。

その場合、デリートインサートで移動されるデータの数は、(9,000+1,000)*5*2=100,000個になります。

それに対して、前述のロジックで処理した場合、データの移動はワーク領域への転送と、戻り値への設定の2回を検索対象の文字列長繰り返すのみですので、10,000*2=20,000個になります。

この時点で必要なデータ移動回数は5倍です。

当然のことですが、デリートインサートで置換を行った場合、ヒットするキーワード数に比例してデータ移動回数が増えます。

ヒットするキーワード数が1000個だった場合はどうでしょうか。

本記事のロジックと比較して、単純計算で500倍のデータ移動が発生する事になります。

移動対象のデータが配置されているのがメモリなのかSSDなのかHDDなのかによってレスポンスはかなり変わってきますが、ケースによって致命的にレスポンスが悪くなるロジックは、採用されないと思うのです。

図解した方が分かり易かったかも知れませんが、「文字列の削除」や「文字列の挿入」にはそれなりにコストがかかります。

内部でどういう動作になっているかイメージできない方は、データがアドレスに割り当てられているイメージが持てていないのかも知れないですね。

なお、実際にこれらのコードをコンパイルした場合、その速度差は500倍まではいかないはずです。

なぜなら、マシン語には「ブロック転送命令」(複数のデータを一度に別のアドレスに転送する命令)があるからです。

8bit時代にもあったから64bitでもあると思ってぐぐったら普通にありました。

それでも、500倍まではいかないだけであって、非効率的であることに変わりはないのですけど。