WBS階層をフラットなリストへ変換:再帰処理を使わないスタックベースのタスク走査術
Microsoft Project(以下、MS Project)を用いた大規模プロジェクト管理において、WBS(Work Breakdown Structure)の階層構造を外部システム(RDB、BIツール、Excelなど)に連携するニーズは絶えません。
しかし、数千から数万タスクに及ぶ大規模なプロジェクトデータを扱う際、多くの開発者が安易に選択する「再帰呼び出し(Recursive Call)」による木構造走査は、深刻なパフォーマンス低下やスタックオーバーフローという致命的なバグを引き起こす温床となります。
本記事では、MS Project VBAにおけるタスク階層解析において、なぜ再帰処理を避けるべきなのかを論理的に解説し、ヒープ領域を活用した「スタックベースの非再帰深さ優先探索(DFS: Depth-First Search)」による、堅牢で極限まで高速化されたプロダクションコードを提示します。
—
1. なぜ「再帰呼び出し」はエンタープライズ開発でアンチパターンなのか
階層構造(ツリー構造)を走査する際、教科書的には以下のような再帰関数がよく紹介されます。
‘ 【アンチパターン】典型的な再帰によるタスク走査
Sub ScanTaskRecursive(parentTask As Task)
Dim childTask As Task
For Each childTask In parentTask.OutlineChildren
‘ 何らかの処理
Debug.Print childTask.Name
‘ 再帰呼び出し
ScanTaskRecursive childTask
Next childTask
End Sub
このコードは一見シンプルで美しく見えますが、実務レベル(数千タスク、深い階層、複雑な先行関係)で運用すると、以下の3つの致命的な問題に直面します。
① コールスタックの枯渇(スタックオーバーフロー)
VBAのコールスタック(実行中の関数情報を保持するメモリ領域)は非常に制限されています。階層が極端に深いプロジェクトや、循環参照に近い複雑な依存関係を持つプロジェクトを読み込んだ際、コールスタックが限界に達し、アプリケーションが警告なしに強制終了(クラッシュ)するリスクを常に抱えます。
② COMオブジェクトの暗黙的保持によるメモリリーク
MS Projectのオブジェクト(`MSProject.Task`)は、COM(Component Object Model)コンポーネントです。再帰呼び出しのスタックフレームが積み重なるたびに、解放されないCOM参照がメモリ上に蓄積されます。これがVBAのメモリプレッシャーを爆発させ、処理速度が指数関数的に低下する原因になります。
③ デバッグとエラーハンドリングの困難さ
再帰の最深部でエラーが発生した場合、コールスタックを遡って原因を特定することは極めて困難です。どのタスクの、どの階層で、何が原因で落ちたのかを特定するためのログ出力やトランザクション制御が著しく複雑化します。
—
2. 解決策:スタックベースの非再帰走査アーキテクチャ
これらの問題を一挙に解決するのが、「スタック(Stack:LIFO – Last In, First Out)」構造を自前で制御する非再帰アルゴリズムです。
システム標準のコールスタックに依存するのではなく、VBAの `Collection` オブジェクト(または動的配列)をカスタムスタックとしてヒープ領域に確保し、ループ処理(`Do While`)の中でタスクの親子関係を制御します。
アルゴリズムの概念図(深さ優先探索:DFS)
1. ルートタスク(またはトップレベルタスク群)を逆順にスタックにプッシュ(登録)する。
2. スタックが空になるまで以下の処理をループする:
- スタックから最上位のタスクをポップ(取り出し)する。
- 取り出したタスクに対して必要な処理(データ抽出など)を行う。
- そのタスクの子タスク(`OutlineChildren`)が存在する場合、逆順にスタックにプッシュする(逆順に積むことで、ポップした際に左側の子タスクから順に、つまりWBSの見た目通りの順番で処理される)。
このアプローチにより、メモリ消費量は「最大階層数(ツリーの深さ)」に比例するのみとなり、数万件のタスクがあってもスタックオーバーフローは原理的に発生しなくなります。
—
3. 堅牢なデータ連携のための設計原則
フラットなデータ構造に変換して外部に出力する際、以下の実務的トラップを考慮する必要があります。
- ユニークIDの担保: タスクの `ID`(行番号)はタスクの挿入・削除で変動します。外部連携のキーには必ず `GUID`(MS Projectでは `UniqueID`)を使用してください。
- 削除済みタスク(Nothing)のハンドリング: MS Projectの `Project.Tasks` コレクションには、削除されたタスクの跡地(`Nothing`)が含まれることがあります。これを適切にフィルタリングしなければヌルポインタ参照エラー(エラー91)が発生します。
- パフォーマンスの最大化: セルへの1セルずつの書き込みや、外部DBへの都度接続はボトルネックになります。データをメモリ内の「二次元配列(Variant)」にすべてバッファリングし、最後に一括で書き出す設計を徹底します。
—
4. 極限のプロダクションコード:WBSフラット化エンジン
以下に、MS Project VBAでそのまま動作する、スタックベースの高速・堅牢なタスク走査プログラムを示します。このコードは、アクティブなプロジェクトの全タスクを階層順に解析し、Excel等の外部連携に即座に利用できる構造化配列を生成します。
Option Explicit
‘ タスク情報を格納するためのユーザー定義型(またはクラス)
Private Type TaskFlatData
UniqueID As Long
TaskName As String
OutlineLevel As Long
WBSCode As String
StartPoint As Date
EndPoint As Date
Duration As Long
ParentUniqueID As Long
End Type
”’
”’ 再帰処理を一切排除しているため、大規模プロジェクトでも極めて安全かつ高速に動作します。
”’
Public Sub ExportFlatWBS()
On Error GoTo ErrorHandler
‘ パフォーマンス最適化:描画と自動計算の停止
Dim prevScreenUpdating As Boolean
Dim prevCalculation As Long
prevScreenUpdating = Application.ScreenUpdating
Application.ScreenUpdating = False
Dim proj As MSProject.Project
Set proj = ActiveProject
If proj.Tasks.Count = 0 Then
MsgBox “タスクが存在しません。”, vbExclamation, “処理中止”
Exit Sub
End If
‘ 1. スタックとしてのコレクションの初期化
Dim taskStack As Collection
Set taskStack = New Collection
‘ 2. 結果を格納する動的配列の準備(最大タスク数で初期確保)
Dim results() As TaskFlatData
ReDim results(1 To proj.Tasks.Count)
Dim resultCount As Long
resultCount = 0
‘ 3. ルート(トップレベル)のタスク群をスタックへ格納(逆順)
‘ ※ WBSの並び順(上から下)を維持するため、逆順でスタックに積む
Dim i As Long
For i = proj.Tasks.Count To 1 Step -1
Dim rootTask As MSProject.Task
Set rootTask = proj.Tasks(i)
‘ 削除済みタスク(Nothing)およびアウトラインレベルが最上位のものをスタックへ
If Not (rootTask Is Nothing) Then
If rootTask.OutlineLevel = 1 Then
taskStack.Add rootTask
End If
End If
Next i
‘ 4. スタックベースの走査ループ
Dim currentTask As MSProject.Task
Dim childTask As MSProject.Task
Dim childCount As Long
Do While taskStack.Count > 0
‘ ポップ処理:スタックの末尾(最後に積んだもの)を取り出す
Set currentTask = taskStack(taskStack.Count)
taskStack.Remove taskStack.Count
‘ 非活性タスク(非アクティブな割り当て等)の除外フィルタリング
If Not (currentTask Is Nothing) Then
‘ データの抽出とバッファリング
resultCount = resultCount + 1
With results(resultCount)
.UniqueID = currentTask.UniqueID
.TaskName = currentTask.Name
.OutlineLevel = currentTask.OutlineLevel
.WBSCode = currentTask.WBS
.StartPoint = currentTask.Start
.EndPoint = currentTask.Finish
.Duration = currentTask.Duration / 480 ‘ 分単位から日単位への変換(標準1日=8時間=480分)
‘ 親タスクの特定
If currentTask.OutlineLevel = 1 Then
.ParentUniqueID = -1 ‘ ルートタスク
Else
.ParentUniqueID = currentTask.OutlineParent.UniqueID
End If
End With
‘ 子タスク群を逆順でスタックへプッシュ
childCount = currentTask.OutlineChildren.Count
If childCount > 0 Then
For i = childCount To 1 Step -1
Set childTask = currentTask.OutlineChildren(i)
If Not (childTask Is Nothing) Then
taskStack.Add childTask
End If
Next i
End If
End If
Loop
‘ 5. 抽出したバッファを二次元配列に変換して出力(例としてイミディエイトウィンドウと新規シートへの書き出し)
If resultCount > 0 Then
ReDim Preserve results(1 To resultCount)
Call WriteToExcel(results, resultCount)
Else
MsgBox “有効なタスクが検出されませんでした。”, vbInformation, “完了”
End If
CleanUp:
‘ パフォーマンス設定の復元とメモリ解放
Application.ScreenUpdating = prevScreenUpdating
Set taskStack = Nothing
Set proj = Nothing
Exit Sub
ErrorHandler:
MsgBox “致命的なエラーが発生しました。” & vbCrLf & _
“エラー番号: ” & Err.Number & vbCrLf & _
“エラー内容: ” & Err.Description, vbCritical, “システムエラー”
Resume CleanUp
End Sub
”’
”’
Private Sub WriteToExcel(ByRef data() As TaskFlatData, ByVal count As Long)
Dim excelApp As Object
Dim wb As Object
Dim ws As Object
‘ Excelのインスタンス化(レイトバインディング)
On Error Resume Next
Set excelApp = GetObject(, “Excel.Application”)
If excelApp Is Nothing Then
Set excelApp = CreateObject(“Excel.Application”)
End If
On Error GoTo ErrExcel
excelApp.Visible = True
Set wb = excelApp.Workbooks.Add
Set ws = wb.Sheets(1)
‘ ヘッダーの作成
Dim headers() As Variant
headers = Array(“UniqueID”, “TaskName”, “OutlineLevel”, “WBSCode”, “Start”, “Finish”, “Duration(Days)”, “ParentUniqueID”)
ws.Range(ws.Cells(1, 1), ws.Cells(1, UBound(headers) + 1)).Value = headers
‘ 配列への展開用バッファ
Dim outputBuffer() As Variant
ReDim outputBuffer(1 To count, 1 To 8)
Dim i As Long
For i = 1 To count
outputBuffer(i, 1) = data(i).UniqueID
outputBuffer(i, 2) = data(i).TaskName
outputBuffer(i, 3) = data(i).OutlineLevel
outputBuffer(i, 4) = data(i).WBSCode
outputBuffer(i, 5) = data(i).StartPoint
outputBuffer(i, 6) = data(i).EndPoint
outputBuffer(i, 7) = data(i).Duration
outputBuffer(i, 8) = data(i).ParentUniqueID
Next i
‘ Excelシートへ一括書き込み(COM通信のオーバーヘッドを最小化)
ws.Range(ws.Cells(2, 1), ws.Cells(count + 1, 8)).Value = outputBuffer
‘ 書式設定の自動調整
ws.Columns(“A:H”).AutoFit
MsgBox “WBSのフラット化が完了しました。タスク数: ” & count & ” 件”, vbInformation, “処理成功”
Exit Sub
ErrExcel:
MsgBox “Excel出力中にエラーが発生しました: ” & Err.Description, vbCritical, “Excel連携エラー”
End Sub
—
5. コードの徹底解説とパフォーマンス上の急所
このプログラムが優れている理由を、アーキテクチャの視点から解説します。
① `Collection` をLIFOスタックとして扱う技法
VBAには標準のスタック構造(`Stack`クラスなど)が存在しません。これを `Collection` オブジェクトの末尾インデックス(`taskStack.Count`)へのアクセスと削除(`Remove`)で擬似的に表現しています。
‘ ポップ(取り出し)
Set currentTask = taskStack(taskStack.Count)
taskStack.Remove taskStack.Count
末尾の要素に対する追加と削除は、内部のポインタ操作だけで完結するため、コレクションの先頭要素を操作するよりも遥かに高速です。
② WBS順を壊さない「逆順プッシュ」
スタックは先入れ後出し(LIFO)であるため、普通に1つ目の子タスクから順に積んでしまうと、取り出すときに最後の子タスクから処理されてしまい、WBSの順序(時系列・優先順位)が逆転してしまいます。
コード内では、`For i = childCount To 1 Step -1` のように逆順ループでスタックに積むことで、ポップした際に元のWBS表示順と完全に一致するように設計されています。
③ COMオブジェクトのバッチ処理化
`WriteToExcel` 処理において、セルへの個別アクセスを徹底的に排除しています。
VBAからExcelやMS Projectの「セル」に対して1セルずつループで値を代入すると、プロセス間通信(COMの境界越え)が数千回発生し、処理時間が数十秒〜数分レベルに悪化します。すべてのデータを一度メモリ内の `Variant` 二次元配列(`outputBuffer`)に展開し、1回の代入処理でExcelに流し込むことで、数万件のデータであっても1秒未満で処理が完了します。
—
6. まとめ:アーキテクトとしてのあるべき設計
大規模なプロジェクトデータを扱うMS Project VBAにおいて、「動けば良い」というコードは、データのスケールアップに伴って必ず破綻します。
本稿で示した「非再帰スタック走査アルゴリズム」は、メモリの安全性を確保しつつ、実行速度を極限まで高めるためのベストプラクティスです。RDB(SQL Server/Oracle)へのバルクインサートや、大規模な工程進捗のExcelレポート作成自動化など、あらゆる外部システム連携の基盤として、この堅牢なアーキテクチャをぜひご活用ください。
