技术文章

HotXLS 公式闭包:Delphi 中的 LAMBDA 与 LET

HotXLS 把 Excel 的 LAMBDA 当作一个真正的头等函数值来求值。一个 RefersTo 文本是 LAMBDA 的已定义名称,可以按名字调用,写作 =MyFunc(5);在 LET 内部绑定的闭包,可以写作 =LET(f, LAMBDA(x, x*2), f(21)) 来调用;定义时刻捕获的词法环境,会随着闭包一起被带走。公式文本会原样往返写回工作簿

这正是区分"公式引擎"和"公式解析器"的那项功能。在 LAMBDA 出现之前,一切都可以通过遍历一棵值的树来求值。LAMBDA 需要一个作用域栈,一旦有了作用域栈,一整类由用户编写的电子表格逻辑,就能在你的 Delphi 应用程序里运行起来,而不再只能在 Excel 里运行

为什么大多数非 Excel 引擎止步于 LAMBDA 关键字?

因为经典的电子表格求值器恰好只有一种价值类型:数字、字符串、布尔值、错误,或者指向持有这些内容的单元格的引用。这里根本没有地方能容纳一个函数。Excel 365 引入 LAMBDA 时,新增了一种携带参数名、函数体表达式,以及编写位置可见绑定的值类型。一个没有这种类型的引擎可以解析 LAMBDA(x, x*2) 并把文本存下来,但一旦某个单元格尝试调用它,就会发现根本没有东西可以调用

HotXLS 把这块缺失的拼图实现为一个闭包值加上一个运行时作用域栈。调用一个闭包会先压入它捕获的环境,再把参数值以形参名压入栈中,求值函数体,然后把栈截断回原来的标记位置。这个顺序很重要,下一节会解释原因

LAMBDA 被调用的三种方式

对一个未知函数名的调用,HotXLS 会依次尝试三条路径来解析,弄清楚是哪一条命中,能解释大多数意外情况。第一,在当前 LET 或 LAMBDA 作用域中绑定的名称:如果 f 是一个持有闭包的局部绑定,f(21) 就会应用这个闭包。第二,公式文本以 LAMBDA 开头的工作簿已定义名称:MyFunc(5) 会编译该名称的函数体并应用它。第三,经典的用户函数处理器,保持不变,接手前两条路径都认领不了的一切

一个持有的不是闭包的局部绑定是不可调用的。把 f 绑定为数字 3,再写 f(21),得到的是一个值错误,而不是一次乘法尝试。这比一门动态语言会有的行为更严格,而且是刻意如此:一个把函数调用意外变成引用的拼写错误,如果被静默接受,会产生一个错误答案,而这正是电子表格引擎能产出的最糟糕的结果

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Model');

    // 一个可复用的具名函数,工作簿作用域
    Book.DefinedNames.Add('NetOf', 'LAMBDA(amount, rate, amount*(1-rate))');

    Sheet.Cells[2, 2].Formula := 'NetOf(1250, 0.19)';

    // 在一个公式内部绑定并应用的闭包
    Sheet.Cells[3, 2].Formula := 'LET(double, LAMBDA(x, x*2), double(21))';

    // 嵌套 LET:每个绑定对它后面的绑定都可见
    Sheet.Cells[4, 2].Formula :=
      'LET(base, 100, bump, LAMBDA(v, v+base), LET(step, bump(5), step*2))';

    Book.Recalculate;
    Book.SaveAs('lambda-model.xlsx');
  finally
    Book.Free;
  end;
end;

名称冲突时,遮蔽规则如何处理?

参数优先。HotXLS 应用一个闭包时,会先压入捕获的词法环境,再压入参数绑定,因此一个名为 rate 的参数会遮蔽一个同名为 rate 的外层绑定,也会遮蔽外部公式中拼写相同的列引用。正是这种顺序,让一个具名函数可以放心复用:调用方不可能因为作用域里恰好有一个同名绑定,就意外改变函数体的含义

