Artigo Técnico

Interseção implícita de nomes definidos no HotXLS Delphi

Um nome definido que aponta para uma coluna inteira é lido pelo Excel como uma única célula quando aparece numa posição escalar: =Vertical+1 na linha 7 significa a célula da linha 7 de Vertical, e não a área inteira. O HotXLS Delphi Component aplica essa interseção implícita na v2.382.4 em dois níveis, na avaliação e na extração de dependências, porque um modelo de empréstimo com 4805 fórmulas mostrou que acertar o valor não basta. Quando o walker de dependências expande o nome para a área completa, uma fórmula a jusante que alimenta qualquer célula dessa área fecha um ciclo que não existe, e o TXLSXWorkbook.Recalculate recusa a pasta de trabalho inteira

O modelo em questão é uma pasta de trabalho padrão de amortização de empréstimo. Com todo valor em cache envenenado para 777 e uma rodada completa de Recalculate, as duas arquiteturas do motor retornaram 23, que é lxErrorRef, o código de referência circular. 3842 das 4805 fórmulas não bateram com a expectativa independente, B18 segurava #VALUE!, E18 continuava 777, e a contagem de pagamentos em J7 tinha lido os placeholders numa coluna de saldo inacabada. Três defeitos distintos se escondiam atrás de um único código de retorno, e este artigo percorre cada um deles com o código-fonte que o corrigiu

Por que uma referência escalar a um nome de coluna cria um ciclo falso?

Porque um grafo de dependências só conhece arestas, e uma aresta de uma fórmula para uma área de 480 linhas são 480 arestas, uma das quais aponta de volta por uma célula que depende da fórmula. Considere =IF(TRUE,Vertical+1,0) em B1 com Vertical definido como Inputs!$A$1:$A$2, e =B1+1 em A2. O Excel avalia B1 como A1+1 e A2 como B1+1, uma cadeia direta. Um walker que registra B1 como dependente de A1:A2 faz de A2 um precedente de B1, A2 já lista B1 como precedente, e a fila de Kahn que conduz a recalculação incremental no HotXLS nunca vê nenhum dos dois nós chegar a grau de entrada zero. Esse é o padrão de que modelos de empréstimo são feitos: cada linha de período referencia colunas nomeadas para o saldo, a taxa e a contagem de pagamentos, cada nome abrange o cronograma inteiro, e cada linha também escreve nessas colunas. Expanda os nomes e o grafo vira um componente fortemente conectado gigante. Avalie-os com interseção implícita e o grafo vira um conjunto de cadeias curtas, uma por linha, que é o que a ECMA-376 Part 1 §18.17.2 descreve para um operando de referência consumido onde um valor único é exigido

Por que um nome de coluna fechou um ciclo falso no HotXLS: com Vertical definido como Inputs!$A$1:$A$2 o walker registra B1 como dependente de A1:A2, enquanto A2 já lista B1 como precedente, então a fila de Kahn nunca esvazia, ao passo que a interseção estreita B1 para a célula da linha, A1, e mantém a cadeia por linha A2, B1, A1 que o Recalculate ordena
Expandir o nome fez do grafo um componente fortemente conectado gigante, e avaliar as mesmas fórmulas com interseção implícita o transforma em cadeias curtas, uma por linha do cronograma
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Inputs');
    Book.DefinedNames.Add('Vertical', 'Inputs!$A$1:$A$2');
    Book.DefinedNames.Add('Alias', '=Vertical');
    Sheet.Cells[1, 1].Value := 1;
    // Posição escalar: Vertical colapsa para A1 porque a fórmula está na linha 1
    Sheet.Cells[1, 2].Formula := '=IF(TRUE,Vertical+1,0)';
    Sheet.Cells[2, 1].Formula := '=B1+1';
    // Um nome cuja definição é outro nome ainda intersecta, então isto é A2
    Sheet.Cells[2, 2].Formula := '=Alias';
    // Argumento de classe referência: a área inteira é somada, sem interseção
    Sheet.Cells[3, 2].Formula := '=SUM(Vertical)';
    // A linha 6 está fora de A1:A2, a interseção é vazia e o IFERROR a captura
    Sheet.Cells[6, 2].Formula := '=IFERROR(Vertical,42)';

    if Book.Recalculate = lxOk then
    begin
      // B1 = 2, A2 = 3, B2 = 3, B3 = 4, B6 = 42
      // Antes da v2.382.4 este ramo era inalcançável: B1 -> A2 -> B1 era um ciclo
    end;
  finally
    Book.Free;
  end;
