Beyond the Formula: MS Projectにおける「ハイブリッド計算」の設計思想
MS Projectのカスタムフィールド(Custom Fields)には、強力な数式(Formula)エンジンが備わっている。しかし、プロジェクトの規模が数千タスクを超え、ロジックが単なる算術演算を超えて「外部データとの照合」や「複雑な条件分岐」、「再帰的な依存関係の判定」を必要とし始めたとき、組み込みの数式エンジンは無力なガラスの天井となる。
多くの開発者は、この壁に突き当たった際に、無理に数式を複雑化させてパフォーマンスを劣化させるか、あるいは諦めて手動更新へと逃げてしまう。だが、真のアーキテクトが取るべき道は、「計算ロジックを数式から切り離し、VBAによる制御下に移譲する」ことである。
本稿では、Projectの数式エンジンの限界を定義し、VBAを用いてカスタムフィールドに値を「注入」する、いわゆる「ハイブリッド計算(Hybrid Calculation)」の設計思想と、その実装における極限の最適化手法について解説する。
1. 組み込みFormulaエンジンの限界と死角
ProjectのFormulaプロパティは、あくまでProject内部のオブジェクトモデル(`Task`, `Resource`等)に基づいた限定的な式評価器である。以下のケースにおいて、Formulaは決定的に破綻する。
1. 外部コンテキストの欠如: SQL Serverの特定テーブルの値や、Web APIから取得した最新の単価情報を参照することは不可能である。
2. 計算コストの指数関数的増大: 数式内で複雑な`IIf`関数を多用したり、他のタスクの属性を動的に参照したりすると、再計算(Recalculation)のたびに計算グラフ全体が走り、UIのフリーズを招く。
3. 非決定的なロジック: 外部のDLL呼び出しや、複雑なアルゴリズム(例:モンテカルロ・シミュレーションに基づくリスク値の算出)を組み込むことはできない。
これらを解決するために、我々は「Formulaに計算させる」のではなく、「VBAで計算した結果を、静的な値としてCustomFieldに書き込む」というアプローチを採用する。
2. ハイブリッド計算のアーキテクチャ設計
ハイブリッド計算を実装する際、単に`For Each`でタスクを回すだけでは、プロフェッショナルの仕事とは言えない。以下の3つの設計原則を遵守すべきである。
I. 計算(Logic)と書き込み(I/O)の分離
計算ロジックを、Projectオブジェクトモデルに依存しない純粋な関数(Pure Function)として分離せよ。これにより、単体テストが可能になり、将来的なVB.NETへの移行や、Web API化へのパスが確保される。
II. 最小限のオブジェクト操作
ProjectのCOMオブジェクト(`Task`オブジェクト等)へのアクセスは非常に高コストである。ループ内でプロパティを読み書きする回数を極限まで減らすことが、パフォーマンスの鍵となる。
III. 状態の決定論的制御
計算を実行するタイミングを制御せよ。Projectの「再計算」イベントに紐付けるのは危険だ。計算が走るたびに大規模なロジックがトリガーされると、ユーザーの操作性を著しく損なう。明示的な「更新ボタン」または「特定のイベント」による実行を推奨する。
3. 実装の極致:高精度プロファイリングとメモリ管理
以下に、大規模プロジェクトでも耐えうる、ハイブリッド計算の実装パターンを示す。ここでは、Windows APIを用いて計算時間を高精度に計測し、メモリリークを防ぐためのオブジェクト解放を徹底したコードを提示する。
Option Explicit
' Windows API: 高精度タイマーを使用して計算パフォーマンスを測定する
Private Declare PtrSafe Function QueryPerformanceCounter Lib "kernel32" (lpPerformanceCounter As Currency) As Long
Private Declare PtrSafe Function QueryPerformanceFrequency Lib "kernel32" (lpFrequency As Currency) As Long
''' <summary>
''' プロジェクト全体のカスタムフィールドをハイブリッド計算で更新する
''' </summary>
Public Sub ExecuteHybridCalculation()
Dim startTime As Currency
Dim endTime As Currency
Dim freq As Currency
Dim taskObj As Task
Dim totalTasks As Long
' 高精度タイマーの準備
QueryPerformanceFrequency freq
QueryPerformanceCounter startTime
' パフォーマンス最適化: 画面更新と再計算の停止
' これを行わないと、書き込みのたびにProjectの計算エンジンが走り、致命的な遅延を招く
On Error GoTo ErrorHandler
Application.ScreenUpdating = False
Application.Calculation = pjManual
' 1. データの事前準備(例:外部DBやAPIからのデータ取得を想定)
' ここでは擬似的にDictionaryを使用
Dim externalData As Object
Set externalData = GetExternalReferenceData() ' 外部データ取得関数
' 2. 計算ループの実行
totalTasks = ActiveProject.Tasks.Count
' 効率的なループ: For Eachは便利だが、インデックス制御の方が
' 特定の条件下ではメモリ管理の観点で明示的である
Dim i As Long
For i = 1 To totalTasks
Set taskObj = ActiveProject.Tasks(i)
' Nullタスク(空行)のスキップ
If Not taskObj Is Nothing Then
' --- 計算ロジックの分離 ---
' 複雑なロジックは外部関数へ。ここでは「計算」と「書き込み」を分ける
Dim calculatedValue As Double
calculatedValue = CalculateComplexLogic(taskObj, externalData)
' --- 書き込み(I/O) ---
' Text1, Number1 などのカスタムフィールドへ直接値を注入
' 数式(Formula)は空にしておくことが重要。そうしないと数式と値が衝突する
taskObj.Text1 = "Hybrid_Calc" ' 識別用フラグ
taskObj.Number1 = calculatedValue
End If
' オブジェクトの明示的な解放(大規模ループでのメモリ圧迫防止)
Set taskObj = Nothing
Next i
' 後処理
Application.Calculation = pjAutomatic
Application.ScreenUpdating = True
' 経過時間の算出
QueryPerformanceCounter endTime
MsgBox "計算完了。" & vbCrLf & _
"処理タスク数: " & totalTasks & vbCrLf & _
"実行時間: " & Format((endTime - startTime) / freq, "0.000") & " 秒", vbInformation
CleanUp:
Set externalData = Nothing
Exit Sub
ErrorHandler:
Msgের "致命的なエラーが発生しました: " & Err.Description
Resume CleanUp
End Sub
''' <summary>
''' 外部データ(DBやAPI)をシミュレートして取得する
''' </summary>
Private Function GetExternalReferenceData() As Object
' 実務ではここでの MSXML2.XMLHTTP や ADODB.Connection の使用が想定される
Dim dict As Object
Set dict = CreateObject("Scripting.Dictionary")
dict.Add "Project_Alpha", 1.25
dict.Add "Project_Beta", 0.95
Set GetExternalReferenceData = dict
End Function
''' <summary>
''' 純粋な計算ロジック(Pure Function)
''' </summary>
Private Function CalculateComplexLogic(t As Task, data As Object) As Double
' ここに数式では不可能な、複雑な条件分岐や外部データ参照を記述する
' 例: タスク名に特定のキーワードが含まれ、かつ外部データが特定の条件を満たす場合
Dim multiplier As Double
multiplier = 1.0
If data.Exists(t.Name) Then
multiplier = data(t.Name)
End If
' 複雑な計算例
CalculateComplexLogic = t.Duration * multiplier * 0.5
End Function
4. エンジニアが陥る「メモリの罠」と回避策
大規模なプロジェクトを扱う際、最も恐ろしいのは「COMオブジェクトのリーク」である。
Project VBAにおいて、`Set taskObj = ActiveProject.Tasks(i)` のようにオブジェクトを割り当てた際、ループの終わりに `Set taskObj = Nothing` を明示的に行わなければ、参照カウントが適切に減少しない場合がある。特に、`Task` オブジェクトを内部の `Resource` オブジェクトや、さらにその先の `Assignment` オブジェクトへと辿っていくようなネストされた処理を行う場合、メモリ使用量は指数関数的に増大し、最終的に `Out of Memory` でシステムがクラッシュする。
プロフェッショナルの鉄則:
- スコープを最小限にする: オブジェクトの生存期間(Lifetime)を、その計算に必要な最小限のブロック内に閉じ込める。
- 大量データの集計には配列(Array)を使え: `Task` オブジェクトを直接触るのではなく、一度必要なプロパティ(`Start`, `Finish`, `Duration`等)をすべて `Variant` 型の配列に吸い上げ、メモリ上の配列に対して計算を行ってから、最後に一括して書き戻す。これが、1万タスクを超えるプロジェクトにおける唯一の正解である。
5. 結論:アーキテクトとしての視座
MS Projectのカスタムフィールドを単なる「計算結果の置き場」と見るか、「ロジックの実行環境」と見るか。この視点の差が、システムの堅牢性と拡張性を決定づける。
組み込みのFormulaは、使い捨ての簡易的な計算には最適である。しかし、企業の基幹システムとして、あるいは高度なプロジェクト管理ツールとして運用するのであれば、計算ロジックをVBA(あるいはそれ以上のレイヤー)へ引き出し、「計算の決定論的制御」を実現せよ。
メモリ、時間、そしてオブジェクトのライフサイクル。これらを完全に掌握したとき、君の書くコードは単なるスクリプトを超え、真の「システム」へと昇華する。
