HotXLS 這個適用於 Delphi 與 C++Builder 的原生 Excel 程式庫,透過 TXLSXWorkbook.Recalculate 執行增量公式重新計算。第一次呼叫會建立一個公式相依圖,並評估每一個公式儲存格;之後的每一次呼叫,都會以拓樸順序,單次掃描自上一次執行以來受到值寫入影響的儲存格進行重新評估,其成本與髒 (dirty) 儲存格的數量成正比,而不是與活頁簿的大小成正比
這一個設計決策,就是讓一個財務模型在編輯假設後,是在幾毫秒內回應,還是停頓好幾秒的差別。如果您產生的報表中,少數幾個輸入儲存格會影響數以千計的下游公式,本文的其餘部分將解釋相依圖的作用、哪些函式會退出增量運算,以及如何報告循環參照而不是陷入無窮迴圈
為何改變一個儲存格會重新計算十萬個公式?
一個天真的公式引擎沒有誰相依於誰的記憶,所以它在任何編輯之後唯一安全的做法就是重新評估所有東西。更糟的是,經典的遞迴策略——當公式 A 參考公式 B 時,立刻評估 B——會無條件地重新評估被參考的儲存格,完全忽略任何快取值。一連串的 n 個公式,每個都參考前一個,在每次完整運算時的成本是 O(n²) 的評估次數,而一個循環參照就會讓遞迴崩潰。每個曾將串聯模型接上遞迴評估器的試算表開發人員,都見過這兩種失敗模式發生
Excel 本身在幾十年前就用其計算鏈 (calculation chain) 解決了這個問題:維護一個公式儲存格的順序,使得編輯只會標記一小組儲存格為髒 (dirty),而引擎只會走過鏈中受影響的尾端。HotXLS 將相同的概念應用為明確的相依圖,從編譯好的公式樹建立一次,並在各次重新計算中重複使用。重點不在於巧妙;而在於重新計算的成本應該跟隨您編輯的範圍大小,而不是跟隨您活頁簿的大小
相依圖如何將編輯轉換為單次運算
HotXLS 相依圖為每個公式儲存格提供一個節點,邊緣 (edge) 從前置項 (precedent) 指向相依項 (dependent)。當您的程式碼寫入一個儲存格的值時,活頁簿會將該儲存格記錄為髒;當 Recalculate 執行時,髒的狀態會沿著邊緣傳播到每個下游公式,並且使用卡恩演算法 (Kahn's algorithm),以拓樸順序對髒的子圖 (subgraph) 精確地評估一次。因為公式在它的前置項之前絕對不會被拜訪,所以每個節點只需要單一次評估——這就是為什麼這趟運算是 O(dirty)
拓樸順序也從根本上解決了遞迴的問題。在重新計算的過程中,引擎會切換到一個專屬的模式,在這個模式下,任何對其他公式儲存格的參考,都會直接讀取該儲存格的快取值,而不是重新評估它——這個順序保證了快取已經是最新的。相同的機制意味著參照迴圈不會觸發無邊界的遞迴:在這趟運算內部沒有任何東西會再次進入評估器去評估相鄰的儲存格
var
Book: TXLSXWorkbook;
Inputs, Model: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Inputs := Book.Sheets.Add('Inputs');
Model := Book.Sheets.Add('Model');
Inputs.Cells[2, 2].Value := 0.05; // 成長假設
Model.Cells[2, 2].Formula := 'Inputs!B2*1000'; // XLSX 公式不需要前導 '='
Model.Cells[3, 2].Formula := 'B2*(1+Inputs!B2)';
// ... 從同一個假設串聯下來的還有數千列 ...
Book.Recalculate; // 第一次呼叫:建立相依圖,完整評估
Inputs.Cells[2, 2].Value := 0.07; // 一個編輯標記一個儲存格為髒
Book.Recalculate; // 第二次呼叫:只有下游鏈會執行
finally
Book.Free;
end;
end;
每個結果都會落在儲存格快取的 Value 中,所以在 Recalculate 傳回之後,您讀取輸出的方式與讀取任何其他儲存格的方式相同。在產生報表的迴圈中,模式就跟上面的程式碼一模一樣:載入或建立模型一次,然後交替進行寫入幾個輸入儲存格與呼叫 Recalculate,只需為真正相依於變更內容的公式付出運算代價
哪些 Excel 函式會強制在每次運算中重新計算?
HotXLS 將 NOW、TODAY、RAND、OFFSET 與 INDIRECT 視為變動 (volatile) 的:任何包含其中之一的公式,在每次 Recalculate 執行時都會被重新評估,無論上游是否有任何改變。前三個是變動的,原因與它們在 Excel 中相同——它們的結果取決於評估的當下,而不是其他儲存格。OFFSET 和 INDIRECT 變動的原因則更微妙:它們讀取的儲存格是在執行時期計算出來的,所以相依圖無法靜態地知道要為它們畫出哪些邊緣
這種保守的規則同樣延伸到相依圖建構器無法鎖定到單一矩形的參考。一個穿過多區域具名範圍 (multi-area named range) 的公式,或者參考外部活頁簿的公式,同樣會降級為變動的,並在每次執行時重新評估。這項策略是刻意為之的:額外進行一次評估只需要付出一點點時間成本,但缺少一個相依邊緣就意味著發布的報表中會出現無聲無息的舊數值,而這是一種嚴重得多的失敗。如果您的模型依賴活頁簿範圍的名稱,在已定義名稱與跨工作表公式這篇姊妹文章中涵蓋了單區域名稱是如何解析的——它們會正常地參與相依圖
實際的指引也直接隨之而來。將大型模型的高負載路徑 (hot path) 保持在相依圖能發揮作用的單純儲存格和範圍參照上,並將 OFFSET 與 INDIRECT 隔離在少數真正需要動態尋址的地方。一個擁有上千個變動公式的模型,在每次運算時都會重新執行那上千個公式,無論編輯的範圍多小——這跟 Excel 使用者從「每次擊鍵都重新計算」的活頁簿中體驗到的行為一模一樣
HotXLS 如何報告循環參照?
TXLSXWorkbook.Recalculate 在無誤的運算時會傳回 lxOk,當它偵測到參照迴圈時則傳回 lxErrorRef。迴圈成員是在拓樸排序期間被識別出來的——它們是卡恩演算法永遠無法釋放的節點——並且會被跳過而不是陷入迴圈:它們的快取值維持不變,而迴圈之外的每個公式仍然會按順序正常評估。您的呼叫端會得到一個明確的錯誤碼,而不是當機 (hang)
case Book.Recalculate of
lxOk:
SaveReport(Book);
lxErrorRef:
// 存在參照迴圈;迴圈成員保留它們之前的
// 快取值,而迴圈之外的所有東西都是最新的
LogWarning('偵測到循環參照 - 請檢閱模型輸入');
end;
找出哪些儲存格形成了迴圈是一項除錯工作,而 公式評估追蹤器 (formula evaluation tracer) 是適合這項工作的工具:追蹤可疑的公式,那個折回自己身上的參照鏈就會一步一步地顯現出來。真實模型中的迴圈幾乎總是一種編輯錯誤——例如一個總計列意外地包含在自己的 SUM 範圍中——因此在重新計算時發出響亮的錯誤碼,正是您所需要的
陣列公式、髒追蹤,以及相依圖何時重建
CSE (Ctrl+Shift+Enter) 陣列公式會為整個錨定的矩形提供一個節點,而不是每個儲存格一個節點。根公式每次運算只會評估一次;產生的矩陣會直接寫入每個成員儲存格中,而參考錨定範圍內任何儲存格的公式——不只是左上角的錨點——都會從該根節點接上一條相依邊緣。純量結果會像 Excel 傳統陣列語意規定的那樣,廣播到整個矩形中
髒追蹤 (dirty tracking) 會掛勾 (hook) 屬性的普通設定器 (setter),所以您的程式碼完全不需要改變。寫入一個儲存格的 Value 會通知活頁簿並將相依項標記為髒;指派一個新的 Formula 是一種結構上的改變,所以它會將整個相依圖標記為舊的 (stale),下一次的 Recalculate 會在評估前重建它。新增、刪除或移動工作表也會讓相依圖失效,因為節點身分 (identity) 編碼了工作表索引。當沒有活躍的相依圖時——例如一個您從未對它呼叫 Recalculate 的活頁簿——這些掛勾在每次指派時只會花費一次 nil 檢查的成本,所以單純的讀寫工作負載不會受到影響
有一個界線值得坦誠說明:相依圖追蹤的是儲存格之間的相依性,因此透過 OnUserFunction 註冊的使用者自訂函式,會在餵給它引數的儲存格改變時被重新評估,就像任何其他公式一樣。如果您正以這種方式擴展引擎,關於 HotXLS 公式引擎中的自訂函式的這篇文章,將會帶您了解回呼 (callback) 契約以及引數值是如何傳遞的
與公式計算器、已定義名稱以及它所加速的匯入/匯出管線並列,增量重新計算是 HotXLS Delphi Excel 元件中標準 XLSX 引擎的一部分。如果您的 Delphi 或 C++Builder 應用程式維護著活生生的模型——例如定價表、合併活頁簿、報表串聯——Recalculate 就是重新運算一整個活頁簿和只重新運算一次編輯之間的差別