Техническая статья

HotXLS: замыкания LAMBDA и LET в формулах Delphi

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, сохранённая как результат ячейки — то есть формула, вычисляющаяся в замыкание, а не в значение, — сохраняет прежнее поведение «пропуск без значения»: текст сохраняется, а закэшированный числовой результат для неё не выдумывается. Это честный исход, поскольку кэшировать здесь просто нечего — нет скаляра

Стоит завести две привычки. Давайте именованным лямбдам область видимости книги, если нет причины поступить иначе, потому что функция с областью видимости листа, исчезающая при копировании листа, порождает ошибку имени в месте, далёком от причины; правила области видимости рассмотрены в статье об определённых именах и межлистовых формулах. А когда книга, полная именованных лямбд, предназначена для отчёта, который должен быть стабильным, рассмотрите заморозку результатов через ConvertFormulasToValues, чтобы потребители ниже по цепочке видели числа, а не функции, которые они, возможно, не поддерживают

При тяжёлом пересчёте тела LAMBDA — это обычные выражения в графе зависимостей, планируемые так же, как любая другая формула, что описано в статье об инкрементальном пересчёте и графе зависимостей. Если ваша модель вызывает одну именованную функцию на тысячах строк, стоимость заключена в теле, а не в механизме вызова, и применяются те же рекомендации по оптимизации, что и для любой повторяющейся формулы

HotXLS — это нативный компонент электронных таблиц для Delphi и C++Builder, читающий и записывающий XLS, XLSX и ODS без Excel и без какой-либо автоматизации Office. Движок формул, определённые имена и API пересчёта задокументированы на странице HotXLS Delphi spreadsheet component