技术文章

Delphi 中 HotXLS 定义名称的隐式交集

指向整列的定义名称出现在标量位置时,Excel 把它读作单个单元格:第 7 行的 =Vertical+1 意思是“Vertical 的第 7 行那个单元格”,而不是整个区域。HotXLS Delphi Component 在两个层面应用这种隐式交集——求值时和依赖提取时,因为光把值算对还不够。把名称展开成整个区域的依赖遍历器,会让任何以该区域单元格为输入的下游公式关上一个并不存在的循环,TXLSXWorkbook.Recalculate 只好把整个工作簿按循环引用拒掉

一份 4805 条公式的股票贷款摊销工作簿是把这些机制全部拉上场的压力案例。从过期缓存正确重算它依赖三个互不相干的机制:名称的隐式交集、lookup 扫描的排序、嵌套算术使用的数值类型。任何一处出错,症状要么是 lxErrorRef(循环引用码),要么是总额偏离逐行独立计算。本文带着相关源码把每个机制走一遍

对标量引用列名为什么会造出假循环?

因为依赖图只认边,而从公式指向 480 行区域的边是 480 条边,其中一条经由一个依赖该公式的单元格指了回来。设 B1 是 =IF(TRUE,Vertical+1,0),Vertical 定义为 Inputs!$A$1:$A$2,A2 是 =B1+1。Excel 把 B1 算成 A1+1、A2 算成 B1+1,一条直链。把 B1 记录为依赖 A1:A2 的遍历器让 A2 成了 B1 的前件,而 A2 已经把 B1 列为前件,驱动 HotXLS 增量重算的 Kahn 队列永远等不到任何一个节点入度归零。贷款模板正是这个模式:每个期间行引用余额、利率和期数这几个命名列,每个名称横跨整张摊销表,而每行又写进这些列。展开名称,图变成一个巨大的强连通分量;用隐式交集求值,图变成一组短链、每行一条——这正是 ECMA-376 第 1 部分 §18.17.2 对“在需要单值的位置消费的引用操作数”的描述

展开列名为何关出假循环:Vertical 定义为 Inputs!$A$1:$A$2 时,把 B1 记录为依赖 A1:A2 的遍历器让 A2 成为 B1 的前件而 A2 已把 B1 列为前件,Kahn 队列永不排空;交集把 B1 收窄到行单元格 A1,保住 Recalculate 能排序的每行链 A2、B1、A1
展开名称让图变成一个巨大的强连通分量;同样的公式改用隐式交集求值,图就变成短链,每行摊销表一条
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Inputs');
    Book.DefinedNames.Add('Vertical', 'Inputs!$A$1:$A$2');
    Book.DefinedNames.Add('Alias', '=Vertical');
    Sheet.Cells[1, 1].Value := 1;
    // 标量位置:公式在第 1 行,Vertical 收缩为 A1
    Sheet.Cells[1, 2].Formula := '=IF(TRUE,Vertical+1,0)';
    Sheet.Cells[2, 1].Formula := '=B1+1';
    // 定义是另一个名称的名称仍参与交集,所以这里是 A2
    Sheet.Cells[2, 2].Formula := '=Alias';
    // 引用类实参:整个区域求和,不做交集
    Sheet.Cells[3, 2].Formula := '=SUM(Vertical)';
    // 第 6 行在 A1:A2 之外,交集为空,IFERROR 接住它
    Sheet.Cells[6, 2].Formula := '=IFERROR(Vertical,42)';

    if Book.Recalculate = lxOk then
    begin
      // B1 = 2, A2 = 3, B2 = 3, B3 = 4, B6 = 42
      // 这里若把 Vertical 展开成 A1:A2,B1 -> A2 -> B1 就成了假循环
    end;
  finally
    Book.Free;
  end;
end;

HotXLS 如何判定一个实参是标量?

