【実務・中級編】【上級プロ】CollectionオブジェクトとDictionaryを用いた、タスク情報のメモリ内高速検索アルゴリズム – Project VBA解析バイブル

スポンサーリンク

【上級プロ】CollectionオブジェクトとDictionaryを用いた、タスク情報のメモリ内高速検索アルゴリズム

開発プロジェクトの現場で、MS ProjectのVBA(Project VBA)を触ったことがある者なら誰もが一度は絶望する瞬間がある。
それは、「数千件規模のタスクを対象にしたループ処理が、絶望的に遅い」という現実だ。

特に、WBSの階層構造をたどりながら親タスクの情報を子タスクに伝播させたり、外部の進捗管理システムとIDベースで突き合わせたりする処理を愚直に書いた日には、コーヒーを淹れている間に終業時間を迎えてしまう。

なぜ遅いのか? 答えは単純だ。
VBAからMS ProjectのCOMオブジェクトモデル(`Project.Tasks` コレクション)に対して`.Find`メソッドやループでの `.Name` 参照を何度も叩くこと自体が、プロセス間通信のオーバヘッドを生み、メモリの往復で激重のボトルネックになっているからだ。

今回は、この悪夢のようなパフォーマンス問題を一刀両断する。
メモリ上に高速なインデックス(検索キャッシュ)を構築し、数千件のタスク検索を「一瞬」で終わらせるための極限のアーキテクチャを伝授しよう。

1. なぜ標準の `.Find` や愚直なループは遅いのか?

多くの開発者がやりがちなアンチパターンを見てみよう。

‘ 【アンチパターン】毎回オブジェクトモデルを走査する最悪のコード
Dim t As Task
For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
‘ 外部IDが一致するタスクを別の場所から探すために、ここでまたループを回す(O(N^2)の悲劇)
Dim targetTask As Task
For Each targetTask In ActiveProject.Tasks
If targetTask.Text1 = t.Text2 Then
‘ 何らかの処理
End If
Next targetTask
End If
Next t

このコードの何がまずいか。
1. O(N^2)の計算量: タスク数が3,000件あれば、$3,000 \times 3,000 = 900万回$ のCOMアクセスが発生する。
2. COM境界の跨ぎすぎ: VBAのランタイムからProjectのネイティブオブジェクトへアクセスするたびに、コンテキストスイッチが発生する。

これを解決するためのアプローチが、「一度メモリ上にデータを全ロードし、`Scripting.Dictionary` を用いてハッシュインデックス(O(1)の計算量)を構築する」という手法だ。

2. 設計思想:Collection と Dictionary の使い分け

メモリ上での高速検索において、我々が武器にすべきは以下の2つだ。

  • `Collection` オブジェクト (VBA標準)
  • メリット: 追加の参照設定が不要。キーによる一意検索が可能。
  • デメリット: キーの大文字小文字を区別しない(厳密なケースセンシティブな処理で注意が必要)、要素の存在確認(Exists)メソッドがないためエラーハンドリングが必要。
  • `Scripting.Dictionary` (`Microsoft Scripting Runtime`)
  • メリット: `Exists` メソッドがあり、キーの存在確認がスマート。大文字小文字の区別をプロパティで制御可能。
  • デメリット: 事前に参照設定(または遅延バインディング)が必要。

プロジェクトの堅牢性を考慮し、プロダクションコードでは「遅延バインディング(Late Binding)による `Scripting.Dictionary`」を採用する。これにより、参照設定の抜けによるコンパイルエラー(参照切れ)を完全に防ぎ、環境非依存のポータビリティを実現する。

3. 【プロダクションコード】メモリ内高速インデックス検索エンジン

以下のコードは、MS Project内の全タスクから「独自ID(Text1)」をキーとして、瞬時にタスクオブジェクト(またはそのプロパティ)を引くためのインデックスをメモリ上に構築し、横断的なデータ突合を行う実践的なモジュールだ。

Option Explicit

‘ ==============================================================================
‘ 模块名: MdlTaskSearchEngine
‘ 概要: Scripting.Dictionaryを活用したタスク情報のメモリ内高速検索アルゴリズム
‘ 著者: シニアVBAアーキテクト
‘ ==============================================================================
Public Sub ExecuteHighSpeedTaskProcessing()
Dim startTime As Double
startTime = Timer

‘ 1. 画面描画とイベントを停止し、極限までパフォーマンスを引き上げる
Application.ScreenUpdating = False
Application.Calculation = pjManual

On Error GoTo ErrorHandler

‘ 2. メインのDictionary(インデックス)を遅延バインディングで生成
Dim dictTaskIndex As Object
Set dictTaskIndex = CreateObject(“Scripting.Dictionary”)
dictTaskIndex.CompareMode = vbTextCompare ‘ 大文字小文字を区別しない

Dim t As Task
Dim uniqueIDKey As String