end;

Como o HotXLS decide que um argumento é escalar?

O HotXLS lê a resposta da tabela de funções, e não do formato do argumento. Toda entrada em TXLSFormula.InitFuncHash é registrada por THashFunc.SetValue com uma string opcional de classe por argumento: 'IF' carrega '100', 'SUMIF' carrega '010', 'VLOOKUP' carrega '1011', e 'SUM' não carrega nenhuma, então todos os seus argumentos caem na classe 0, a de nível de função. O novo TXLSFormula.FunctionArgumentClass(APtg, AArgument) expõe esse byte por THashFuncEntry.ArgClass, e um resultado 1 significa classe de valor. São as mesmas três classes que a [MS-XLS] §2.2.2 atribui a tokens de operando, e o encoder já dependia delas: ao gravar uma referência ele calcula o ptg como $24 + $20 * aClass, o que dá PtgRef para a classe 0, PtgRefV para a classe 1 e PtgRefA para a classe 2. Um arquivo BIFF escrito pelo Excel guarda essa classe em todo token de referência, então um motor cuja tabela bate com a especificação consegue responder se um argumento é escalar sem olhar os dados. O argumento do meio de SUMIF é o critério, um valor; o primeiro e o terceiro são áreas, referências. SUMPRODUCT é registrada com classe 2, a de matriz, no nível de função, e é por isso que =SUMPRODUCT(Vertical,Vertical) continua multiplicando a área inteira

Três funções não consultam a própria entrada da tabela para nada além do primeiro argumento. IF (ptg 1), CHOOSE (ptg 100) e IFERROR (ptg 255) repassam o que quer que selecionem, então seus argumentos de ramo herdam a classe da posição que a função ocupa. Essa única regra é o que permite que =CHOOSE(1,Vertical,0) em G2 resolva para A2 enquanto =SUMIF(Vertical,">0",Vertical) logo ao lado continua somando as duas linhas, e é a regra que um cronograma de amortização mais exercita, porque suas células de período se apoiam no IF para testar se o empréstimo ainda está aberto

De onde o HotXLS lê as classes de argumento para a interseção implícita: IF registra 100, SUMIF 010, VLOOKUP 1011 e SUM nada, então seus argumentos caem na classe 0, o encoder grava tokens de referência como ptg $24 mais $20 vezes a classe, produzindo PtgRef, PtgRefV e PtgRefA, e as funções de repasse IF, CHOOSE e IFERROR herdam a classe da posição que ocupam
Como a tabela de classes bate com a especificação, o motor consegue responder se um argumento é escalar sem olhar os dados, e o CHOOSE resolvendo para A2 ao lado de um SUMIF que soma as duas linhas decorre de uma única regra

Levando a classe pela travessia de dependências

O extrator de dependências em lxCalc.pas é um Walk recursivo sobre a árvore de sintaxe compilada, e ele existe duas vezes, uma em TXLSCalculator.ExtractDependencies para o grafo por pasta de trabalho e outra em ExtractWorkspaceDependencies para o grafo entre pastas de trabalho. A v2.382.4 dá aos dois walkers dois parâmetros extras. AScalar começa como True na raiz de uma fórmula, é recalculado para cada filho de função a partir de FunctionArgumentClass, e passa inalterado para os argumentos de ramo de ptg 1, 100 e 255. ANameRoot só vira True quando o walker desce na definição compilada de um nome, e sobrevive apenas por nós SA_GROUP, os parênteses, de modo que um nome definido como =A1:A2+1 não é confundido com uma área simples. Quando as duas flags são True num nó SA_RANGE, AddResolvedRange estreita a área com o mesmo helper que o avaliador usa antes de registrar a dependência. O helper é curto o bastante para ser citado por inteiro