HotXLS 从函数表读答案,而不是看实参的形状。TXLSFormula.InitFuncHash 里的每个条目经 THashFunc.SetValue 注册,带一个可选的逐实参类别字符串:'IF' 带 '100','SUMIF' 带 '010','VLOOKUP' 带 '1011','SUM' 什么都不带,于是它的全部实参回退到函数级类别 0。TXLSFormula.FunctionArgumentClass(APtg, AArgument) 经 THashFuncEntry.ArgClass 暴露那个字节,结果 1 表示值类。这正是 [MS-XLS] §2.2.2 派给操作数 token 的那三类,编码器同样依赖它们:写引用时按 $24 + $20 * aClass 计算 ptg,得到类别 0 的 PtgRef、类别 1 的 PtgRefV、类别 2 的 PtgRefA。Excel 写出的 BIFF 文件把该类别存进每个引用 token,所以表与规范一致的引擎不用看数据就能回答“这个实参是不是标量”。SUMIF 的中间实参是判据,一个值;第一、第三是区域,引用。SUMPRODUCT 以函数级类别 2(数组)注册,所以 =SUMPRODUCT(Vertical,Vertical) 照样乘整个区域

三个函数从第一个实参往后不再查自己的表条目。IF(ptg 1)、CHOOSE(ptg 100)和 IFERROR(ptg 255)原样传递它们选中的分支,所以分支实参继承函数自身所处位置的类别。仅凭这一条规则,G2 的 =CHOOSE(1,Vertical,0) 解析到 A2,旁边求和两行的 =SUMIF(Vertical,">0",Vertical) 照常求和;摊销表最吃这条规则,因为它的期间单元格靠 IF 判断贷款是否仍未结清

HotXLS 为隐式交集读取实参类别的位置:IF 注册 100、SUMIF 注册 010、VLOOKUP 注册 1011、SUM 不注册而实参回退到类别 0,编码器把引用 token 写成 ptg $24 加 $20 乘类别从而产生 PtgRef、PtgRefV、PtgRefA,而直通函数 IF、CHOOSE、IFERROR 继承其所处位置的类别
类别表与规范一致,引擎不用看数据就能回答实参是否标量;CHOOSE 解析到 A2 而旁边的 SUMIF 求和两行,出自同一条规则

让类别贯穿依赖遍历

lxCalc.pas 里的依赖提取器是对编译后语法树的递归 Walk,它存在两份:一份在 TXLSCalculator.ExtractDependencies,服务工作簿内依赖图;一份在 ExtractWorkspaceDependencies,服务跨工作簿依赖图。两个遍历器都多带两个参数。AScalar 在公式根部为 True,对每个函数子节点按 FunctionArgumentClass 重算,对 ptg 1、100、255 的分支实参原样传递。ANameRoot 只在遍历器深入某个名称的编译定义时变 True,且只穿过 SA_GROUP 节点(括号)存活,所以定义为 =A1:A2+1 的名称不会被误当成普通区域。两个标志在 SA_RANGE 节点同时为 True 时,AddResolvedRange 先用求值器用的同一个辅助函数收窄区域,再记录依赖。这个辅助函数短得可以全文引用

HotXLS 中守护名称依赖的 IntersectNamedScalarRange 判定:已是单格的区域原样通过;单列在 CurRow 落在范围内时收窄到公式行;单行收窄到公式列;其余情形——二维区域或越界行——求值时给出 #VALUE! 且完全不记录依赖
两个依赖遍历器与求值器调用同一个辅助函数,公式读到的值与图记录的边,对被交集的名称永远说法一致
function IntersectNamedScalarRange(CurRow, CurCol: Integer;
  var Row1, Row2, Col1, Col2: Integer): Boolean;
begin
  Result := False;
  if (Row1 = Row2) and (Col1 = Col2) then Exit(True);   // 已是单格
  if (Col1 = Col2) and (CurRow >= Row1) and (CurRow <= Row2) then
  begin
    Row1 := CurRow; Row2 := CurRow;                     // 单列:取这一行
    Exit(True);
  end;
  if (Row1 = Row2) and (CurCol >= Col1) and (CurCol <= Col2) then
  begin
    Col1 := CurCol; Col2 := CurCol;                     // 单行:取这一列
    Result := True;
  end;
end;

辅助函数拒绝的一切——二维区域、跨表引用、公式所在行落在命名列之外——在求值侧给出 #VALUE!、在图侧不记录任何依赖,这正是 Excel 对空交集的行为。求值侧住在 TXLSCalculator.GetValueItemName:它从编译定义剥掉 SA_GROUP 包装,根是 SA_RANGE 就调 GetRangeInfo、做交集、经 FGetValue 取那一个单元格,而不是对整个定义求值。外部引用走常规路径,因为没有可交集的本地行。名称的存储与作用域从何而来,见定义名称与跨表公式一文;这里只关心名称解析之后引擎做什么