‘ 3. 【一括ロード】プロジェクト内の全タスクを一度だけ走査し、メモリ上にハッシュマップを構築 (O(N))
‘ ※オブジェクトそのものを格納するとメモリを圧迫するため、必要なIDとTaskオブジェクトのペア、
‘ あるいはプリミティブなデータを格納する。今回はTaskオブジェクトへの参照を保持。
For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
‘ ここでは例として「Text1」フィールドをユニークキーとする
If Trim(t.Text1) <> “” Then
uniqueIDKey = Trim(t.Text1)

‘ キーが重複していない場合のみ登録
If Not dictTaskIndex.Exists(uniqueIDKey) Then
dictTaskIndex.Add uniqueIDKey, t
Else
‘ 重複キーのハンドリング(必要に応じてログ出力など)
Debug.Print “Warning: 重複した一意IDが検出されました -> ” & uniqueIDKey
End If
End If
End If
Next t

MsgBox “インデックス構築完了: ” & dictTaskIndex.Count & ” 件のタスクをメモリにキャッシュしました。”, vbInformation

‘ 4. 【超高速検索・処理フェーズ】
‘ ここでは例として、別の条件(例: Text2に格納されている外部ID)に一致するタスクを
‘ DictionaryからO(1)で引き出し、値を書き換える処理を行う。
Dim matchCount As Long: matchCount = 0

For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
Dim searchKey As String
searchKey = Trim(t.Text2)

‘ ループ内でタスクを探す代わりに、Dictionaryから一瞬で引き出す (O(1))
If searchKey <> “” And dictTaskIndex.Exists(searchKey) Then
Dim targetTask As Task
Set targetTask = dictTaskIndex(searchKey)

‘ 紐づいたターゲットタスクの情報を加工する例
‘ 例:見つかったパートナータスクの名称とコストを転記
t.Text3 = “Match found with ID: ” & targetTask.UniqueID
t.Cost = targetTask.Cost 1.1 ‘ 例としてのコスト算入

matchCount = matchCount + 1
End If
End If
Next t

‘ 5. クリーンアップ
dictTaskIndex.RemoveAll
Set dictTaskIndex = Nothing

Application.ScreenUpdating = True
Application.Calculation = pjAutomatic

MsgBox “処理が正常終了しました。一致件数: ” & matchCount & ” 件” & vbCrLf & _
“処理時間: ” & Format(Timer – startTime, “0.00”) & ” 秒”, vbInformation
Exit Sub

ErrorHandler:
‘ 異常終了時も必ず環境を復元する(デッドロック・画面フリーズ防止)
Application.ScreenUpdating = True
Application.Calculation = pjAutomatic
Set dictTaskIndex = Nothing
MsgBox “致命的なエラーが発生しました: ” & Err.Description, vbCritical
End Sub

4. プロダクション環境における堅牢性(ファイル・DB連携の注意点)

このアルゴリズムを実際の業務(ERPや進捗管理DBとの連携ツール)に組み込む際、以下の「現場の罠」に注意してほしい。

1. メモリリークとオブジェクトの解放
`Dictionary` に格納した `Task` オブジェクトへの参照は、処理の終了時に必ず `Set dictTaskIndex = Nothing` および `.RemoveAll` で明示的に解放すること。Project VBAはExcel以上にCOMオブジェクトのメモリ管理にシビアであり、不適切な参照保持はMS Project自体の強制終了(クラッシュ)を誘発する。
2. NULL値・空文字のバリデーション
MS Projectのカスタムフィールド(Text1など)は、未入力の場合に空文字列 `””` を返す。これをそのままキーとしてDictionaryに登録すると、意図しない上書きやヒットが発生する。必ず `Trim` 関数による空白除去と、値の存在チェック(`If Trim(t.Text1) <> “”`)を挟むこと。
3. トランザクションとエラーハンドリング
大規模な一括書き換えを行う前には、必ず `Application.ScreenUpdating = False` と `Application.Calculation = pjManual` をかけ、処理が終わったら確実に(エラー時も含めて)元に戻すこと。さもないと、ユーザーの画面がフリーズしたままになり、最悪の場合プロジェクトファイルが破損する。

5. チーフアーキテクトからの総括

「コードが動けばいい」というフェーズは、タスク数が100件までの話だ。数千件、数万件のWBSを扱うエンタープライズ領域において、パフォーマンスは機能の一部である。

今回紹介した 「メモリ上へのインデックス構築(Dictionaryパターン)」 は、単なるテクニックではない。リソースの重みを知り、COM境界のコストを最小化するためのエンジニアリングの基本原則だ。

この設計を取り入れることで、あなたの作るVBAツールは「遅くて使い物にならないお荷物」から、「現場の業務を秒速で自動化する不可欠なインフラ」へと生まれ変わるはずだ。さあ、今すぐその非効率なループを書き換えろ。

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