技术文章

Delphi 中循环引用的迭代计算

为了在 Delphi 中计算有意的循环引用,HotXLS 在其 XLSX 引擎上提供了迭代计算:将 TXLSXWorkbook.Iterate 设置为 True,TXLSXWorkbook.Recalculate 就会让每个检测到的引用循环走向一个固定点——最多进行 IterateCount 次,或者直到每个单元格的变化小于 IterateDelta,而不是返回 #REF! 并放弃

这种区别比单个布尔值所暗示的更重要。检测到引用循环并拒绝在其上循环的同一个引擎,一旦翻转一个属性,就会刻意评估该循环直到它稳定下来。理清这两种行为——什么时候循环是需要报告的缺陷,什么时候是需要解决的模型——正是本文的全部主题

为什么循环引用默认会出错?

默认情况下,HotXLS 将任何引用循环视为编写错误,并对其进行报告而不是进行计算。TXLSXWorkbook.Recalculate 构建公式依赖图,按拓扑顺序评估每个公式单元格,并在发现循环时立即返回 lxErrorRef(即拓扑排序期间永远无法释放的节点)。这些循环成员保留它们以前缓存的值;循环之外的每个公式仍然正常评估。该图的机制,以及为什么循环成员被跳过而不是循环,在关于 渐进式公式重新计算与依赖图 的配套文章中有所涵盖

默认设置是安全的,因为大多数循环都是 Bug:例如汇总行不小心拉入了其自身的 SUM 范围,或者复制粘贴将引用转移到了自身。对于这些情况,在重新计算时出现明显的错误代码正是您所希望的。但是,一类特定且重要的模型是有意循环的。利滚利计划、部门之间循环的成本或开销分配,以及余额驱动的费用计算,都描述了一个合法反馈到其自身输入中的值——而 Excel 仅在用户勾选“文件 → 选项 → 公式 → 启用迭代计算”后才计算它们

如何在 HotXLS 中启用迭代计算?

HotXLS 通过 TXLSXWorkbook 上的三个属性来镜像该 Excel 复选框:Iterate 开启该模式,当它为 True 时,检测到的循环将被交给迭代求解器,而不是产生 lxErrorRef。考虑经典的利滚利单元格对,其中期末余额取决于利息,而利息取决于余额

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Model');
    Sheet.Cells[1, 2].Value   := 1000;     // B1: 期初本金
    Sheet.Cells[2, 2].Value   := 0.05;     // B2: 期间利率
    Sheet.Cells[3, 2].Formula := 'B1+B4';  // B3: 余额 = 本金 + 利息
    Sheet.Cells[4, 2].Formula := 'B3*B2';  // B4: 利息 = 余额 * 利率

    Book.Iterate := True;                   // 选择加入迭代计算
    if Book.Recalculate = lxOk then
      // B3 收敛到 1052.63...,B4 到 52.63...
      Report(Sheet.Cells[3, 2].Value);
  finally
    Book.Free;
  end;
end;

B3 引用 B4 且 B4 引用 B3,因此依赖图报告了一个双节点循环。如果 Iterate 保持默认值 False,该对将返回 lxErrorRef 且两个单元格都不会稳定。当它为 True 时,Recalculate 会从当前缓存值中播种循环,并一轮又一轮地重新评估其成员,将每轮的输出作为下一轮的输入,直到数字停止变动。在这里,闭式解是 principal / (1 - rate),因此余额稳定在 1052.63,利息稳定在 52.63——这与 Excel 在启用迭代时产生的数据完全相同

是什么让迭代停止?

两个独立的停止条件约束着求解器,理解这两者是防止模型出错或无限旋转的关键。IterateCount 是重新评估循环成员次数 the 硬上限(默认值为 100,与 Excel 一致)。IterateDelta 是收敛阈值:在每轮之后,求解器测量所有循环单元格中最大的数值变化,一旦该最大变化降至 IterateDelta 以下(默认值为 0.001),则提前中断轮次循环。无论哪个条件先满足都会结束迭代

Book.Iterate := True;
Book.IterateCount := 1000;    // 硬上限:对循环最多进行 1000 次评估
Book.IterateDelta := 0.0001;  // 收敛:一旦每个单元格的变化小于 0.0001 则停止

case Book.Recalculate of
  lxOk:
    // 循环已收敛,或者它达到了 1000 次的上限并保留了其
    // 最后一次迭代的值 -- 当 Iterate 为 True 时,两条路径都返回 lxOk
    SaveWorkbook(Book);
  lxErrorRef:
    // 仅在 Iterate = False 时可达:报告了循环,没有解决
    LogWarning('Circular reference with iteration disabled');