参数个数(arity)在任何求值发生之前就会被检查。一次调用如果实参数量和闭包的形参数量不匹配,会立刻返回一个值错误,而不是先求值部分实参再失败,这让无副作用的求值真正做到不留下任何半成品工作。作用域栈会在一个 finally 块中被截断回进入时的标记位置,因此函数体内部的一次错误,不会把过期的绑定遗留给下一个公式看到

var
  Book: TXLSXWorkbook;
  Name: TXLSXDefinedName;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('customer-model.xlsx') = 1 then
    begin
      // 在信任重新计算结果之前,先检查用户写了什么
      Name := Book.DefinedNames.FindByName('NetOf');
      if (Name <> nil) and
         (UpperCase(Copy(Name.Formula, 1, 6)) = 'LAMBDA') then
        Log('Named lambda found: ' + Name.Formula);

      Book.Recalculate;
      Log(VarToStr(Book.Sheets[1].Cells[2, 2].Value));
    end;
  finally
    Book.Free;
  end;
end;

LET 不再只是部分实现

早期的 HotXLS 版本只把 LET 实现到能应付常见的单绑定场景为止。当前的实现是完整的:每一个绑定对它之后的所有绑定和函数体表达式都可见,嵌套 LET 也能正常组合,因此 LET(a, 1, b, a+1, LET(c, b*2, c)) 的求值结果和 Excel 求值的结果一致

这种完整性比听起来更重要。LET 是用户用来避免在一个公式里把同一个子表达式重复计算五次的手段,因此真实的工作簿恰恰会用到那种深度嵌套的写法,而这正是部分实现最容易出错的地方。如果你之前是靠在求值前展开 LET 绑定来绕过这个缺口的,这个变通方案现在可以去掉了

逗号还是分号:现在两者都支持

HotXLS 中的公式文本现在除了经典的分号之外,也接受逗号作为参数分隔符。这不是一个区域设置,而是解析器里的一条接受规则。这一点之所以重要,是因为公式经常来自你无法控制的地方:从支持工单里粘贴过来的、从文档里复制出来的、由某个输出 Excel 标准语法的脚本生成的、从一份公式字符串 CSV 里导入的

实际效果是 SUM(A1,A2)SUM(A1;A2) 都能编译通过。往返读写会保留源文件用的是哪种分隔符,因此你加载的工作簿在写回时,用的还是原来的分隔符,而不会在用户不知情的情况下被悄悄统一格式

什么会被原样保留,又该检查什么

公式文本是原样存储的,因此一个已定义名称中的 LAMBDA,在完成一次加载再保存的循环后依然完好无损,在 Excel 里打开时还是同一个函数。一个直接作为单元格结果存储的裸 LAMBDA——也就是一个求值结果是闭包而不是值的公式——会保持既有的"跳过、不生成值"行为:文本被保留下来,不会为它凭空捏造一个缓存的数值结果。这才是诚实的做法,因为根本没有一个标量值可以缓存

有两个习惯值得养成。除非有特别的理由,否则应该把具名 lambda 设为工作簿作用域,因为一个表作用域的函数在工作表被复制时会消失,从而在离根因很远的地方产生一个名称错误;相关的作用域规则在已定义名称与跨表公式一文中有说明。另外,当一份满是具名 lambda 的工作簿要用于一份必须保持稳定的报表时,可以考虑用 ConvertFormulasToValues 把结果冻结下来,这样下游消费者看到的就是数字,而不是他们可能不支持的函数

在重度重新计算的场景下,LAMBDA 的函数体在依赖图里就是普通表达式,会像任何其他公式一样被调度,这在增量重新计算与依赖图一文中有说明。如果你的模型在成千上万行里调用同一个具名函数,开销来自函数体本身,而不是调用机制,对任何重复公式适用的优化建议,在这里同样适用

HotXLS 是一个原生的 Delphi 和 C++Builder 电子表格组件,无需 Excel 或任何 Office 自动化即可读写 XLS、XLSX 和 ODS。公式引擎、已定义名称和重新计算 API 都记录在 HotXLS Delphi 电子表格组件页面