【上級プロ】Project VBAにおける「再帰呼び出し」の最適化:WBSが深いプロジェクトの処理落ちを防ぐ
レガシーシステムの最前線、あるいは数千行を超える大規模な進捗管理基盤において、Microsoft ProjectのVBA開発は常にリソースとの戦いだ。
特に、階層が深く入り組んだWBS(Work Breakdown Structure)を走査する際、思考停止で実装した「素朴な再帰処理(Recursive Calls)」は、スタックオーバーフローやメモリリークを引き起こし、最悪の場合、宿主であるProjectプロセス全体のクラッシュを誘発する。
今回は、Project VBAのオブジェクトモデルの挙動、VBAランタイムのコールスタックの限界、そしてメモリ管理の極意に踏み込み、「深さ100層を超えるWBSであっても、ミリ秒単位で安全に走査し切るためのアーキテクチャ」を解説する。
—
1. なぜProjectの再帰処理は「重く、危険」なのか?
WBSの階層構造を走査する際、最も直感的なアプローチは、`Task.ChildTasks` コレクションを再帰的に呼び出す方法だ。
‘ 【アンチパターン】典型的な素朴な再帰処理
Sub TraverseWBS_Bad(ByVal t As Task)
Dim child As Task
Debug.Print t.Name
If t.ChildTasks.Count > 0 Then
For Each child In t.ChildTasks
TraverseWBS_Bad child ‘ ここで再帰呼び出し
Next child
End If
End Sub
このコードには、シニアエンジニアが見過ごしてはならない致命的な問題が3つ潜んでいる。
1. コールスタックの肥大化とスタックオーバーフロー:
VBAのランタイムは、C/C++のようにスタックサイズを動的に細かく制御できない。WBSの階層が深くなる(例: 50層〜100層)と、関数呼び出しのたびにローカル変数や戻りアドレスがスタックに積まれ、限界を超えた瞬間にエラー 28(スタック領域が不足しています)が発生する。
2. COMオブジェクトの暗黙的な参照残留:
`For Each` ループ内で `Task` オブジェクトを取得するたび、COM(Component Object Model)の参照カウンタがインクリメントされる。VBAのガベージコレクタは気まぐれであり、スコープを抜けただけでは即座にメモリが解放されない。これが数千タスクの規模で蓄積すると、メモリリークによるパフォーマンス低下(処理落ち)を招く。
3. 不要なプロパティアクセスによるIPC(プロセス間通信)のオーバーヘッド:
`t.ChildTasks.Count` や `t.Name` などのプロパティアクセスは、裏側でCOMを介したプロセス境界を跨ぐ通信が発生している。これを無駄に繰り返すことが、処理速度を致命的にスポイルする。
—
2. 究極の対策:明示的スタック管理(非再帰ループへの変換)とCOM解放
スタックオーバーフローを完全に根絶する唯一の建築学的アプローチは、「システムコールスタックに依存せず、独自の配列(ヒープ上の動的スタック)で階層を管理する」ことだ。
さらに、取得したCOMオブジェクトをループのイテレーションごとに明示的に `Nothing` 代解放し、VBAの背後で肥大化するCOMラッパーの参照を即座に削ぎ落とす必要がある。
以下のコードは、深さ無制限のWBSであってもスタックオーバーフローを起こさず、メモリを極限までクリーンに保ちながら高速走査する「非再帰型・スタック駆動走査エンジン」の実装例である。
実装コード:メモリ最適化済みWBS走査エンジン
Option Explicit
‘ ==============================================================================
‘ 組織的WBS走査エンジン (Iterative WBS Traversal with Explicit Stack)
‘ ==============================================================================
Public Sub ExecuteOptimizedWBSTraversal()
Dim prj As Project
Set prj = ActiveProject
Dim startTime As Double
startTime = Timer
On Error GoTo ErrorHandler
‘ ルートタスク群を安全に処理するため、カスタムスタック構造体を使用
‘ Projectのタスク数に基づく最大見積もり、または動的拡張配列を用意
Call TraverseTasksIteratively(prj)
MsgBox “走査完了 処理時間: ” & Format(Timer – startTime, “0.000”) & ” 秒”, vbInformation
Exit Sub
ErrorHandler:
MsgBox “致命的なエラーが発生しました: ” & Err.Description, vbCritical
End Sub
Private Sub TraverseTasksIteratively(ByRef prj As Project)
Dim taskStack() As Task
Dim stackPointer As Long
‘ 初期スタックサイズの確保(タスク総数を見越したサイズ、または動的リサイズ)
‘ ここでは安全のため最大予測値として Tasks.Count をベースにする
If prj.Tasks.Count = 0 Then Exit Sub
ReDim taskStack(1 To prj.Tasks.Count 2)
stackPointer = 0
‘ ルートレベルのタスクを逆順でスタックに積む(LIFO構造のため、正順で取り出すために逆から積む)
Dim i As Long
Dim t As Task
‘ 注: Projectの Tasks コレクションは 1-indexed だが、サマリーや階層構造を正確に辿るには
‘ OutlineChildren または Project.Tasks 全体から親を持たないものを抽出するのが定石。
‘ 今回は簡略化のため、プロジェクト直下のタスク群を起点とする。
For i = prj.Tasks.Count To 1 Step -1
Set t = prj.Tasks(i)
‘ Nothing判定と、トップレベル(OutlineParentがNothing、またはIDが自分自身など)の判定
If Not t Is Nothing Then
If t.OutlineLevel = 1 Then
stackPointer = stackPointer + 1
Set taskStack(stackPointer) = t
End If
End If
Set t = Nothing
Next i
‘ メインの非再帰ループ(スタックが空になるまで処理)
Dim currentTask As Task
Dim childColl As Tasks
Dim c As Task
Dim childCount As Long
Do While stackPointer > 0
‘ スタックからポップ
Set currentTask = taskStack(stackPointer)
stackPointer = stackPointer – 1
‘ — 【ビジネスロジックの実行】 —
‘ ここでタスクに対する処理を行う(例: ログ出力、カスタムフィールドの書込など)
‘ Debug.Print currentTask.Name & ” (Level: ” & currentTask.OutlineLevel & “)”
‘ ——————————–
‘ 子タスクが存在する場合、スタックに積む
‘ 子を処理する順番を維持するため、子コレクションの末尾から順にスタックにプッシュする
Set childColl = currentTask.ChildTasks
childCount = childColl.Count
If childCount > 0 Then
‘ スタック溢れ防止のための動的拡張
If stackPointer + childCount > UBound(taskStack) Then
ReDim Preserve taskStack(1 (UBound(taskStack) + childCount + 1000))
End If
For i = childCount To 1 Step -1
Set c = childColl(i)
stackPointer = stackPointer + 1
Set taskStack(stackPointer) = c
Set c = Nothing ‘ ループ内変数の即時解放
Next i
End If
‘ 参照の明示的解放(メモリリークの完全阻止)
Set childColl = Nothing
Set currentTask = Nothing
Loop
‘ スタック配列のクリーンアップ
Erase taskStack
End Sub
—
3. レガシー環境・システム間連携におけるさらなる最適化と知見
このコードベースを実際のエンタープライズ環境(基幹システム連携や、Excel/Access/SQL Serverとのデータ同期など)に導入する際、さらに意識すべきプロフェッショナルな知見を共有する。
1. 画面描画の完全抑制 (`ScreenUpdating`)
VBAからMS Projectを操作する際、タスクのプロパティ参照や走査がUIの再描画を引き起こすと、処理速度が何十倍も低下する。
処理の冒頭で `Application.ScreenUpdating = False` をかけたいところだが、MS ProjectのVBAオブジェクトモデルにはExcelのような直接的なScreenUpdatingプロパティが存在しない場合がある。そのため、以下のWindows APIを活用したウィンドウ描画のサスペンドが極限のパフォーマンスを生む。
If VBA7 Then
Private Declare PtrSafe Function SendMessage Lib “user32” Alias “SendMessageA” (ByVal hwnd As LongPtr, ByVal wMsg As Long, ByVal wParam As LongPtr, lParam As Any) As LongPtr
Private Declare PtrSafe Function FindWindow Lib “user32” Alias “FindWindowA” (ByVal lpClassName As String, ByVal lpWindowName As String) As LongPtr
Else
Private Declare Function SendMessage Lib “user32” Alias “SendMessageA” (ByVal hwnd As Long, ByVal wMsg As Long, ByVal wParam As Long, lParam As Any) As Long
Private Declare Function FindWindow Lib “user32” Alias “FindWindowA” (ByVal lpClassName As String, ByVal lpWindowName As String) As Long
End If
Const WM_SETREDRAW = &HB
Public Sub SuspendPainting(ByVal suspend As Boolean)
Dim projHwnd As LongPtr
projHwnd = FindWindow(“MSProject”, vbNullString)
If projHwnd <> 0 Then
If suspend Then
SendMessage projHwnd, WM_SETREDRAW, 0, ByVal 0&
Else
SendMessage projHwnd, WM_SETREDRAW, 1, ByVal 0&
End If
End If
End Sub
このAPI制御を走査処理の前後で挟むことにより、UIスレッドの無駄な再描画コストを完全に遮断し、純粋なメモリ・CPU演算処理へと昇華させることが可能になる。
2. COMオブジェクトの「デストラクタ的発想」
VBAにはクラスのデストラクタ(`Class_Terminate`)はあるが、ローカル変数のスコープアウト時に確実な解放を保証するためには、例外発生時(`On Error GoTo`)であっても確実にオブジェクト変数を `Nothing` に倒すエラーハンドリングの構造化が不可欠である。
「動かば動くほどメモリを消費するVBAアプリケーション」から脱却し、24時間稼働するサーバーサイドのバッチ処理と同等の堅牢性を、Project VBAの領域でも担保しなければならない。
—
結言
WBSの深さに怯える時代は終わった。
再帰呼び出しの美しさに囚われるあまり、システム全体の寿命を縮めるコードを書くのはアマチュアの所業である。
システムアーキテクトとしての誇りを持て。コールスタックを支配し、メモリのライフサイクルを掌中に収めた者だけが、真に安定したエンタープライズ級のProject自動化基盤を構築できる。
現場のコードに直ちにこの「スタック駆動型・明示的解放モデル」を適用し、圧倒的なパフォーマンスの差を知らしめよ。
