HotXLS 是原生的 Delphi 和 C++Builder Excel 库,它通过 TXLSXWorkbook.Recalculate 进行增量公式重新计算;第一次调用会构建一个公式依赖图,并对每个公式单元格进行评估;此后的每次调用都会按照拓扑顺序,在单次扫描中仅重新评估自上次运行以来受数值写入影响的单元格,其开销与脏单元格的数量成正比,而不是与工作簿的大小成正比
这一个设计决策,决定了金融模型是对编辑的假设在几毫秒内做出响应,还是会卡顿几秒钟;如果您生成的报表中只有少数几个输入单元格,却供给着数以千计的下游公式,本文的其余部分将解释依赖图的作用、哪些函数不参与增量计算,以及如何报告循环引用而不是无限循环下去
为什么更改一个单元格要重新计算十万个公式?
天真的公式引擎记不住谁依赖谁,因此在任何编辑后,其唯一安全的举动就是重新评估所有内容;更糟糕的是,经典的递归策略 —— 当公式 A 引用公式 B时,当场评估 B —— 会无条件地重新评估被引用的单元格,而忽略任何缓存值;一个由 n 个公式组成且每个公式都引用前一个公式的链条,每次完整扫描的评估成本为 O(n²),并且循环引用会使递归彻底崩溃;每个将级联模型接入递归评估器的电子表格开发人员都曾目睹过这两种失效模式的发生
Excel 自身在几十年前就已经通过其计算链解决了这个问题:维护公式单元格的顺序,使得编辑只将一小部分单元格标记为脏,而引擎仅遍历该链条受影响的尾部;HotXLS 将相同的想法应用为显式的依赖图,在编译的公式树中仅构建一次,并在重新计算的运行中重复使用;关键并不在于聪明,而是在于重新计算的成本应当追踪您编辑的大小,而不是您工作簿的大小
依赖图如何将一次编辑转为单次扫描
HotXLS 依赖图为每个公式单元格分配一个节点,边从先决单元格指向依赖单元格;当您的代码写入单元格数值时,工作簿会将该单元格记录为脏;当 Recalculate 运行时,脏状态会沿着边传播到每个下游公式,并使用 Kahn 算法按照拓扑顺序对脏子图进行正好一次评估;因为公式绝不会在它的先决单元格被评估前被访问,所以每个节点仅需单次评估 —— 这正是使扫描的时间复杂度为 O(dirty) 的原因
拓扑顺序也从根本上解决了递归问题;在重新计算扫描期间,引擎切换到专用模式,在该模式下,对另一个公式单元格的任何引用都会直接读取该单元格的缓存值,而不会重新对其进行评估 —— 顺序保证了缓存已经是新鲜的;相同的机制意味着引用循环无法触发无限递归:扫描内没有任何内容会重新进入相邻单元格的评估器
var
Book: TXLSXWorkbook;
Inputs, Model: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Inputs := Book.Sheets.Add('Inputs');
Model := Book.Sheets.Add('Model');
Inputs.Cells[2, 2].Value := 0.05; // growth assumption
Model.Cells[2, 2].Formula := 'Inputs!B2*1000'; // XLSX formulas take no leading '='
Model.Cells[3, 2].Formula := 'B2*(1+Inputs!B2)';
// ... thousands more rows cascading off the same assumption ...
Book.Recalculate; // first call: builds the graph, full evaluation
Inputs.Cells[2, 2].Value := 0.07; // one edit marks one cell dirty
Book.Recalculate; // second call: only the downstream chain runs
finally
Book.Free;
end;
end;
每个结果都落在单元格的缓存 Value 中,因此在 Recalculate 返回后,您读取输出的方式与读取任何其他单元格完全相反;在报表生成循环中,模式与上面的代码完全相同:一次性加载或构建模型,然后在写入几个输入单元格与调用 Recalculate 之间交替进行,仅为实际上依赖于发生变化的单元格的公式支付开销
哪些 Excel 函数会在每次扫描时强制重新计算?
HotXLS 将 NOW、TODAY、RAND、OFFSET 和 INDIRECT 视为易失性函数:包含其中之一的任何公式在每次 Recalculate 扫描时都会被重新评估,无论上游是否发生了变化;前三个是易失性的,原因与它们在 Excel 中相同 —— 它们的结果取决于评估的时刻,而不是其他单元格;OFFSET 和 INDIRECT 易失的原因更微妙:它们读取的单元格是在运行时计算出来的,因此依赖图无法静态获知该为它们画哪些边
相同的保守规则也延伸到了图表构建器无法固定 to 单个矩形的引用上;穿过多个区域的已定义名称的公式,或者引用外部工作簿的公式,同样会降级为易失的并在每次扫描时重新计算;这一策略是刻意设计的:额外的评估会花费一点时间,但学术性缺失的依赖边意味着发出的报表中有静默的陈旧值,这是糟糕得多的失败;如果您的模型依赖于工作簿作用域的名称,关于已定义名称和跨工作表公式的配套文章介绍了单区域名称是如何解析的 —— 它们可以正常参与图表构建
实际的指导是显而易见的:将大型模型的热路径保持在普通的单元格和范围引用上(在这些引用上依赖图可以发挥作用),并将 OFFSET 和 INDIRECT 隔离在少数真正需要动态寻址的地方;拥有 1000 个易失性公式的模型在每次扫描时都会重新运行这 1000 个,无论编辑多么微小 —— 这正是 Excel 用户从“按键即重新计算”的工作簿中熟知的行为
HotXLS 如何报告循环引用?
TXLSXWorkbook.Recalculate 在干净通过时返回 lxOk,在检测到引用循环时返回 lxErrorRef;循环成员在拓扑排序期间被识别出来 —— 它们是 Kahn 算法永远无法释放的节点 —— 并且它们会被跳过而不是无限循环:它们的缓存值保持不变,而循环之外的每个公式仍正常按顺序进行评估;您的调用方会得到一个明确的错误代码,而不是挂起
case Book.Recalculate of
lxOk:
SaveReport(Book);
lxErrorRef:
// a reference cycle exists; cycle members kept their previous
// cached values and everything outside the cycle is up to date
LogWarning('Circular reference detected - review model inputs');
end;
寻找哪些单元格构成了循环是一项调试工作,而公式评估追踪器是合适的工具:追踪可疑的公式,折叠回自身的引用链就会一步步显现出来;真实模型中的循环几乎全都是编写错误 —— 比如汇总行意外包含在它自己的 SUM 范围中 —— 因此重新计算时给出显眼的错误代码正是您想要的
数组公式、脏追踪以及依赖图重建的时机
数组公式为整个固定的矩形获得一个节点,而不是每个单元格一个节点;根公式每次扫描评估一次,生成的矩阵直接写入每个成员单元格,并且引用固定范围内任何单元格(不仅是左上角锚点)的公式都会从该根节点拾取一条依赖边;标量结果跨矩形广播,方式遵循 Excel 传统的数组语义规定
脏追踪挂钩了普通属性 Setter,因此您的代码无需做任何修改;在单元格上写入 Value 会通知工作簿并将依赖项标记为脏,分配新的 Formula 是结构性变更,因此它会将整个依赖图标记为陈旧,下一次 Recalculate 会在评估之前重建它;添加、删除或移动工作表也会使图失效,因为节点标识对工作表索引进行了编码;当没有处于活动状态的依赖图时 —— 比如您从未对其调用 Recalculate 的工作簿 —— 挂钩在每次分配时仅耗费单个 nil 检查,因此普通的读写负载不受影响
一个值得坦率说明的边界:依赖图追踪的是单元格之间的依赖关系,因此,当向用户自定义函数(通过 OnUserFunction 注册)的参数提供数据的单元格发生变化时,它就会像其他任何公式一样被重新评估;如果您正在以这种方式扩展引擎,在 HotXLS 公式引擎中自定义函数的文章逐步介绍了回调契约以及参数值是如何到达的
增量重新计算是适用于 Delphi 和 C++Builder 的 HotXLS Delphi Excel Component 中标准 XLSX 引擎的一部分,此外还有公式计算器、已定义名称和它所加速的导入/导出管道;如果您的 Delphi 或 C++Builder 应用程序维持着活模型(定价单、合并工作簿、报表级联),Recalculate 就是重新计算整个工作簿与重新计算编辑的区别