技術文章

在 Delphi 中讀取 Excel 快取公式值而不觸發重算

HotXLS 是原生 Delphi 與 C++Builder 的 Excel 程式庫,透過 TryGetCachedFormulaValueIXLSFormulaCacheReader 讀取 Excel 早已存放在公式旁的那個值。這兩個進入點都不會呼叫計算器、不會反組譯公式語彙、不會更新髒旗標,也不會把任何東西寫回模型,所以您唯讀開啟的活頁簿會與開啟時完全一致

促成這件事的情境很枯燥,卻極為常見。一個夜間作業開啟幾百個別人產生的活頁簿,從每個活頁簿抽出一欄總計,再把數字推進資料倉儲。這些總計早已躺在檔案裡——Excel 算好並存下來了。但作業一向公式儲存格詢問值的那一刻,對這個問題只有一種答案的程式庫就會建立相依性圖、評估整張工作表,於是一個理應受 I/O 限制的作業變成了計算效能測試

為什麼讀一個公式儲存格要付出完整重算的代價?

因為在公式儲存格上取值,等於是要求產生一個值,而唯一普遍正確的產生方式就是評估該公式。對於會編輯活頁簿的應用程式,這是正確的預設行為;對於只擷取資料的管線,這卻是錯誤的預設。更糟的是,評估並非沒有副作用:它會把結果寫回儲存格、翻轉髒旗標,而且當某個函式不受支援或外部參考斷裂時,它算出的結果可能與當初產生檔案的應用程式不同。您跟維運團隊描述成唯讀的作業,會悄悄產生一個與磁碟上不再一致的活頁簿;若之後有任何東西把它存檔,磁碟上的檔案也就跟著改了

讀取快取值則是這份契約的另一半。它回答一個更窄的問題——當初產生檔案的應用程式在這裡存了什麼?——並拒絕回答其他任何問題。當您真的想要最新的數字時,HotXLS 仍提供由相依性圖驅動的增量重算;重點是擷取與評估應該是兩個不同的呼叫,而不是一個有兩種心情的呼叫

關於一個儲存格的三件正交事實

先講結論:一個公式快取值承載三件獨立的事實,把它們壓成單一 Variant 會丟掉您需要的資訊。TXLSFormulaCacheInfo 把三者分開保存為 StateKindValueTXLSFormulaCacheState 以五種狀況記錄出處——xlfcsNotFormulaxlfcsMissingxlfcsLoadedxlfcsCalculatedxlfcsInvalidated——TXLSFormulaCacheValueKind 則把承載內容分類為 xlfcvBlankxlfcvNumberxlfcvDateTimexlfcvStringxlfcvBooleanxlfcvError。正是這種分離讓「是否存在」能被誠實回報:快取的空白、快取的空字串、快取的 False、快取的零與快取的錯誤都是真實的值,所以「存在」永遠不能從 VarIsEmptyVarIsNull 推斷。TryGetCachedFormulaValue 只在 xlfcsLoadedxlfcsCalculated 時回傳 True,回傳 False 時仍會填入可供診斷的狀態

HotXLS 的 TXLSFormulaCacheInfo 記錄把一個公式儲存格的三件正交事實分開:橫跨五種狀況的出處 State、橫跨六種的承載類型 Kind,以及 Variant 的 Value,因此快取的空白或 False 絕不會被誤認為沒有快取
出處、承載型別與承載值保持分離,這是快取的空白、零、空字串或錯誤能如實回報為其真實值的唯一方式
var
  Book: TXLSXWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-model.xlsx');
    // 這裡的 SheetIndex、Row 與 Col 全部是一基
    if Book.TryGetCachedFormulaValue(1, 12, 5, Info) then
      Writeln('cached value: ', VarToStr(Info.Value))
    else
      Writeln('no usable cache, state ordinal ', Ord(Info.State));
  finally
    Book.Free;
  end;
end;

為什麼快取值會缺失?

TryGetCachedFormulaValue 回傳 False 的原因恰好有四個,而狀態會告訴您是哪一個。xlfcsNotFormula 表示該儲存格放的是字面值或根本沒有東西,座標超出範圍也會歸併成同樣的答案。xlfcsMissing 表示該儲存格確實是公式,但產生端沒有為它存放值承載——產生器只寫公式、讓 Excel 在首次開啟時填入結果,常見的結局就是如此。xlfcsInvalidated 表示公式文字在載入後被取代,所以原本在那裡的值描述的是一個已不存在的運算式。相對地,xlfcsCalculated 是成功案例:它標記的是您自己的程式碼或 HotXLS 評估器在本次工作階段產生的值,與來自檔案的 xlfcsLoaded 相對

對缺失快取誠實,比把它粉飾過去更重要。HotXLS 拒絕捏造值,儲存時也同樣嚴格——只有 xlfcsLoadedxlfcsCalculated 會輸出快取值,xlfcsMissingxlfcsInvalidated 則只寫公式,而不會把過時的數字凍結進檔案。這讓您在管線中有三種合理的回應:跳過該列並記下缺口、刻意對那一個活頁簿重算並接受其成本,或評估後再對帳。若評估出的數字與當初產生檔案的應用程式會寫入的不一致,公式評估追蹤器就是找出兩次計算在哪裡分歧的工具,而不是憑結果猜測

一個讀取器橫跨傳統、OOXML 與 ODF 三種引擎

