【上級プロ】CollectionオブジェクトとDictionaryを用いた、タスク情報のメモリ内高速検索アルゴリズム
Microsoft Project VBAの現場において、数千から数万件に及ぶWBS(タスク階層)を愚直にループさせ、特定の条件でタスクを検索・更新するコードを書いたことがある者なら、誰もが絶望的な遅さに直面したことがあるはずだ。
`ActiveProject.Tasks` コレクションに対する毎回の `.Find` メソッドの呼び出し、あるいは全件走査によるプロパティアクセス。これらは内部でCOM(Component Object Model)の境界を跨ぐオーバーヘッドを伴い、VBAの実行速度を致命的にスポイルする。
今回は、このパフォーマンスの壁を突破し、メモリ上に構築したインデックスによって瞬時にタスクへアクセスするための極限のアルゴリズムを解説する。
—
1. なぜMS Projectの標準検索は遅いのか:アーキテクチャの真実
MS Projectのオブジェクトモデルは、強力である反面、VBAとC++製の内核(Core Engine)の間に厚いCOMラッパーが存在する。
`Tasks(i)` にアクセスするたびに、VBAのランタイムはCOMポインタを介してProjectのネイティブメモリ領域へ問い合わせを行っている。
数千件のタスクを持つプロジェクトに対し、特定のカスタムテキストフィールドやリソース割り当てを条件にループを回すと、この「COM境界の往復」が数万回発生し、処理が数分単位でフリーズする原因となる。
解決のアプローチ
この問題を解決する唯一にして最良の手段は 「一度の全件走査でデータをVBA側のメモリ(RAM)上に引き上げ、VBAネイティブの連想配列(Dictionary)でインデックスを構築する」 ことだ。
一度メモリ上に乗せてしまえば、2回目以降の検索はCOMを跨がない。O(1)のオーダーで目的のタスクID(IDプロパティやUniqueID)を瞬時に引き当てることが可能になる。
—
2. 実装:DictionaryとCollectionによる高速インデックスエンジン
以下のコードは、数千件のタスクを保持するProjectにおいて、`UniqueID` をキーにした高速検索インデックスと、カスタムフィールド(例: `Text1`)によるグループ化インデックスをメモリ上に一括構築する実用モジュールである。
Option Explicit
‘ 早期バインディングのために「Microsoft Scripting Runtime」への参照設定が必要
‘ または、CreateObject(“Scripting.Dictionary”) による遅延バインディングを使用
Private m_TaskIndex As Scripting.Dictionary
Private m_CustomFieldGroup As Scripting.Dictionary
Private m_IsInitialized As Boolean
”’
”’
Public Sub InitializeTaskIndex()
Dim t As Task
Dim startTime As Double
startTime = Timer
Set m_TaskIndex = New Scripting.Dictionary
Set m_CustomFieldGroup = New Scripting.Dictionary
‘ メモリ最適化:あらかじめ容量を予測してリハッシュコストを削減(数千件規模を想定)
m_TaskIndex.CompareMode = TextCompare
m_CustomFieldGroup.CompareMode = TextCompare
If ActiveProject.Tasks.Count = 0 Then
m_IsInitialized = True
Exit Sub
End If
For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
‘ 1. UniqueIDをキーとしてタスクオブジェクトへの参照を保持
‘ 墓場(Nothing)を指さないよう、参照を直接Dictionaryに格納
If Not m_TaskIndex.Exists(t.UniqueID) Then
m_TaskIndex.Add t.UniqueID, t
End If
‘ 2. カスタムフィールド(例: Text1)をキーにしたグループ化インデックスの構築
Dim groupKey As String
groupKey = Trim(t.Text1)
If groupKey <> “” Then
Dim col As Collection
If Not m_CustomFieldGroup.Exists(groupKey) Then
Set col = New Collection
m_CustomFieldGroup.Add groupKey, col
End If
‘ 同一グループに属するタスクのUniqueIDをコレクションに蓄積
m_CustomFieldGroup(groupKey).Add t.UniqueID
End If
End If
Next t
m_IsInitialized = True
Debug.Print “インデックス構築完了: ” & ActiveProject.Tasks.Count & “件 / 処理時間: ” & Format(Timer – startTime, “0.000秒”)
End Sub
”’
”’
Public Function GetTaskByUniqueID(ByVal uniqueID As Long) As Task
If Not m_IsInitialized Then InitializeTaskIndex
If m_TaskIndex.Exists(uniqueID) Then
Set GetTaskByUniqueID = m_TaskIndex(uniqueID)
Else
Set GetTaskByUniqueID = Nothing
End If
End Function
”’
”’
Public Function GetTaskIDsByCustomField(ByVal groupKey As String) As Variant
If Not m_IsInitialized Then InitializeTaskIndex
If m_CustomFieldGroup.Exists(groupKey) Then
Dim col As Collection
Set col = m_CustomFieldGroup(groupKey)
‘ 配列に変換して返却(呼び出し側での処理を高速化)
ReDim resultArr(1 To col.Count) As Long
Dim i As Long
For i = 1 To col.Count
resultArr(i) = col(i)
Next i
GetTaskIDsByCustomField = resultArr
Else
GetTaskIDsByCustomField = Empty
End If
End Function
”’
”’
Public Sub ClearIndex()
If Not m_TaskIndex Is Nothing Then m_TaskIndex.RemoveAll
If Not m_CustomFieldGroup Is Nothing Then m_CustomFieldGroup.RemoveAll
Set m_TaskIndex = Nothing
Set m_CustomFieldGroup = Nothing
m_IsInitialized = False
‘ ガベージコレクションの明示的誘導
Erase
Debug.Print “インデックスメモリを解放しました。”
End Sub
—
3. シニアエンジニアが知るべき「メモリ管理とライフサイクル」の罠
VBAにおける `Scripting.Dictionary` と `Collection` の使用には、シニアエンジニアとして知っておくべき決定的な落とし穴がある。
オブジェクト参照の保持とメモリリーク
上記のコードでは、`Dictionary` に `Task` オブジェクトへの参照 (`t`) を直接格納している。
VBAのオブジェクト参照は参照カウンタ方式で管理されているため、Dictionaryが存在し続ける限り、背後にあるProjectのタスクオブジェクトもメモリ上にピン留めされる。
処理が完了した際や、アドイン(COMアドイン含む)のアンロード時には、必ず `ClearIndex` メソッドを呼び出し、ポインタの参照を切断(Nothing代入及ぶRemoveAll) しなければならない。これを怠ると、ExcelやProjectを終了するまでメモリ上にゾンビオブジェクトが残り続け、VBAホストプロセスのメモリフットプリントを肥大化させる。
レガシー環境・64bit/32bit Officeの差異
特に古い組織では、いまだに32bit版のOffice(Microsoft Project)が稼働しているケースが多い。
32bitプロセス空間では、利用可能なユーザーモードメモリは実質2GB〜3GBに制限される。数万件のタスクを持つ巨大なMPPファイルを扱う場合、Dictionaryに保持するオブジェクト参照や文字列キーがヒープ領域を圧迫し、突発的な `Out of memory (エラー 7)` を引き起こすリスクがある。
大規模案件では、タスクオブジェクト全体を保持するのではなく、「必要なUniqueIDのみ」をDictionaryに保持し、実データの取得が必要な時だけ最小限のCOMアクセスを行うというハイブリッドな省メモリ設計が求められる。
—
4. システム間連携への応用:外部DB/APIとの高速差分同期
このメモリ内インデックス・アルゴリズムが真価を発揮するのは、基幹システム(SAPやP6、独自ERPなど)とのタスクデータ同期バッチをVBAで構築する場面だ。
外部からJSONやCSV形式で渡された数千件の更新データに対し、MS Project側の該当タスクを愚直に毎回検索していたのでは、バッチ処理に数時間かかる。
しかし、事前に `InitializeTaskIndex` でProject側のインデックスをメモリ上に展開しておけば、外部データのループ側から以下のように一瞬で対象タスクを特定し、値をバルク更新できる。
Public Sub SyncExternalData(ByVal externalUniqueID As Long, ByVal newName As String, ByVal newCost As Double)
Dim targetTask As Task
‘ O(1)でメモリからタスクを取得
Set targetTask = GetTaskByUniqueID(externalUniqueID)
If Not targetTask Is Nothing Then
‘ 変更が必要な場合のみプロパティを書き換え(無駄なCOM書き込みを抑制)
If targetTask.Name <> newName Then targetTask.Name = newName
If targetTask.Cost <> newCost Then targetTask.Cost = newCost
Else
‘ 新規タスク追加のフォールバック処理
‘ ActiveProject.Tasks.Add …
End If
End Sub
このアプローチにより、数千件規模のデータ同期であっても、処理時間を数分から数秒レベルへと劇的に短縮することが可能となる。
—
5. 結び:技術の本質を見極める
VBAは、しばしば「おもちゃの言語」と揶揄される。しかし、それは言語の仕様を理解せず、場当たり的なコードを量産した場合の話に過ぎない。
オブジェクトのライフサイクル、メモリの割り当てと解放、そしてCOM境界のコスト。これらを完全にコントロール下に対象を置き、アルゴリズムの計算量を意識したコードを書くならば、Project VBAは企業の基幹業務を支える極めて強靭な自動化エンジンへと変貌する。
プロフェッショナルであれば、動くコードを書くだけでなく、その裏で何が起きているのかを常に想像し、最適化の限界を追求し続けなければならない。