A decisão de IntersectNamedScalarRange que protege as dependências de nomes no HotXLS: um intervalo que já é uma célula passa direto, uma coluna única estreita para a linha da fórmula quando CurRow cai dentro dela, uma linha única estreita para a coluna da fórmula, e qualquer outra coisa, uma área bidimensional ou uma linha fora do intervalo, gera #VALUE! na avaliação e não registra dependência nenhuma
Os dois walkers de dependência e o avaliador chamam o mesmo helper, então o valor que uma fórmula lê e a aresta que o grafo registra nunca podem discordar sobre um nome intersectado
function IntersectNamedScalarRange(CurRow, CurCol: Integer;
  var Row1, Row2, Col1, Col2: Integer): Boolean;
begin
  Result := False;
  if (Row1 = Row2) and (Col1 = Col2) then Exit(True);   // já é uma célula
  if (Col1 = Col2) and (CurRow >= Row1) and (CurRow <= Row2) then
  begin
    Row1 := CurRow; Row2 := CurRow;                     // coluna única: pega esta linha
    Exit(True);
  end;
  if (Row1 = Row2) and (CurCol >= Col1) and (CurCol <= Col2) then
  begin
    Col1 := CurCol; Col2 := CurCol;                     // linha única: pega esta coluna
    Result := True;
  end;
end;

Tudo o que o helper rejeita — uma área bidimensional, uma referência entre várias planilhas ou uma fórmula cuja linha cai fora da coluna nomeada — produz #VALUE! no lado da avaliação e dependência nenhuma no lado do grafo, que é o que o Excel faz para uma interseção vazia. O lado da avaliação mora em TXLSCalculator.GetValueItemName: ele tira os invólucros SA_GROUP da definição compilada e, se a raiz for um SA_RANGE, chama GetRangeInfo, intersecta e busca a célula única por FGetValue em vez de avaliar a definição inteira. Referências externas seguem pelo caminho antigo, porque não existe linha local contra a qual intersectar. De onde vêm o armazenamento e o escopo de um nome está no artigo sobre nomes definidos e fórmulas entre planilhas; o ponto aqui é só o que o motor faz depois que o nome resolve

Por que o MATCH sobre uma coluna meio calculada leu 777?

Porque o argumento de matriz de busca do MATCH é uma referência de scan, e referências de scan foram deliberadamente excluídas da ordem de avaliação. O artigo sobre scan de lookup apresentou TXLSDepRange.LookupScan e fechou com uma seção intitulada o que você abre mão ao excluir arestas de scan da ordenação: uma fórmula de lookup pode rodar antes de toda célula do seu intervalo ter sido recalculada e ler valores velhos. Numa sessão interativa isso converge na passada seguinte. Num recálculo em lote de um modelo envenenado, não, e PaymentCount, definido como =MATCH(0.01,Balances,-1)+1, leu os placeholders 777 ainda parados na coluna de saldo e devolveu uma contagem de períodos que não podia estar certa

O TXLSDepGraph.TopoOrder agora trata arestas de scan como arestas suaves de ordenação. Ao lado do grau de entrada rígido ele mantém um array ScanInDeg, contando precedentes de scan sujos por nó e decrementando conforme esses precedentes são emitidos, usando as listas ScanPrecedents, ScanDependents e ScanPrecedentCount que a mudança anterior já armazenava. A cada iteração a fila de Kahn varre sua janela pronta em busca do primeiro nó cujo ScanInDeg é zero e o troca para a cabeça; se todo nó pronto ainda espera por um precedente de scan, a cabeça é retirada na ordem estável. Arestas de scan nunca entram no grau de entrada rígido, então um VLOOKUP autorreferente sobre a própria coluna continua legal, mas um lookup que poderia esperar por um precedente finalizável agora espera. A regressão que fixa isso, LookupScan_WaitsForDirtyFormulaValues, envenena três células de saldo para 777 e espera que PaymentCount volte como 3, depois zera a entrada e espera que =IFERROR(PaymentCount,99) veja o #N/A e retorne 99

