t-hom’s diary

主にVBAネタを扱っているブログ…とも言えなくなってきたこの頃。

VBA フォルダー階層を辿るサンプル ~ 再帰をマスターする

VBAではFileSystemObjectを使用することで簡単にWindowsのフォルダーを扱うことができる。

※FileSystemObjectを使用するには、VBEのツールメニューから参照設定で、MicrosoftScriptingRuntimeを参照しておく必要がある。

まず手始めに、特定フォルダ直下のファイルを列挙するサンプルプログラムを作成してみた。

Sub 特定フォルダ直下のファイルを列挙する()
    Dim FSO As New FileSystemObject
    Dim f As File
    For Each f In FSO.GetFolder("C:\users\thom").Files
        Debug.Print f.Name
    Next
End Sub

このようにFor Eachを使って簡単に列挙できる。

フォルダも同様で、変数fの型をFolderに変更し、For Eachに渡すプロパティをSubFoldersにしてやるだけだ。

Sub 特定フォルダ直下のフォルダを列挙する()
    Dim FSO As New FileSystemObject
    Dim f As Folder
    For Each f In FSO.GetFolder("C:\users\thom").SubFolders
        Debug.Print f.Name
    Next
End Sub

もっと下の階層を掘り下げるには

直下のフォルダを列挙することができたら、そのまた下の階層も列挙してみよう。
まず思いつくのはループの階層を増やすことだ。