end;

有一个后果值得明确说明,因为它是该功能客观存在的边界。当达到上限而变化仍未降到 IterateDelta 以下时,SolveCycleIteratively 不会引发错误——它返回 lxOk 并让单元格保留其最后一次迭代的值,就像 Excel 在其自身的迭代限制达到而未收敛时,写入最后计算的数字完全一样。因此,在迭代模式下从 Recalculate 返回成功的代码意味着“求解器运行了”,而不是“求解器收敛了”。其反馈循环发散或振荡的模型将悄悄消耗掉所有的 IterateCount 轮次,并传回根本不是固定点的数字,并且没有任何异常来标记这一区别

该设置如何保存到 XLSX 和 XLS 文件中?

迭代计算设置在两种电子表格格式中都会持久化,因此在 Excel 中打开的工作簿表现得与您的 Delphi 代码配置的一致。在 XLSX 侧,只有当 Iterate 为 True时,写入器才会发射 OOXML <calcPr> 元素,并且它会省略任何仍处于默认值的属性以保持输出最小化:使用默认值的工作簿仅写入 <calcPr iterate="1"/>,而 iterateCount 只有当它不同于 100 时才出现,iterateDelta 只有当它不同于 0.001 时才出现。在打开时,TXLSXWorkbook 读回相同的三个属性,因此往返是对称的

传统的 BIFF8 (.xls) 引擎 TXLSWorkbook 则通过不同属性三元组下的三个独立记录来携带等效状态。EnableIteration 映射到 CalcIter 记录 ($0011, [MS-XLS] §2.4.33),MaxIterations 映射到 CalcCount 记录 ($000C, [MS-XLS] §2.4.31),而 MaxIterationChange 则映射到 CalcDelta 记录 ($0010, [MS-XLS] §2.4.32)。Setter 强制执行规范范围——CalcCount 必须落在 1..32767 之间,因此 MaxIterations 被夹紧,并且负的 MaxIterationChange 会弹回 0.001 的默认值。在已加载的 .xls 工作簿上设置它们,这三个计算记录将在保存时忠实地写入

var
  Book: TXLSWorkbook;   // BIFF8 (.xls) 引擎
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('model.xls');
    Book.EnableIteration    := True;    // CalcIter 记录 $0011
    Book.MaxIterations      := 500;     // CalcCount 记录 $000C(夹紧在 1..32767)
    Book.MaxIterationChange := 0.0001;  // CalcDelta 记录 $0010
    Book.SaveAs('model.xls');           // 这三个计算记录在往返中保留
  finally
    Book.Free;
  end;
end;

请注意刻意命名的划分:XLSX 引擎使用 Iterate / IterateCount / IterateDelta(OOXML 词汇),而 BIFF8 引擎使用 EnableIteration / MaxIterations / MaxIterationChange(与 [MS-XLS] 记录名称一致)。两个三元组描述相同的三个旋钮——开/关、迭代上限和收敛 delta,并带有相同的默认值:关闭、100 和 0.001

循环引用何时是 Bug 而不是模型?

启用迭代特意不是消除循环引用警告的方法,那样对待它是一个陷阱。全局打开 Iterate 会将每个意外的循环(默认错误代码本应捕获的那些)转换为默默收敛或默默未收敛的数字。规范配置恰恰相反:保持 Iterate 为 False 作为常态,以便真正的编写错误仍作为 lxErrorRef 暴露出来,而仅在循环性是设计好的且已被理解的工作簿上启用迭代

当确实出现循环且您不确定是哪一种时,公式评估追踪器 是区分它们的工具:追踪可疑的公式,折返回自身的引用链将一步步变得可见,因此您可以决定它编码的是真正的反馈循环还是无意的自我引用。记住循环单元格在环绕循环的途中可以调用任何内置函数也有助于这一点——解决 工程或复数公式 的同一个计算器会在每一轮中对循环成员进行评估——因此发散的模型通常是循环内部的公式问题,而不是迭代设置问题

实用的核对清单非常简短。在依赖迭代前确认循环具有真正的固定点;保持 IterateDelta 足够严密,使“收敛”意味着您的模型所需要的含义;在您期望收敛的 Recalculate 之后,对已知的输出进行合理性检查,而不是仅仅信任 lxOk,因为该代码无法将收敛与达到迭代上限区分开来

循环引用的迭代计算是 HotXLS Delphi Excel Component 中 XLSX 引擎的一部分,此外还有它所构建的增量依赖图重新计算,以及将该设置带入您写入的每个文件中的 OOXML 和 BIFF8 持久性。对于有意循环的财务和工程模型,这是错误代码与答案之间的区别