【実務・中級編】【上級プロ】Project VBAにおける「再帰呼び出し」の最適化:WBSが深いプロジェクトの処理落ちを防ぐ – Project VBA解析バイブル

スポンサーリンク

【上級プロ】Project VBAにおける「再帰呼び出し」の最適化:WBSが深いプロジェクトの処理落ちを防ぐ

開発現場でMS ProjectのVBAを扱うとき、避けて通れないのが「WBS(Work Breakdown Structure)の階層走査」だ。
プロジェクトが大規模化し、タスクの階層が10段、20段と深くなると、素朴な再帰関数(Recursive Function)は途端に牙をむく。

「突然のスタックオーバーフロー」
「メモリリークによるExcel/Project全体のフリーズ」
「O(N^2)に近い計算量の爆発による激遅な処理速度」

君が書いたその再帰コード、本当に10万行規模のエンタープライズ案件に耐えられるか?
今回は、Project VBAのオブジェクトモデルの挙動、メモリ管理の裏側、そして極限まで最適化された「非再帰的アプローチ(スタック明示管理)」または「スマートな再帰制御」の極意を、チーフアーキテクトの私から授けよう。

1. なぜ従来の再帰呼び出しはProject VBAで破綻するのか?

多くのプログラマがWBSを走査するとき、以下のようなコードを書く。

‘ 【アンチパターン】よくある素朴な再帰処理
Sub TraverseTasks_Bad(t As Task)
Dim subT As Task
‘ 何らかの処理
Debug.Print t.Name

If Not t.OutlineChildren Is Nothing Then
For Each subT In t.OutlineChildren
TraverseTasks_Bad subT ‘ 再帰呼び出し
Next subT
End If
End Sub

このコードの何が致命的なのか。理由は大きく3つある。

① コールスタックの枯渇(Stack Overflow)

VBAのランタイム環境は、深すぎる関数呼び出しに対して脆弱だ。WBSの階層が深くなると、コールスタック領域が圧迫され、エラー48(メモリ不足)またはエラー28(スタック領域が不足しています)が容赦なく発生する。

② COMオブジェクトの暗黙的生成と解放漏れ

`t.OutlineChildren` や `For Each` を使ったコレクションの走査は、裏でCOM(Component Object Model)のラッパーインスタンスを大量に生成する。VBAのガベージコレクションは頼りにならないため、明示的にオブジェクト変数を解放しないと、メモリリークの温床となる。

③ `.OutlineChildren` の重み

MS Projectのオブジェクトモデルにおいて、`OutlineChildren` コレクションへのアクセスはパフォーマンスコストが非常に高い。ループのたびにこれを評価していると、それだけでCPU時間をドブに捨てることになる。

2. 堅牢性と速度を両立する設計アプローチ

では、どう設計すべきか。
実務の現場で採用すべきアプローチは「コレクションキャッシュによるフラット走査」、または「自前スタック(Array/Collection)による非再帰(Iteration)化」である。

今回は、プロダクション環境で最も安全かつ高速に動作する、「配列スタックを用いた非再帰アルゴリズム」をベースにしつつ、可読性と保守性を担保したスマートな実装を伝授する。

3. 【プロダクションコード】極限最適化されたWBS走査エンジン

以下のコードは、数千〜数万タスクを持つ深いWBSであっても、スタックオーバーフローを起こさず、一瞬で走査を完了させるための実践的モジュールだ。

Option Explicit

‘ ==============================================================================
‘ 模块名: clsWBSOptimizer
‘ 用途 : 巨大かつ階層の深いWBSを高速・安全に走査するための最適化エンジン
‘ ==============================================================================

Public Sub ExecuteHighSpeedTraversal()
Dim prj As Project
Set prj = ActiveProject

Dim startTime As Double
startTime = Timer

On Error GoTo ErrorHandler

‘ 1. タスク群を一括取得(ActiveProject.Tasks)し、パフォーマンスを最大化
Dim tskColl As Tasks
Set tskColl = prj.Tasks

If tskColl.Count = 0 Then
MsgBox “処理対象のタスクが存在しません。”, vbInformation
Exit Sub
End If

‘ 2. 自前スタック(配列)を用いた非再帰走査の実行
‘ ※コールスタックを消費しないため、無限の深さに耐える
Call TraverseWithoutRecursion(tskColl)

MsgBox “WBS走査完了。処理時間: ” & Format(Timer – startTime, “0.00秒”), vbInformation
Exit Sub

ErrorHandler:
MsgBox “予期せぬエラーが発生しました: ” & Err.Description, vbCritical
‘ 適切なクリーンアップ処理をここに記述
End Sub

‘ ——————————————————————————
‘ 非再帰によるツリー走査(スタックオーバーフロー完全回避型)
‘ ——————————————————————————
Private Sub TraverseWithoutRecursion(ByRef tskColl As Tasks)
Dim stack() As Long ‘ タスクIDを格納する自前スタック
Dim stackPointer As Long ‘ スタックのポインタ
Dim capacity As Long ‘ スタックの容量