管線不該在意它剛開啟的檔案是 BIFF、OOXML 還是 ODF。IXLSFormulaCacheReader 是三者共用的唯一唯讀進入點:TXLSWorkbook.CreateFormulaCacheReaderTXLSXWorkbook.CreateFormulaCacheReader 都會在各引擎既有的稀疏儲存格查詢之上,回傳一個輕量型配接器,並使用相同的一基工作表、列與欄座標。活頁簿類別刻意不自己實作這個介面——對活頁簿的介面參考會改變其擁有權語義,讓呼叫端溜過生命週期租用。相反地,終結活頁簿會清除該租用內的原始指標,您的程式碼仍持有的任何讀取器會在下次查詢時擲出 EXLSFormulaCacheReaderInvalidated,而不是去解參考已釋放的記憶體。這是速敗式的生命週期檢查,不是並行保證

var
  Reader: IXLSFormulaCacheReader;
  Info: TXLSFormulaCacheInfo;
  Row, Missing, Errors: Integer;
  Total: Double;
begin
  Reader := Book.CreateFormulaCacheReader;
  Total := 0;
  Missing := 0;
  Errors := 0;
  for Row := 2 to LastRow do
    if Reader.TryGetCachedFormulaValue(1, Row, 7, Info) then
    begin
      case Info.Kind of
        xlfcvNumber: Total := Total + Double(Info.Value);
        xlfcvError:  Inc(Errors);
      end;
    end
    else if Info.State = xlfcsMissing then
      Inc(Missing);
  // 沒有計算器執行,沒有髒旗標變動,Book 維持不變
end;

快取位元組實際住在哪裡

對於傳統 .xls 檔案,快取就是 Formula 記錄的 FormulaValue 欄位,即 [MS-XLS] §2.5.133 描述的八個位元組。當高位字組等於 $FFFF 時,承載內容不是 IEEE 754 雙精度浮點數,而是一個帶標記的變體,而且其版面很容易出細微錯誤:變體型別放在 val[0],布林或 BErr 承載放在 val[2]val[1] 則未定義。HotXLS 先前從 val[1] 讀取承載,這是那種只會在「快取的是布林或錯誤而非數字」的特定檔案上浮現的差一錯誤。讀取器與共用公式寫入器現在採用一致的偏移,所以快取的 TRUE 能在載入與儲存後完好倖存,而不是衰變成雜訊

HotXLS 讀取傳統 XLS Formula 記錄那八個位元組的 FormulaValue 欄位:除非高位字組等於 FFFF,否則是 IEEE 754 雙精度浮點數;若等於 FFFF,變體型別放在 val[0],布林或錯誤承載放在 val[2]
高位字組為 FFFF 時,此欄位是帶標記的變體,承載放在 val[2]、val[1] 未定義,而那正是讀取器過去誤取的位元組

封裝格式中的型別保真則是另一個帶有自身陷阱的問題。在 OOXML 中,快取值掛在 c 項目下作為 <v>,型別由 t 屬性依 ECMA-376 Part 1 §18.3.1.4 命名。HotXLS 把 t="e" 直接讀成 varError Variant,並在儲存時對映回標準錯誤文字,所以錯誤絕不會偽裝成普通整數——但 Delphi RTL 在這裡幫不上忙,因為 VarAsType(Integer, varError) 會擲出轉換例外。可行的建構方式是直接設定 TVarData.VTypeTVarData.VError。日期則以相反方向遵循同一份紀律:t="d" 與 ODF 的日期值型別是明確的型別宣告,會成為 varDate;而 BIFF 數值快取完全不帶日期旗標,因此維持 Double。HotXLS 絕不從儲存格的數值格式猜測日期,因為數值格式是呈現,快取才是資料。ODF 還多了一個值得知道的狀況——office:value-type="void" 表達一個「存在但不帶值」的快取,而且由於 ODF 沒有錯誤值型別,看起來像錯誤的文字會以文字保留,而不是被提升為錯誤

function DescribeCache(const Info: TXLSFormulaCacheInfo): string;
begin
  case Info.State of
    xlfcsNotFormula:  Result := 'not a formula cell';
    xlfcsMissing:     Result := 'formula stored with no cached value';
    xlfcsInvalidated: Result := 'formula replaced since load';
  else
    case Info.Kind of
      xlfcvError:    Result := 'error code ' + IntToStr(TVarData(Info.Value).VError);
      xlfcvDateTime: Result := DateTimeToStr(VarToDateTime(Info.Value));
      xlfcvBoolean:  Result := BoolToStr(Info.Value, True);
      xlfcvNumber:   Result := FloatToStr(Double(Info.Value));
      xlfcvString:   Result := VarToStr(Info.Value);
    else
      Result := 'present but blank';
    end;
  end;
end;

共用公式會共用它們的快取值嗎?

不會,而假設會,就是一次掃描最後對整欄回報同一個數字的原因。OOXML 共用公式只共用公式運算式與儲存最佳化;每個成員儲存格仍擁有自己的 <v>。因此 HotXLS 絕不把根成員的快取傳播給一個到來時沒有值的從屬成員,而以 xlfcsMissing 載入的從屬成員在儲存並重新開啟後仍回報 xlfcsMissing。如果您正在理解這個群組當初是如何儲存與展開的,共用公式 si 屬性及其展開的機制另有專文說明;就快取讀取而言,規則可化約為一行——詢問每個儲存格,不相信任何您沒親自詢問得來的東西

HotXLS 觀點下的 OOXML 共用公式群組:si 屬性只共用運算式與儲存版面,每個成員儲存格都擁有自己的快取值,因此沒有快取值就載入的從屬成員會持續回報 xlfcsMissing
群組共用的是運算式,不是數字,所以根成員的快取絕不傳播,到來時沒有值的成員會持續回報那個缺口

快取值讀取、統一的跨引擎讀取器,以及您可以選擇呼叫的重算引擎,都隨適用於 Delphi 與 C++Builder 的標準 HotXLS Delphi 試算表元件出貨,不依賴 Excel,也不依賴任何 OLE 自動化伺服器;產品頁面附有本文所示活頁簿與讀取器進入點的完整 API 參考