技術文章

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 的綁定,也會遮蔽周圍公式中拼字相同的欄位參照。這個順序正是讓具名函式能安全重複使用的關鍵:呼叫端不可能因為作用域內剛好有個同名綁定,就意外改變主體的含義

引數個數會在任何求值開始之前就先被檢查。引數數量與閉包參數數量不符的呼叫,會立刻回傳一個數值錯誤,而不是先求值部分引數再失敗,這讓「無副作用求值」能真正做到完全無副作用,不留半途而廢的痕跡。作用域堆疊會在一個 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 試算表元件頁面