capacity = 1000
ReDim stack(0 To capacity – 1)
stackPointer = -1

‘ ルートレベルのタスクを逆順でスタックにプッシュ(先頭から処理するため)
‘ ※ProjectのタスクIDは1始まりだが、OutlineParentの関係性を効率よく扱う
Dim t As Task
Dim rootIds() As Long
Dim rootCount As Long: rootCount = 0

‘ サマリーや独立タスクの洗い出し(OutlineChildrenを使わずIDベースで制御)
Dim i As Long
For i = 1 To tskColl.Count
Set t = tskColl(i)
If Not t Is Nothing Then
‘ トップレベル(親を持たないタスク)を抽出
If t.OutlineLevel = 1 Then
rootCount = rootCount + 1
ReDim Preserve rootIds(1 To rootCount)
rootIds(rootCount) = t.ID
End If
End If
Next i

‘ ルートIDをスタックに逆順で積む(LIFOのため、最初の子を先に取り出すため)
For i = rootCount To 1 Step -1
stackPointer = stackPointer + 1
If stackPointer >= capacity Then
capacity = capacity 2
ReDim Preserve stack(0 To capacity – 1)
End If
stack(stackPointer) = rootIds(i)
Next i

‘ メインループ(スタックが空になるまでループ)
Dim currentId As Long
Dim currentTask As Task
Dim childTask As Task

Do While stackPointer >= 0
‘ ポップ(スタックから取り出し)
currentId = stack(stackPointer)
stackPointer = stackPointer – 1

Set currentTask = tskColl(currentId)

If Not currentTask Is Nothing Then
‘ ==========================================
‘ 【ここに実際のビジネスロジックを記述】
‘ 例: 進捗率の集計、カスタムフィールドへの書き込みなど
Call ProcessTask(currentTask)
‘ ==========================================

‘ 子タスクが存在する場合、スタックに積む
‘ 効率化のため OutlineChildren を使用するが、非再帰なのでスタック溢れはない
If currentTask.OutlineChildren.Count > 0 Then
Dim children As Tasks
Set children = currentTask.OutlineChildren

‘ 逆順でスタックに積むことで、左側のタスクから順に処理されるようにする
For i = children.Count To 1 Step -1
Set childTask = children(i)
If Not childTask Is Nothing Then
stackPointer = stackPointer + 1
If stackPointer >= capacity Then
capacity = capacity 2
ReDim Preserve stack(0 To capacity – 1)
End If
stack(stackPointer) = childTask.ID
Set childTask = Nothing
End If
Next i
Set children = Nothing
End If
End If

Set currentTask = Nothing
Loop

Erase stack
End Sub

‘ ——————————————————————————
‘ 個別タスクに対する処理(ビジネスロジック分離)
‘ ——————————————————————————
Private Sub ProcessTask(ByRef t As Task)
‘ ログ出力やプロパティの書き換え
‘ 例: Debug.Print String(t.OutlineLevel – 1, ” “) & t.Name

‘ ※COMオブジェクトの過剰な参照を防ぐため、必要なプロパティのみアクセス
If t.PercentComplete < 100 And t.Summary = True Then ' サマリータスク固有の処理など End If End Sub ---

4. データベースおよび外部ファイル連携時の注意点

WBSの走査結果を外部データベース(SQL ServerやAzure SQL等)やExcel、CSVに出力する際、以下の「実務の罠」に嵌まる開発者が後を絶たない。

1. トランザクションの分離とバッチ処理:
タスクを1件処理するたびにDBへINSERTを発行してはならない。走査した結果をメモリ上の二次元配列(`Variant`型配列)に一度蓄積し、一括でSQL Bulk InsertやADOのトランザクション処理で流し込むこと。I/Oボトルネックを劇的に削減できる。
2. プロジェクトの排他制御:
大規模なWBSを走査・更新している最中に、他のユーザーやバックグラウンドプロセスがプロジェクトファイルを書き換えないよう、`FileOpenEx` 時の読取専用モードや排他ロックの制御を厳格に行うこと。

5. チーフアーキテクトからの最終提言

VBAは「おもちゃの言語」ではない。正しくメモリとオブジェクトのライフサイクルを理解し、アルゴリズムの計算量を意識して実装すれば、C#やPython製のデスクトップアプリにも匹敵するパフォーマンスを発揮する。

特にMS Projectを扱う現場では、WBSの深さとタスクの数に比例してパフォーマンス問題が顕在化する。
「動けばいいや」の精神で作られた再帰コードは、プロジェクトが炎上した瞬間にツールごと爆発する。

今日から君のプロジェクトには、スタックを明示管理した堅牢な非再帰走査エンジンを導入してほしい。コードの美しさと圧倒的な実行速度が、プロとしての仕事を証明してくれるはずだ。

タイトルとURLをコピーしました