HotXLS Delphi Component 把 =1<2<3 求值为 FALSE,与 Excel 16 给出的答案一致,因为从 v2.384.3 起,它的公式解析器把比较运算符从左到右折叠:1<2 得 TRUE,而 TRUE<3 是 FALSE,因为布尔值排在所有数字之上。同一个版本还让空操作数既等于 0 也等于 "",并允许 SUMIF 把单格求和区域拉伸成条件区域的形状。每一条看起来都像冷知识,直到你发现 Delphi 里算出来的工作簿和 Excel 里打开的同一份工作簿对不上
分歧往往从一条凭直觉写出的公式开始。有人敲下 =0<B2<100 想检查数量是否在范围内,Excel 对每一行都安静地回答 FALSE,然后这张表就带着这个 bug 发布了。计算引擎无权修正用户的意图;它的职责是产出 Excel 会产出的值,让 HotXLS 写进文件的缓存结果与 Excel 重算后显示的一致。v2.384.3 之前,HotXLS 对这类范围检查每行都答 TRUE,错在相反方向,服务器上生成的报告会和桌面上打开的同一份报告互相打架
Excel 里 =1<2<3 为什么返回 FALSE?
Excel 返回 FALSE,是因为它把比较链读作 (1<2)<3,内层的 TRUE 随后在和数字 3 的类型排序较量中落败。旧 HotXLS 解析器把同一段文本读作 1<(2<3):lxFormula.pas 里的 TXLSSyntax.Parse_expr 解析一个操作数,看到比较记号,就为右侧递归进 Parse_expr,这使运算符成了右结合。于是得到 1<TRUE,数字排在布尔之下,结果便是 TRUE。错误是对称的:=3>2>1 在 Excel 里是 TRUE、在 HotXLS 里曾是 FALSE,=1=1=TRUE 在 Excel 里是 TRUE、修复前也是 FALSE。回归测试 CalculateFormula_ComparisonChainsFoldLeftToRight 把七条这样的公式钉在 Excel 16 的返回值上,并让每一条都跑过两种引擎架构——经典的 TXLSWorkbook 和 XLSX 原生的 TXLSXWorkbook,用的是HotXLS 公式引擎总览里介绍的 Calculate 方法
const
Formulas: array [0..6] of string = ('=1<2<3', '=3>2>1', '=(1<2)<3',
'=1<(2<3)', '=1=1=TRUE', '=3>2>1=TRUE', '=1<2<3=FALSE');
// Excel 16 的返回值:FALSE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE
var
Classic: IXLSWorkbook;
Xlsx: TXLSXWorkbook;
i: Integer;
begin
Classic := TXLSWorkbook.Create;
Xlsx := TXLSXWorkbook.Create;
try
// TXLSXWorkbook.Calculate 对活动工作表求值,
// 工作簿没有任何工作表时返回 Null
Xlsx.Sheets.Add('Data');
for i := 0 to High(Formulas) do
Writeln(Formulas[i], ' classic=', VarToStr(Classic.Calculate(Formulas[i])),
' xlsx=', VarToStr(Xlsx.Calculate(Formulas[i])));
finally
Xlsx.Free;
end;
end;
修复把 Parse_expr 改成与 Parse_expr1 处理 +、- 和 & 时同形的循环。它先用 Parse_expr1 解析第一个操作数,只要下一个记号是 =、<>、<、>、<= 或 >= 之一,就创建一个比较节点,把累计的左侧结果挂为第一个子节点,用 Parse_expr1 而非 Parse_expr 解析下一个操作数,再让新节点成为下一轮的左侧结果。把递归改写成迭代时有两处细节容易出错,维护者笔记里都记了:累计节点必须按 lChild := Item; Item := nil 的顺序移交,错误路径在释放半成品节点后必须 Exit,不能落到循环外返回一棵悬空树
HotXLS 在比较中如何给数字、文本和布尔排序?
HotXLS 对混合类型的排序与 Excel 一致:所有数字小于所有文本,所有文本小于所有布尔。lxCalc.pas 里的 TXLSCalculator.CompareVariants 用 GetRetValueType 把两个操作数归入枚举 TXLSRetValueType = (xlVariantValue, xlNumberValue, xlStringValue, xlBooleanValue),两类不同时直接比较序数,所以这个枚举的声明顺序就是跨类型规则。同一类之内按自然顺序比较,文本有一个 Excel 特有的细节:两个字符串都先过一遍 lxUpperCase,因此 ="abc"="ABC" 是 TRUE。正因为有这套排序,链式比较的结果才没法绕开它去推理:TRUE<3 不是把 TRUE 强转成 1,而是布尔和数字比较,布尔赢。日期对引擎来说是序列号(varDate 归入 xlNumberValue),所以日期永远低于任何文本,包括碰巧长得像日期的文本
空单元格在比较中等于什么?
空单元格作比较操作数时,另一侧是数字则等于 0,另一侧是文本则等于 "",从 v2.384.53 起另一侧是逻辑值则等于 FALSE,所以在 A1 为空时 =A1=0、=A1="" 和 =A1=FALSE 全是 TRUE。服务于全部六个比较运算符的 TXLSCalculator.CompareVarValues 会在调用 CompareVariants 之前替换空值:恰好一个操作数是 Null 时,伙伴是字符串则换成 WideString(''),伙伴是布尔则换成 False,其余情况换成 0。两个空单元格互比仍然直接相等、不做替换。算术路径一直把空值当 0,这是 =A1+1 得 1 的原因,但 CompareVariants 过去把 Null 当独立的最低档保留,低于一切数字,比较运算符直接用了那一档
var
Wb: IXLSWorkbook;
Sh: TXLSWorksheet;
begin
Wb := TXLSWorkbook.Create;
Sh := Wb.Sheets.Add;
Sh.Range['B1', 'B1'].Value := 5; // A1 特意留空
Writeln(VarToStr(Wb.Calculate('=A1=0'))); // True
Writeln(VarToStr(Wb.Calculate('=A1=""'))); // True
Writeln(VarToStr(Wb.Calculate('=A1<B1'))); // True:空值按 0 比较
Writeln(VarToStr(Wb.Calculate('=A1<0'))); // False;v2.384.3 之前是 True
end;
最后一行才是实际伤人的那处。旧排序下,空值小于一切数字,负数也不例外,于是 =IF(A1<0,"overdrawn","ok") 把每个空的余额单元格都标成透支,而 =A1=0 对任何用户都会称作零的单元格返回 FALSE。v2.384.3 之后还留着一处边界:替换只在 0 和空串之间挑,空值与布尔比较时被换成 0,0 排在 TRUE 和 FALSE 之下,于是空 A1 上的 =A1=FALSE 求值为 FALSE。从 HotXLS 2.384.53 起,XLS 与 XLSX 两个引擎都把与逻辑值比较的空值当作 FALSE,与 Excel 一致:A1 为空时,=A1=FALSE 和 =A1<TRUE 返回 TRUE,=A1=TRUE 返回 FALSE。这也意味着比较分不清空值和 FALSE——Excel 和 HotXLS 都一样;表格确实需要这个区分时,用 ISBLANK 或 =A1="" 来测
为什么单格求和区域的 SUMIF 返回 0?
SUMIF 返回 0,是因为 HotXLS 把迭代范围钳到两个区域中较小的那个,而 Excel 保留条件区域的形状、只借用求和区域的左上角单元格。所以在 Excel 里 =SUMIF(A1:A10,">5",B1) 意味着 B1:B10,许多手工搭的模板就靠这个便利。共享的工作函数 TXLSCalculator.GetValueItemRange2 过去把行列数缩到值区域的尺寸,例题被缩减成 A1 对 B1 的一次单独比较。v2.384.3 拆掉了这把钳子:循环现在沿条件区域行走,在求和区域左上角的相同偏移处逐个取值。因为 CalcSumIF 和 CalcAverageIF 调用的是同一个工作函数,AVERAGEIF 也获得同样的尺寸调整;求和区域大于条件区域时,也按同样的理由裁剪成条件区域的形状。中间的条件参数是值类参数,外侧两个是引用类参数,这个区分在隐式交集与参数类别一文里有讲
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Row: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Sales');
for Row := 1 to 10 do
begin
Sheet.Cells[Row, 1].Value := Row; // 条件列:1..10
Sheet.Cells[Row, 2].Value := Row * 100; // 金额:100..1000
end;
Sheet.Cells[1, 4].Formula := '=SUMIF(A1:A10,">5",B1)'; // 单格求和区域
Sheet.Cells[2, 4].Formula := '=SUMIF(A1:A10,">5",B1:B10)'; // 显式求和区域
if Book.Recalculate = lxOk then
// D1 与 D2 都是 4000(600+700+800+900+1000);v2.384.3 之前 D1 是 0
Writeln(VarToStr(Sheet.Cells[1, 4].Value), ' ', VarToStr(Sheet.Cells[2, 4].Value));
finally
Book.Free;
end;
end;
INDIRECT 与 YEARFRAC:两处更安静的修正
INDIRECT 现在尊重它的第二个参数,合法引用之后的多余文本会报错而不是被忽略。a1 为 FALSE 时文本按绝对 R1C1 解析,所以 =INDIRECT("R2C3",FALSE) 读到 C2;旧代码无视这个开关,把 "R2" 当成 R 列、第 2 行,悄悄返回了错误的单元格。开关按 variant 类型分发(布尔、数字或文本),因为把字符串 variant 直接转 Double 会抛异常。相对 R1C1 文本如 R[1]C[1] 返回 #REF!,因为 INDIRECT 没有公式单元格原点可供参照;带尾随字符的 A1 文本如 "B2 junk" 同样返回 #REF!。YEARFRAC 在 basis 0 下现在套用 DAYS360 已实现的 NASD 二月末规则:两个日期都是二月最后一天时结束日记为 30,随后起始日是二月最后一天也记为 30。从 2024-02-29 到 2025-02-28,现在算出 360 天、分数恰好为 1,而旧的 Days360US 数出 359
这些修复保证了什么,教训又是什么?
比较链行为由一个用 Excel 16 实测值对照两个引擎的测试保证,而这个测试之所以存在,是因为对修复的第一次描述就写错了。v2.384.3 的发布说明最初声称从左到右折叠使 =1<2<3 变成 TRUE——这恰恰是旧右结合解析器的产物,与 Excel 和新代码的返回值都相反。没有人实际算过这个例子;它是照「1 小于 2 小于 3」的直觉写下的。发布说明在后续提交里被更正,七公式的测试也一并补上,由此得出的规则适用于任何给电子表格语义写文档的人:先在 Excel 里把例子跑一遍,再落笔期望值。空操作数替换和 SUMIF 尺寸调整同样跟随 Excel 行为,包括 v2.384.53 起的空值对布尔一例;还要跳过筛选或隐藏行的条件聚合,遵循的是SUBTOTAL 与 AGGREGATE 隐藏行一文里那套独立规则
HotXLS 是原生的 Delphi 和 C++Builder 电子表格组件,无需安装 Excel 即可读取、重算和写出 XLS、XLSX、ODS 与 CSV,本文的比较、空值和 SUMIF 规则都住在两种工作簿架构共享的计算引擎里。完整函数列表与授权选项见HotXLS Delphi 电子表格组件产品页