HotXLS,這套給 Delphi 與 C++Builder 的原生 Excel 試算表元件,在 2026 年 9 月出了兩個相關的 AGGREGATE 修正。2.382.0 版校正了 options 引數,讓 1/3/5/7 忽略隱藏列、2/3/6/7 忽略錯誤、0 到 3 忽略巢狀的 SUBTOTAL 與 AGGREGATE 儲存格,完全照 Microsoft 的文件。2.382.3 版接著阻止那些選取旗標滲進函式所參照的那些儲存格本身的求值。第一個缺陷的難堪程度,就跟所有抄錯表格的 bug 一樣:位元位置被對調了,所以每一條用了非零 options 碼的公式,都拿到作者沒要求的原則。第二個更有意思,因為它是您在任何「用暫時欄位把情境傳進遞迴走訪」的求值器裡都會遇到的形狀。外層聚合設起一個旗標、走訪一個範圍,然後拉出一個公式還沒被算過的儲存格。那條公式在同一個計算器上執行、看到同一個已設起的旗標,然後悄悄聚合了錯誤的列,產出一個光從公式文字誰也解釋不出來的數字
AGGREGATE 的 options 0 到 7 到底選了什麼
AGGREGATE 的 options 引數是一個三位元矩陣,而這三個位元彼此獨立。bit 0(值 1)代表忽略隱藏列,bit 1(值 2)代表忽略錯誤值,而 bit 2(值 4)代表停止忽略巢狀的 SUBTOTAL 與 AGGREGATE 儲存格,因為對較低的碼來說跳過它們才是預設。這件事有兩個地方很容易搞反。隱藏列是低位元,不是中間那個,所以 AGGREGATE(9,1,...) 是篩選後總和的寫法,而 AGGREGATE(9,2,...) 是容忍錯誤的那種。而巢狀聚合原則是相對另外兩個反過來的:只有 4 到 7 的碼會把一條自身公式是 SUBTOTAL 或 AGGREGATE 的儲存格當成普通值。ECMA-376 Part 1 §18.17.7 定義的 SUBTOTAL 也有同一種含入或排除隱藏列的切分,落在 1-11 與 101-111 兩組碼上,而 AGGREGATE,在 OOXML 檔案裡以 _xlfn. 前綴儲存,把那個切分一般化進 options 引數,所以 Microsoft 為 AGGREGATE 函式公布的那張表,是引擎必須滿足的契約,而不是什麼方便參考
| 選項 | 隱藏列 | 錯誤值 | 巢狀 SUBTOTAL / AGGREGATE |
|---|---|---|---|
| 0 | 含入 | 往外傳遞 | 忽略 |
| 1 | 忽略 | 往外傳遞 | 忽略 |
| 2 | 含入 | 忽略 | 忽略 |
| 3 | 忽略 | 忽略 | 忽略 |
| 4 | 含入 | 往外傳遞 | 含入 |
| 5 | 忽略 | 往外傳遞 | 含入 |
| 6 | 含入 | 忽略 | 含入 |
| 7 | 忽略 | 忽略 | 含入 |
為什麼 HotXLS 的 AGGREGATE options 是反的
因為最初的 TXLSCalculator.CalcAggregateFunc 是照著那張表的轉述寫的,而不是照著那張表。它算的是 ignoreErrors := (optCode >= 4) and (optCode <= 7),並對 2、3、6、7 這幾個碼設起隱藏列閘門,而巢狀聚合原則根本沒有實作。先前那篇談 SUBTOTAL 與 AGGREGATE 隱藏列的文章把這個缺口列為未解限制,並照當時出貨的狀況描述舊的對應;那段描述對程式碼是準確的,對 Excel 則是錯的,而很久沒人發現,因為大多數人會組合的兩個原則,也就是隱藏加錯誤,在兩張表下都落在 3 與 7 這兩個碼上。只有單位元的碼暴露了對調:AGGREGATE(9,1,A1:A4) 回傳未篩選的總和,而 AGGREGATE(9,2,...) 跳過隱藏列卻仍然往外傳遞 #DIV/0!。這個缺陷是從對 lxCalc.pas 的靜態審閱浮出來的,登錄為專案已知問題登記表中的 HXLS-008,不是來自客戶檔案,這也說明了單位元碼在正式活頁簿裡有多罕見。2.382.0 版把解碼重寫成三個集合成員測試,並為巢狀原則加上第二道閘門,透過活頁簿與 TXLSIsRowHidden 一併提供的新回呼 TXLSIsSubtotalCell 串接起來
// TXLSCalculator.CalcAggregateFunc, v2.382.3 form
if (optCode < 0) or (optCode > 7) then
begin
Result := lxErrorValue; // Excel 會拒絕 0..7 之外的碼
Exit;
end;
ignoreErrors := optCode in [2, 3, 6, 7];
prevIgnoreHidden := FIgnoreHiddenRows;
prevIgnoreSubtotal := FIgnoreSubtotalCells;
FIgnoreHiddenRows := (optCode in [1, 3, 5, 7]) and Assigned(FIsRowHidden);
FIgnoreSubtotalCells := (optCode in [0, 1, 2, 3]) and Assigned(FIsSubtotalCell);
try
// ... 把 function_num 映射到內層 iftab,走訪 ref1..refN ...
finally
FIgnoreHiddenRows := prevIgnoreHidden;
FIgnoreSubtotalCells := prevIgnoreSubtotal;
end;
注意這兩個旗標是無條件被指定的,而不是只在選項要求它們時才設起。2.382.0 版仍然用 if ... then FIgnoreHiddenRows := True,這代表一個巢狀在 SUBTOTAL(109, ...) 裡、碼為 4 的 AGGREGATE,會繼承外層的隱藏列閘門而不是把它清掉。在進入時指定解碼後的值、並在 finally 區塊裡還原先前的值,讓每一個 AGGREGATE 呼叫只在自己的走訪期間擁有自己的原則,不多一分。2.382.0 版也讓陣列形式誠實起來:當某個引數求值成一維或二維的 Variant 陣列時,CalcAggregateFunc 現在會走訪每個元素並逐元素套用錯誤原則,而舊程式碼只檢查是不是 NaN double,其他情況就把整個陣列交給 ExcelSum
為什麼外層 AGGREGATE 會滲進它參照的公式
因為 FIgnoreHiddenRows 與 FIgnoreSubtotalCells 是計算器上的欄位,而那個計算器被一次重算期間求值的每一條公式共用。這些閘門被設計成暫存欄位,正是為了讓六個儲存格走訪迴圈能查它們,而不必把參數穿過每一個函式簽章,而只要閘門設起期間執行的每一件事都屬於設起它的那個聚合,這個設計就是站得住腳的。這個假設只在一處破掉:FGetValue。當走訪器向活頁簿要一個儲存格值,而那個儲存格裝著一條沒有快取結果的公式時,活頁簿會就地編譯並求值那條公式,在同一個 TXLSCalculator 上,而外層閘門仍然設著。HotXLS.WorkbookApiTests.pas 裡的回歸測試資料用四個儲存格展示了這個失效。A1 是 10,A2 是隱藏列上的 20,A3 是 =1/0,而 A4 是 =SUBTOTAL(9,A1:A2),正確值是 30。現在求 =AGGREGATE(9,7,A1:A4):忽略隱藏列、忽略錯誤、把巢狀 subtotal 當成一個值。Excel 回傳 10 + 30 = 40。在 A4 沒有快取的情況下,2.382.3 之前的引擎設起了隱藏列閘門、走到 A4、觸發它的求值,而碼 9 的 CalcSubtotalFunc 繼承了那個已設起的閘門,因為它只在 101 到 111 的碼時設起旗標,從來不清掉它。A4 求值成 10 而不是 30,外層總和回來是 20。在產生那個錯誤數字的整條路徑上,兩條公式都沒有任何地方提到隱藏列
巢狀聚合閘門也以另一個方向同樣地滲漏。在 0 到 3 的碼下,FIgnoreSubtotalCells 是設起的,而 GetValueItemRange 裡的通用範圍走訪器會遵守它,所以一條公式為 =SUM(B1:B3) 的前置項,會在 B2 剛好裝著一個 SUBTOTAL 時默默把 B2 丟掉。更糟的是,CalcSubtotalFunc 在離開時把 FIgnoreSubtotalCells 重設成 False,而不是還原先前的值,所以在走訪中途碰到一個未快取的 SUBTOTAL 前置項,就會讓之後每一個儲存格的外層閘門都被解除。專案的已知問題登記表把這件事歸在 HXLS-008 底下,稱之為巢狀選取狀態滲漏,而這正是這一類 bug 該有的名字:一個全域的暫時旗標,對設起它的那一層框架是對的,對每一個繼承它的框架則是錯的
AggregateGetCellValue 與 AggregateGetItemValue 如何隔離這趟走訪
v2.382.3 的修法,在 AGGREGATE 讀取它自己沒算過的值之每一處周圍放了一道邊界。TXLSCalculator.AggregateGetCellValue 包住原始的 FGetValue 呼叫:它存下兩個旗標、把它們清掉、執行取值,並在 finally 區塊裡還原它們。外層聚合仍然對它剛取到的儲存格套用自己的原則,因為隱藏列與巢狀儲存格的測試發生在取值周圍的走訪器裡,但前置項公式本身是在完全沒有原則的情況下執行,而那正是 Excel 的做法
function TXLSCalculator.AggregateGetCellValue(SheetIndex, Row, Col: Integer;
var Value: Variant; var OutOfRange: Boolean): Integer;
var
Hidden, Nested: Boolean;
begin
Hidden := FIgnoreHiddenRows;
Nested := FIgnoreSubtotalCells;
FIgnoreHiddenRows := False; // 前置項公式擁有自己的原則
FIgnoreSubtotalCells := False;
try
Result := FGetValue(SheetIndex, Row, Col, Value, OutOfRange);
finally
FIgnoreHiddenRows := Hidden;
FIgnoreSubtotalCells := Nested;
end;
end;
AggregateGetItemValue 對非範圍引數做同樣的事,而且它得做的比清旗標更多,因為像 A1:A4/(B1:B4-20) 這樣的引數是一個算出來的陣列,它的元素形狀必須存活下來。這個包裝器透過 AggregateGetCellValue 把一個普通範圍實體化成二維 Variant 陣列,把回傳錯誤碼的儲存格映射成 VarAsError,好讓錯誤原則仍能逐元素套用,並且用 ApplyArrayBinaryOp 與 ApplyArrayUnaryOp 遞迴穿過二元與一元運算子節點(SA_ADD、SA_DIV、SA_UNARMINUS 以及其他);其他任何東西就落到一般的 GetValueItem。實體化之前坐著兩道守衛:大於 EffectiveFormulaArrayMemoryLimit 的範圍回傳 lxErrorResourceLimit,而跨工作表或反向的範圍回傳 #VALUE!。資源上限碼在 2/3/6/7 的選項下,刻意不被當成可忽略的儲存格錯誤,因為一個只因為使用者要求跳過 #N/A 就吞掉自己記憶體不足訊號的引擎,是在說謊。三個 AGGREGATE 走訪器,也就是 SUM 家族的 AggregateCollectRange、STDEV、VAR 與 PRODUCT 的 AggregateReduceVariance,以及 MEDIAN 與各分位形式的 AggregateReduceWithK,全都從 FGetValue 與 GetValueItem 換到那兩個包裝器,而且每一個都透過 FIsSubtotalCell 取得了巢狀儲存格的測試
當 AGGREGATE 不忽略錯誤時,它回傳哪一個錯誤
原本那一個,從 v2.382.3 起。2.382.0 版正確地偵測到錯誤儲存格,卻把每一個都壓成 lxErrorValue,所以在一個 #DIV/0! 儲存格上求 AGGREGATE(9,4,A1:A3) 會回傳 #VALUE!,而 Excel 是原樣往外傳遞它遇到的第一個錯誤。取代它的輔助函式 AggregateErrorCode 把一個 Variant 映射到對應的 lxError* 碼,不管那個 Variant 是真正的 varError 還是七個錯誤字串之一,而 AggregateValueIsError 現在就只是檢查結果是否非零。每個走訪器都記下它看到的第一個錯誤碼並回傳那個碼,這也代表一個公式從未被計算過、因而它的錯誤是以 FGetValue 的回傳碼而不是快取的 Variant 抵達的儲存格,往外傳遞的方式與已快取的完全一樣。有兩個計數函式在 AggregateCollectRange 裡得到特殊待遇,而那個待遇對齊的是 SUBTOTAL 而不是 SUM。對內層函式 0,也就是 COUNT,錯誤儲存格永遠不被計數、也永遠不往外傳遞,不管 options 碼是什麼,因為 COUNT 只數數字。對內層函式 169,也就是 COUNTA,錯誤儲存格是一個非空值,算作 1,除非 options 碼忽略錯誤,那種情況下它被跳過。這個不對稱正是 Excel 在 AGGREGATE 之外對待 COUNT 與 COUNTA 的方式,而它正是那種會被一條通用的「有錯誤就往外傳」規則悄悄弄錯的細節
八選項回歸矩陣驗證了什麼
上面所述的測試資料在 AggregateFunc_OptionMatrixCoversHiddenErrorsAndNestedAggregates 裡被當成完整矩陣演練:對 0 到 7 的每一個 options 碼,它對 A1:A4 各求 SUM 形式與 MEDIAN 形式一次,並把結果拿來對照手工推導出的期望值。0、1、4、5 這幾個碼必須往外傳遞來自 A3 的 #DIV/0!,因為它們沒有一個忽略錯誤。碼 2 給出 SUM 30、MEDIAN 15,來自 10 與 20,巢狀的 A4 被跳過。碼 3 給出 10 與 10。碼 6 給出 60 與 20,因為 A4 裡的 30 現在算進去了。碼 7 給出 40 與 20,而那正是滲漏修好之前回傳 20 的案例。登記在已知問題表中的更廣泛驗收執行,涵蓋全部十九個函式編號對上全部八個碼,每個前置項都分別快取與未快取,在 Win32 與 Win64 上共 304 個情境
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Data');
Sheet.Cells[1, 1].Value := 10;
Sheet.Cells[2, 1].Value := 20;
Sheet.Cells[3, 1].Formula := '=1/0';
Sheet.Cells[4, 1].Formula := '=SUBTOTAL(9,A1:A2)'; // 分組小計 = 30
Sheet.RowHidden[2] := True;
Sheet.Cells[6, 1].Formula := '=AGGREGATE(9,1,A1:A4)'; // #DIV/0! 跳過隱藏列,錯誤往外傳
Sheet.Cells[7, 1].Formula := '=AGGREGATE(9,3,A1:A4)'; // 10 隱藏列 + 錯誤 + 巢狀都跳過
Sheet.Cells[8, 1].Formula := '=AGGREGATE(9,6,A1:A4)'; // 60 只跳過錯誤
Sheet.Cells[9, 1].Formula := '=AGGREGATE(9,7,A1:A4)'; // 40 在 v2.382.3 之前是 20
Book.Recalculate;
Book.SaveAs('aggregate-options.xlsx');
finally
Book.Free;
end;
end;
界線仍然在哪裡
在您以此為基礎建置之前,有三個限制值得知道。第一,巢狀聚合的述詞是文字上的。TXLSXWorkbook.GetCalcIsSubtotalCell 與它在經典引擎的雙胞胎,會在一個儲存格的公式以 SUBTOTAL(、AGGREGATE( 或 _xlfn.AGGREGATE( 開頭時回答 True,不論有沒有前置的等號,所以像 =IF(C1,SUBTOTAL(9,B1:B9),0) 或 =SUBTOTAL(9,B1:B9)*2 這樣的公式不會被認成巢狀,而會被 0 到 3 的碼重複計入,儘管 Excel 會跳過它;一個會產生計算型小計的產生器,應該把聚合呼叫留在公式開頭。第二,隔離只住在三個 AGGREGATE 走訪器裡。CalcSubtotalFunc 仍然走 GetValueItemRange、CollectRangeValues 與 SubtotalReduceVariance,而它們直接呼叫 FGetValue,所以一個範圍內含未快取前置項公式的 SUBTOTAL(109, ...),仍然可以把它的隱藏列閘門傳進那個前置項。完整的 Recalculate 會先算前置項再算相依項,所以走的是已快取路徑、閘門永遠不會被繼承;曝險僅限於透過 Calculate 的臨時求值,以及載入時沒有快取值的工作簿,而如果您靠沿相依圖做的增量重算來讓大型模型保持靈敏,同一份順序保證正是讓這個滲漏保持休眠的東西。第三,兩道閘門都以 Assigned(FIsRowHidden) 與 Assigned(FIsSubtotalCell) 為條件。兩個活頁簿外觀類別都在建構函式裡接好回呼,但只帶兩個原始引數手工建出 TXLSCalculator 的程式碼,會對每一個 options 碼默默得到舊的全含行為。當一個總和看起來不對而公式文字看起來正確時,逐步追蹤求值過程是看清一個前置項是在繼承來的閘門下被求值、還是某個回呼根本沒被接上的最快方法
這裡所述的計算引擎、選項解碼器、隔離過的取值包裝器,以及把它們釘住的回歸矩陣,都附原始碼在 HotXLS Delphi 試算表元件裡出貨,它能在 Delphi 與 C++Builder 中讀取、寫入與重算 XLS、XLSX 與 ODS 活頁簿,不需要安裝 Excel