Sub 特定フォルダ配下のフォルダと、そのまた配下のフォルダを列挙する()
    Dim FSO As New FileSystemObject
    Dim Subf As Folder, f As Folder
    For Each Subf In FSO.GetFolder("C:\users\thom\").SubFolders
        Debug.Print Subf.Name
        For Each f In Subf.SubFolders
            Debug.Print "  "; f.Name
        Next
    Next
End Sub

上のコードでは、子フォルダを変数Subfに入れ、さらに孫フォルダをfに入れている。
Debug.Printは直下のフォルダはそのまま出力し、孫フォルダはスペース2つ分インデントさせる。

これを実行すると…

f:id:t-hom:20160109235911p:plain

おっと。変なエラーがでた。
デバッグモードで確認すると、SubfにC:\Users\thom\Application Dataが格納されている個所でエラーが発生しているようで、実際にExplorerで開こうとしてもエラーになった。
f:id:t-hom:20160110000741p:plain

エラー処理を作りこむのは面倒なので、とりあえずResume Nextで囲む。

Sub 特定フォルダ配下のフォルダ配下のフォルダを列挙する()
    Dim FSO As New FileSystemObject
    Dim Subf As Folder, f As Folder
    On Error Resume Next    '追加
    For Each Subf In FSO.GetFolder("C:\users\thom\").SubFolders
        Debug.Print Subf.Name
        For Each f In Subf.SubFolders
            Debug.Print "  "; f.Name
        Next
    Next
    On Error GoTo 0     '追加
End Sub

これで一応、列挙できるようになった。

しかしこれはあまりうまいやり方ではない。フォルダの階層ごとにループの階層を増やしていてはキリがないからだ。
ループできちんとやろうと思うと、スタックを使っていろいろとややこしい処理をしなければならない。

再帰とは

このようなケースには再帰(さいき)が適している。

プロシージャが自分自身を呼び出すことを再帰という。

フォルダーを扱う前に、シンプルな再帰プログラムで頭を慣らしておこう。
再帰の例としてはよく数学の階乗関数が使われるが、それでも難しいと感じる方も居ると思うので、さらにシンプルなものを用意した。

予め断っておくと、次のプログラムは再帰がどういうものか知ってもらうためだけに作成したので全く実用性はない。

Sub スタート()
    Call 再帰テスト(1)
End Sub

Sub 再帰テスト(n)
    Debug.Print n
    If n < 3 Then
        再帰テスト (n + 1)
    End If
End Sub

まず「スタート」プロシージャは「再帰テスト」プロシージャに引数1を渡して呼び出す。ここまでは普通の呼び出しである。
再帰テストプロシージャは、nが3未満だった場合、引数n+1をつけて自分自身を呼び出す。

このプログラムを実行するとイミディエイトウインドウに1 2 3の順で出力して終了する。


自分を呼び出すというのがイメージしにくいかもしれないが、普通の関数呼び出しに置き換えるとこうなる。

Sub スタート()
    Call 再帰テストA(1)
End Sub

Sub 再帰テストA(n)
    Debug.Print n
    If n < 3 Then
        再帰テストB (n + 1)
    End If
End Sub

Sub 再帰テストB(n)
    Debug.Print n
    If n < 3 Then
        再帰テストC (n + 1)
    End If
End Sub

Sub 再帰テストC(n)
    Debug.Print n
    If n < 3 Then
        'この時点でnは3なので、実行されない。
        Debug.Print "←実行されない。"
    End If
End Sub

スタートが再帰テストAを呼び、AがBを呼び、BがCを呼ぶ。
呼び出しのたびに、受け取ったnに1を加えた値を次のプロシージャの引数にしているので、Cが呼ばれた時点で引数は3になっている。結局プロシージャCのDebug.Printは実行されない。

再帰テストCが終了すると呼び出し元である再帰テストBに戻り、Bも終了してAに戻り、Aが終了してスタートに戻り、それからマクロ全体が終了する。VBAインタプリターから見たら他のプロシージャーを呼び出すのも自分のプロシージャを呼び出すのも同じことである。

再帰でフォルダー階層を辿る

さて、いよいよフォルダー階層を辿るコードである。

Sub ViewFolders()
    Dim FSO As New FileSystemObject
    RecExplorer FSO.GetFolder("C:\Users\thom\documents"), 0
End Sub

Sub RecExplorer(f As Folder, n As Long)
    Debug.Print String(n * 2, " "); f.Name
    Dim Subf As Folder
    
    On Error GoTo ErrCheck
        For Each Subf In f.SubFolders
            '★ここで再帰呼び出し
            RecExplorer Subf, n + 1
        Next
    On Error GoTo 0
    
    Exit Sub
    
ErrCheck:
    '実行時エラー「書き込みできません。」(70)は想定内なので無視。
    'それ以外はエラーを発生させる。
    If Err.Number <> 70 Then
        Err.Raise Err.Number
    End If
End Sub

まずViewFoldersがRecExplorerを呼び出し、そこでサブフォルダーの一つずつを引数として再帰呼び出しが実施される。
RecExplorerに渡すフォルダ「f」のSubFoldersが一つもなければFor Eachの要素が0になるので、再帰呼び出しは実行されず、呼び出し元に戻り、次のフォルダーが処理される。

※先ほどの再帰コードではnが処理終了の条件を左右する重要な変数であったが、今回のnはただインデントの深さを決めるだけの変数なので終了条件とは関係ない。

この処理を図で描くと、以下のようになる。
f:id:t-hom:20160110010518p:plain
赤が再帰呼び出し、青が呼び出し元に戻る処理である。

このようなツリー構造はWindowsのフォルダー以外にも、Outlook VBAのメールフォルダーやレジストリ、TreeViewコントロールなど、色々と使いどころがあるので再帰は是非マスターしたい。

再帰の欠点

再帰呼び出しはスマートであるが、欠点もある。
ループと違って呼び出しのたびにローカル変数がメモリに積み重なっていくため、再帰呼び出しの階層が深すぎる場合はメモリのスタックと呼ばれる領域が枯渇してエラーになる。

例ではローカル変数がnしか無い為、4994まで呼び出しができたが、ローカル変数が増えるとそれだけオーバーフローしやすくなる。
f:id:t-hom:20160110011637p:plain


ただ、ツリー構造を扱うシンプルな処理では、この欠点はほとんど問題にならない。
先ほどの図で説明しよう。
一番深い階層の「フォルダ1」を扱っているとき、ルートフォルダ、フォルダA、フォルダIはそれぞれメモリに残っている。
f:id:t-hom:20160110012027p:plain

しかし呼び出しを終えてフォルダAの処理まで戻ってくると、フォルダIとフォルダ1はメモリから解放できる。
f:id:t-hom:20160110012352p:plain

従って、ツリー構造を扱う際に再帰は有用である。
サイトによっては、再帰はオーバーフローの危険があるので使うべきではないという意見もあるが、よほどフォルダがネストしていなければ問題ないと考えている。

再帰のスタックオーバーフロー実験

試しに以下のようにかなりネストしたフォルダ構造を作ってみたが、問題なく実行できた。
f:id:t-hom:20160110013943p:plain

試験的に呼び出しの途中にスタックに重たいデータを乗せてみたところ、cフォルダのネストの途中でクラッシュしてしまった。
f:id:t-hom:20160110014532p:plain

50000文字の固定長文字列が1000要素集まった配列である。

しかし実際そこまで重たいデータを扱うケースはあまりない。
よほど特殊なケースでない限りは問題ないと思う。

さらに、先ほどの無限再帰をもう一度実験してみた。
f:id:t-hom:20160110015619p:plain

さきほどは4994回でスタック不足になったのに対し、今回は6442回。
これは現在のスタック空き容量によって変化するということ。
この不安定さも商用ソフトのプログラミングで再帰が避けられる要因である。

また、無限再帰内で100個の変数を宣言してみたところ、呼び出し可能数は545まで低下した。
f:id:t-hom:20160110024422p:plain

ちなみにこれはVariant型の場合で、Integer型100個なら2000回以上の呼び出しが可能だった。

スタック領域の呼び出し可能数が不定で、変数を作りすぎると呼び出し回数が減るといっても、フォルダの階層なんてたかだか10階層程度のものだろう。

検証の結果、普通のツリー構造に対して再帰を使う分には全く問題ないことが分かった。

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。