技术文章

HotXLS 在 Delphi 里的 AGGREGATE 选项矩阵与闸门泄漏

HotXLS,这个面向 Delphi 和 C++Builder 的原生 Excel 电子表格组件,在 2026 年 9 月发布了两个相关的 AGGREGATE 修复。版本 2.382.0 修正了 options 参数,让代码 1/3/5/7 忽略隐藏行、2/3/6/7 忽略错误值、0 到 3 忽略嵌套的 SUBTOTAL 与 AGGREGATE 单元格,与微软文档完全一致。版本 2.382.3 随后挡住了这些选择标志泄漏进该函数所引用单元格的求值过程。第一个缺陷难堪的方式正是抄表类 bug 一贯的难堪方式:位的位置搞反了,于是每个用了非零 options 代码的公式都拿到一个作者没要求的策略。第二个更有意思,因为这是一种你在任何用临时字段把上下文传进递归遍历的求值器里都会遇到的形态。外层聚合武装一个标志、遍历一个区域、拉到某个公式还没算过的单元格。那个公式在同一个计算器上运行,看到同一个被武装的标志,悄悄聚合了错误的行,产出一个谁也无法单从公式文本解释的数字

AGGREGATE 的 options 0 到 7 到底选了什么?

AGGREGATE 的 options 参数是一个三位矩阵,而这三个位彼此独立。第 0 位(值 1)表示忽略隐藏行,第 1 位(值 2)表示忽略错误值,第 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 把那个划分推广到 options 参数里,在 OOXML 文件中以 _xlfn. 前缀存储,所以微软为 AGGREGATE 函数发布的那张表是一个引擎必须满足的契约,而不是什么便利

选项隐藏行错误值嵌套的 SUBTOTAL / AGGREGATE
0计入传播忽略
1忽略传播忽略
2计入忽略忽略
3忽略忽略忽略
4计入传播计入
5忽略传播计入
6计入忽略计入
7忽略忽略计入

为什么 HotXLS 把 AGGREGATE 的选项搞反了?

因为最初的 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 把解码重写成三个集合成员判断,并为嵌套策略加上第二道闸门,通过一个新的 TXLSIsSubtotalCell 回调接通,由工作簿与 TXLSIsRowHidden 一起提供

HotXLS 的 AGGREGATE 选项解码在 v2.382.0 前后的对比示意图:原来的 CalcAggregateFunc 给代码 2、3、6、7 武装隐藏行闸门、对 4 及以上忽略错误且没有嵌套策略,而修正后的解码把隐藏行判在 1、3、5、7,错误判在 2、3、6、7,嵌套跳过判在 0 到 3
只有单一位代码暴露了这次对调,因为常见的隐藏加错误组合在两张表下都落在代码 3 和 7 上;而现在 0 到 7 之外的代码返回 lxErrorValue,与 Excel 拒绝它们的方式一致
// TXLSCalculator.CalcAggregateFunc,v2.382.3 的形式
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;

注意这两个标志是无条件赋值的,而不是只在选项要求时设置。v2.382.0 的版本还在用 if ... then FIgnoreHiddenRows := True,这意味着一个代码为 4 的 AGGREGATE 嵌套在 SUBTOTAL(109, ...) 里时,会继承外层的隐藏行闸门而不是把它清掉。在进入时赋上解码出的值、并在 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。在两个公式里,产生那个错误数字的路径上都没有任何东西提到隐藏行

外层的 HotXLS AGGREGATE 如何泄进它的前置公式示意图:代码 7 下 FIgnoreHiddenRows 被武装,遍历走到未缓存的 A4,它装着对 A1:A2 的 SUBTOTAL 9,FGetValue 在同一个计算器上求值它,CalcSubtotalFunc 继承闸门并返回 10 而不是 30,于是合计报出 20,而 Excel 返回 40
嵌套闸门在另一个方向上也会泄,而且 CalcSubtotalFunc 在退出时是把 FIgnoreSubtotalCells 重置而不是恢复,于是遍历中途碰上一个未缓存的 subtotal 之后,外层策略对后面的每个单元格都被解除了

嵌套聚合闸门在另一个方向上以同样的方式泄漏。代码在 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 就是这么做的

HotXLS v2.382.3 的隔离示意图:AggregateGetCellValue 保存两个闸门标志、清掉它们、通过 FGetValue 取值并在 finally 块里恢复,于是前置公式在没有任何策略的情况下求值,而外层遍历器仍然在取值周围套用隐藏行与嵌套单元格的判断
AggregateGetItemValue 对计算出来的数组参数做同样的事,并把取值错误映射成 VarAsError,而在忽略错误选项下,资源上限错误码被刻意地从不当作可忽略错误处理
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)';   // 分组 subtotal = 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 会跳过它;一个会生成计算型 subtotal 的生成器应当把聚合调用留在公式头部。第二,隔离只住在三个 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