De onde veio o truncamento de quatro casas decimais?

Da aritmética de Variant do Delphi, e só em posições aninhadas. Os operadores binários em TXLSCalculator.GetValueItem já copiavam um + ou - de primeiro nível para duas variáveis locais Double, então =B1-A1 estava certo. Dentro de =IF(TRUE,B1-A1,0) a mesma subtração rodava como Value := Value - SubValue sobre dois Variants e, quando um operando era um valor de célula Int64 e o outro um Double, o resultado que observamos era um Currency, um tipo de ponto fixo com quatro casas decimais, então 1066.1854641400994 menos 120 voltava truncado para quatro decimais. Num cronograma em que cada pagamento é composto a partir da linha anterior, esse erro atravessa centenas de períodos antes de chegar aos totais

// TXLSCalculator.GetValueItem, ramo de aritmética binária (lxCalc.pas)
if VarIsNull(Value) then Value := 0;
if VarIsNull(SubValue) then SubValue := 0;
// Aritmética Variant mista de Int64/Double pode promover para Currency.
// A aritmética de planilha precisa manter a precisão de ponto flutuante.
if VarIsNumeric(Value) then Value := Double(Value);
if VarIsNumeric(SubValue) then SubValue := Double(SubValue);

A guarda roda antes de SA_ADD, SA_SUB, SA_MUL e SA_DIV igualmente, e a regressão Arithmetic_MixedInt64AndDoubleKeepsPrecision guarda Int64(120) em A1 e 1066.1854641400994 em B1, depois confere a diferença e a soma aninhadas até 1E-10 e o produto e o quociente até 1E-8 e 1E-12. O HotXLS não afirma conhecer toda regra de promoção que a RTL aplica a tipos Variant mistos entre versões de compilador; ele afirma que aritmética de planilha é double IEEE, e agora torna os dois operandos double antes de o operador vê-los, o que elimina a dúvida

O que a correção garante, e o que não garante

Depois da v2.382.4 as duas arquiteturas do motor retornam lxOk para o modelo envenenado, todos os 4805 valores em cache batem com a expectativa independente linha a linha dentro de 1E-7, e valem as asserções de que os caches realmente foram envenenados, de que o hash da origem não mudou e de que toda fórmula continua presente. Nenhuma iteração foi habilitada e nenhum código de erro foi suprimido para chegar lá. Um ciclo de verdade por um nome — =B1 em A1 com B1 ainda lendo Vertical — continua retornando erro, e o teste NamedScalarRanges_IntersectWithoutFalseCycles termina afirmando exatamente isso

Vale dizer os limites sem rodeios. A interseção implícita se aplica só a um nome cuja definição compilada, depois de tirar os parênteses, é uma área de uma coluna ou de uma linha numa única planilha; um nome bidimensional numa posição escalar dá #VALUE!, como no Excel, e uma função que a tabela não conhece recebe classe 0 de FunctionArgumentClass, então seus argumentos de nome continuam sendo expandidos por inteiro. A ordenação suave é uma preferência, não uma garantia: um ciclo só de scan ainda é avaliado em ordem estável e lê o que estiver em cache, que é o comportamento que o artigo sobre scan de lookup aceitou de propósito. E o resultado do modelo inteiro é verificado contra um script de expectativa independente, não contra outro motor de planilha, porque o pacote de escritório usado como referência não terminou de recalcular o modelo original dentro de um orçamento de 60 segundos. O HotXLS é um componente de planilha nativo para Delphi e C++Builder que lê, recalcula e grava XLS, XLSX, ODS e CSV sem o Excel instalado; a interseção de nomes, a tabela de classes de argumento e a ordenação suave de scan valem para todos os formatos porque o motor de cálculo é compartilhado, e a cobertura atual de funções está listada na página do produto HotXLS Delphi Spreadsheet Component