如果一份包含隐藏行的工作簿中,SUBTOTAL(109, ...)和SUBTOTAL(9, ...)返回的是同一个数字,那么这两者中必定有一个是错的。HotXLS是面向Delphi和C++Builder的原生Excel电子表格组件,在2.197.0版本之前它的行为恰恰就是如此,因为它的计算引擎根本没有办法去询问某个工作表某一行是否被隐藏
这个症状很少是以"公式代码有bug"这种形式出现的问题报告。它出现的方式是一次数字不一致:服务器端的批处理作业算出一个总和,用户在Excel里打开同一份文件、应用了一个筛选器,两个数字之间的差额恰好等于被筛掉的那些行加起来的和。没有人会去怀疑聚合函数本身,因为两处单元格里的公式字符串一模一样。区别完全在于求值器被允许看到什么
为什么SUBTOTAL 109会把隐藏行也算进去?
因为在大多数引擎设计中,负责求值公式的那一层根本不知道行的可见性。HotXLS就是一个教科书式的案例:lxCalc.pas里的计算引擎通过一个单一的TXLSGetValue回调来获取单元格的值,这个回调只接受一个(工作表, 行, 列)三元组,除此之外什么也不返回。可见性是存储在行记录上的一个展示属性,调用链上没有任何一环把它往下传递。因此这个引擎只有一条聚合路径,而SUBTOTAL函数编号表的前后两半都指向这同一条路径。这不是一类误差累积式的小问题:这恰恰是那张表后半部分之所以存在的全部理由。ECMA-376 Part 1(以ISO/IEC 29500-1的形式发布)在其公式函数定义章节(§18.17.7)中把SUBTOTAL定义为第一个参数同时选择内部聚合方式和隐藏行策略。编号1到11分别映射到AVERAGE、COUNT、COUNTA、MAX、MIN、PRODUCT、STDEV、STDEVP、SUM、VAR和VARP,并把手动隐藏的行也计算在内。编号101到111对应同样这十一种聚合方式,但会把它们排除在外。一个输入109而不是9的用户,是在就隐藏数据这件事做出明确表态,而一个把这两者混为一谈的引擎,等于是在悄悄推翻这个表态
这些函数编号在引擎内部对应到哪里
HotXLS在CalcSubtotalFunc中解析SUBTOTAL的第一个参数,把编号101到111归一化到和编号1到11相同的内部函数标识符上,再据此分发到具体的聚合逻辑。这个家族中的大多数会走增量式的ExcelSum累加器,也就是处理SUM、COUNT、COUNTA、MIN、MAX和AVERAGE的那一个。有五个走不了这条路:STDEV、VAR、STDEVP、VARP和PRODUCT需要对数据做一次封闭形式的遍历,所以CalcSubtotalFunc把内部编号12、46、193、194和183路由到另一个独立的归约器SubtotalReduceVariance。在动手改任何东西之前,这个拆分是首先值得梳理清楚的一点,因为两条独立的聚合路径意味着两个独立的单元格遍历循环,一次只修了其中一条路径的修复会产生最糟糕的结果:SUBTOTAL(109, ...)遵循了筛选,而同一个范围上的SUBTOTAL(107, ...)却没有。把AGGREGATE也算进来之后清点这些循环,一共有六个,分散在范围求值、普通范围收集和三个独立的归约器之中
为什么用一个临时字段,而不是新增六个函数签名
因为为了一个布尔值,就把一个新参数穿透六个单元格遍历函数、以及所有调用它们的地方,是对一条热点代码路径动的一次大手术。HotXLS已经有过这种替代方案的先例:在计算器上加一个瞬态字段,和GetRangeInfo用来记录一次三维引用是否解析到了外部工作簿的那个临时字段是同一个思路。2.197.0版本又加了第二个。引擎新增了一个回调类型TXLSIsRowHidden,声明为一个接受(工作表索引, 行)、返回布尔值的函数,存储在FIsRowHidden中,另外还加了一个瞬态的FIgnoreHiddenRows标志。当函数编号落在101到111之间时,这个标志会在CalcSubtotalFunc入口处被置位;对于选择了排除隐藏行的AGGREGATE选项编号,则在CalcAggregateFunc入口处置位。此后每一个单元格遍历循环都会检查这个标志,一旦置位就跳过对应的一行,每处只需要多加一行代码
// The shape repeated in all six cell-walk loops
for sh := s1 to s2 do
for rr := r1 to r2 do
begin
if FIgnoreHiddenRows and Assigned(FIsRowHidden) and FIsRowHidden(sh, rr) then
Continue;
for cc := c1 to c2 do
begin
// ... fold Cells[rr, cc] into the accumulator ...
end;
end;
置位这段代码里有两个细节,关系到整套方案的正确性。这个标志是保存后再恢复的,而不是简单地置位再清零,因为一个SUBTOTAL的参数本身可能包含一个表达式,这个表达式会在外层聚合还在调用栈上的时候运行自己的求值过程,而这次嵌套的求值绝不能继承或者破坏外层的这道闸门。而且恢复操作放在一个finally块里,因为CalcSubtotalFunc有好几处针对错误码的提前返回;如果一次错误返回之后这个标志还留在置位状态,就会悄无声息地污染重算顺序中接下来那个毫不相干的公式
prevIgnoreHidden := FIgnoreHiddenRows;
if (fnCode >= 101) and (fnCode <= 111) and Assigned(FIsRowHidden) then
FIgnoreHiddenRows := True;
try
// aggregate over Item.Child[2] .. Item.Child[ChildCount]
// every Exit path below is covered by the finally
finally
FIgnoreHiddenRows := prevIgnoreHidden;
end;
Assigned这个判断正是保持改动向后兼容的关键。HotXLS给计算器的构造函数扩展了第三个参数,默认为nil,所以任何用旧的两参数写法构造TXLSCalculator的代码依然能编译通过,依然会得到把隐藏行也算进去的旧行为。现有API的外形没有发生任何变化
隐藏行这一位信息到底是从哪里来的?
来自工作表,经由两个不同的来源,因为HotXLS内部同时带着两套工作簿引擎。旧式BIFF这一侧是从TXLSRowInfoList.GetHidden取值,经由TXLSWorkbook.GetRowHidden暴露出来。OOXML这一侧是从TXLSXWorksheet.GetRowHidden取值,经由TXLSXWorkbook.GetCalcRowHidden暴露出来。两者都在构造计算器时被接入,与它们各自对应的单元格取值回调一起接入。这类桥接代码通常最容易出问题的地方就是行号的约定方式,所以这里值得明确说清楚。计算器传给回调的行号是从0开始的,与TXLSGetValue已经使用的坐标体系一致。而XLSX工作表的隐藏行映射表是按从1开始的行号来建索引的,正如Excel给行编号的方式一样,公开的RowHidden[ARow]属性暴露出来的也是这套编号。因此XLSX这一侧的桥接代码在查找之前会加一,而BIFF这一侧不会,因为TXLSRowInfoList本来就是从0开始的。两边的桥接代码都把超出有效范围的工作表索引或行号当作可见处理,所以一次越界查询会退化成旧的"隐藏行也算进去"的答案,而不是丢失数据
对于带筛选的工作簿,什么变了
这正是引发支持工单的那种情况。在HotXLS中通过ApplyAutoFilter应用一个自动筛选,会对列条件求值,并隐藏所有不匹配的数据行,这和用户点击一个筛选下拉框时Excel所做的事情完全一样。在v2.197.0之前,这些隐藏行对用户不可见,但对计算引擎却完全可见,所以服务器端的SUBTOTAL(109, ...)报告的是未筛选状态下的总和。现在同一次调用报告的是筛选之后的结果
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
VisibleRows: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('orders.xlsx');
Sheet := Book.Sheets[0];
Sheet.SetAutoFilter('A1:E500');
Sheet.AddAutoFilterColumn(3, xlsxAfOpGreaterOrEqual, '1000');
VisibleRows := Sheet.ApplyAutoFilter; // hides the non-matching rows
Sheet.Cells[501, 4].Formula := '=SUBTOTAL(109,D2:D500)';
Book.Recalculate;
// The cell value now agrees with what Excel shows for the same filter,
// and VisibleRows tells you how many rows fed into it
Book.SaveAs('orders-filtered.xlsx');
finally
Book.Free;
end;
end;
手动隐藏的效果是一样的,因为RowHidden[ARow] := True写入的状态和筛选写入的状态完全相同。这种等价关系在Excel里本来就是刻意为之的设计,现在在HotXLS里也同样成立了。有一个后果值得写进你生成的工作簿所附带的任何文档里:一个用编号109算出来的总和是一个依赖于视图的数字,接收方如果清除了筛选,这个数字就会变。当一份报表需要陈述一个固定数字、不受读者对视图做了什么操作影响时,编号9才是正确的选择,而且一直都是。筛选、数据验证和表格在数据验证、自动筛选与表格一文中有统一介绍。由于隐藏行本身不涉及任何公式,它也不会自行弄脏依赖关系图,如果你依赖基于脏子图的增量重算来保持大型工作簿的响应速度,这一点值得了解
AGGREGATE的选项编号,以及一个仍未解决的限制
AGGREGATE本质上是带了第二个策略参数的SUBTOTAL,HotXLS在CalcAggregateFunc中处理它。选项参数编码了几个相互独立的开关:范围内嵌套的SUBTOTAL和AGGREGATE调用是否要跳过、隐藏行上的值是否要跳过,以及错误值是应该被抑制还是应该继续传播。HotXLS对选项编号2、3、6和7置位共享的隐藏行闸门,并对选项编号4到7抑制错误值。函数编号参数随后按SUBTOTAL的方式选择具体聚合方式,包括把方差、标准差和乘积路由到它们各自的归约器。目前还留着一处已知的缺口,与其等它在生产环境中被发现,不如现在就说清楚:与较低选项编号相关联的"忽略嵌套SUBTOTAL"语义,在HotXLS中尚未实现。要检测被引用范围内嵌套着的SUBTOTAL,需要标记求值器的递归状态,让内层聚合能够向外层聚合"通报"自己的存在,这是一个比隐藏行闸门大得多的改动。实践中这个风险敞口很小,因为真实的工作簿几乎总是把SUBTOTAL公式放在其他SUBTOTAL公式所聚合的范围之外。如果你的生成器确实会构造出相互重叠的聚合范围,请不要指望靠这些较低的选项编号来对它们去重
随之一起发布的参数个数校验
2.197.0版本还在同一个分发器里补上了一个校验缺口,其设计理由和促成那个临时字段的理由是一样的:把检查放在一个可以只写一次的地方。大约280个内置函数体各自独立地把自己的参数个数与Item.ChildCount做校验,这就导致"参数过多"这种情况没有一个统一一致的边界。像=SIN(1,2)这样的调用,会进入一个只检查第一个参数、忽略多余参数的函数体,返回一个看似合理的数字,而Excel在这种情况下返回的是#VALUE!。HotXLS的函数注册表里本来就存着每个内置函数声明的参数个数,通过THashFunc.ArgsCnt暴露出来,用-1标记像SUM、IF或CONCAT这样的可变参数函数。2.197.0版本通过一个新的TXLSFormula.FuncArgsCntByPtg属性把这个信息转发出来,并在主分发器GetValueItemFunc的最前面加了一道闸门
lDeclaredArgs := FFormula.FuncArgsCntByPtg[Item.IntValue];
if lDeclaredArgs >= 0 then
begin
lProvidedArgs := Item.ChildCount - 1; // Child[0] is the function node
if lProvidedArgs > lDeclaredArgs then
begin
Result := lxErrorValue; // =SIN(1,2) now yields #VALUE!
Exit;
end;
end;
这道闸门只拒绝参数过多的情况,刻意对参数过少的情况不做任何处理。在Excel中,省略末尾的可选参数对VLOOKUP、SUBSTITUTE以及一长串其他函数来说都是合法的,所以一个对称的检查会把正确的公式也一并拦下,只为了抓住那些不正确的公式。未知的标识符会被当作可变参数处理,从而完全跳过这道闸门,这也正是用户自定义函数不会受它影响的原因;如果你注册了自己的函数,公式引擎与自定义函数指南中描述的行为不受这次改动影响。把"参数过少"这种情况也集中处理是另一项独立的工作,因为那280个函数体各自有自己的错误码语义,必须逐一审查,而不能想当然地一概而论
这里介绍的计算引擎、两套工作簿门面,以及为它们提供数据的AutoFilter与行可见性API,都是HotXLS Delphi电子表格组件的一部分,随附完整源码,适用于Delphi和C++Builder,运行时无需在机器上安装Excel