MATCH 为什么必须等半算完的列?

因为 MATCH 的查找数组实参是扫描引用,而扫描引用被有意排除在硬求值顺序之外。lookup 扫描一文介绍了 TXLSDepRange.LookupScan,并写明把扫描边排除出排序的代价:lookup 公式可能在其区域尚未全部重算时运行、读到过期值。交互会话里下一轮就收敛;对缓存过期的模板做一次性批量重算则不会:PaymentCount(定义为 =MATCH(0.01,Balances,-1)+1)会读到余额列里尚未更新的值,返回一个不可能正确的期数

所以 TXLSDepGraph.TopoOrder 把扫描边当作软排序边。它在硬入度之外维护一个 ScanInDeg 数组,统计每个节点的脏扫描前件并随前件输出而递减,用的是图里本就存着的 ScanPrecedents、ScanDependents 和 ScanPrecedentCount 列表。每轮迭代,Kahn 队列在就绪窗口里扫第一个 ScanInDeg 为零的节点并把它换到队首;若就绪节点全都还在等扫描前件,队首按稳定顺序弹出。扫描边从不进入硬入度,所以对自己的列做自引用 VLOOKUP 依旧合法;而等得起一个可完成前件的 lookup 就真的等。三个余额单元格还揣着过期值时,PaymentCount 回来是 3;把输入拨到零,=IFERROR(PaymentCount,99) 看到 #N/A 返回 99

嵌套算术为何被强制成 Double

因为 Delphi 的 Variant 算术有个只在嵌套位置现形的坑。把顶层的 + 或 - 拷进两个 Double 局部变量,=B1-A1 保持精确。而在 =IF(TRUE,B1-A1,0) 里,想当然的实现会以 Value := Value - SubValue 对两个 Variant 做同样的减法:一个操作数是 Int64 单元格值、另一个是 Double 时,我们观察到的结果类型是 Currency——一种四位小数的定点类型——于是 1066.1854641400994 减 120 回来被截成四位小数。摊销表里每期都从上一行复利而来,这个误差会走过数百个期间才抵达合计栏;这就是 TXLSCalculator.GetValueItem 在运算符执行前先把两个操作数转换掉的原因

// TXLSCalculator.GetValueItem,二元算术分支(lxCalc.pas)
if VarIsNull(Value) then Value := 0;
if VarIsNull(SubValue) then SubValue := 0;
// 混合 Int64/Double 的 Variant 算术可能升格成 Currency。
// 电子表格算术必须保住浮点精度。
if VarIsNumeric(Value) then Value := Double(Value);
if VarIsNumeric(SubValue) then SubValue := Double(SubValue);

这道防护在 SA_ADD、SA_SUB、SA_MUL 和 SA_DIV 上一视同仁,所以 A1 放 Int64(120)、B1 放 1066.1854641400994 时,嵌套的差、和、积、商全部保住双精度。HotXLS 不假装通晓 RTL 对混合 Variant 类型在不同编译器版本下施加的每条升格规则;它把电子表格算术当作 IEEE double,在运算符见到操作数之前把两者都变成 double,问题就此消失

引擎对命名列保证什么

三个机制就位后,两种引擎架构都把摊销模板从过期缓存重算到 lxOk,全部 4805 个值与逐行独立预期在 1E-7 内吻合。没有开迭代,也没有压掉任何错误码。穿过名称的真循环——A1 放 =B1 而 B1 仍读 Vertical——照旧返回错误,理应如此

这些规则跟随 Excel。隐式交集适用于编译定义剥掉括号后是单工作表上单列或单行区域的名称;二维名称出现在标量位置是 #VALUE!,与 Excel 一致;表里不认识的函数从 FunctionArgumentClass 拿到类别 0,其名称实参被完整展开。软排序是偏好而非保证:纯扫描环按稳定顺序求值、读到什么算什么,这是 lookup 扫描一文有意接受的取舍。HotXLS 是无需安装 Excel 即可读写、重算 XLS、XLSX、ODS 和 CSV 的原生 Delphi 与 C++Builder 电子表格组件;名称交集、实参类别表和软扫描排序适用于所有格式,因为计算引擎是共享的;当前函数覆盖情况见 HotXLS Delphi 电子表格组件产品页