Artigo Técnico

Ler valores de fórmula em cache em Delphi sem recalcular

O HotXLS, a biblioteca Excel nativa para Delphi e C++Builder, lê o valor que o Excel já guardou ao lado de uma fórmula através de TryGetCachedFormulaValue e IXLSFormulaCacheReader. Nenhum dos pontos de entrada chama a calculadora, descompila tokens de fórmula, atualiza o estado dirty ou escreve qualquer coisa de volta no modelo, pelo que um livro que só lê fica exatamente como o abriu

O cenário que motiva isto é aborrecido e extremamente comum. Um trabalho noturno abre algumas centenas de livros produzidos por outra pessoa, retira uma coluna de totais de cada um e empurra os números para um armazém de dados. Os totais já estão nos ficheiros — o Excel calculou-os e guardou-os. Ainda assim, no momento em que o trabalho pede o valor de uma célula de fórmula, uma biblioteca que só tem uma resposta para essa questão constrói um grafo de dependências e avalia a folha inteira, e um trabalho que devia ser limitado por E/S transforma-se numa referência de desempenho de cálculo

Porque é que ler uma célula de fórmula custa um recálculo completo?

Porque um getter de valor numa célula de fórmula é um pedido para produzir um valor, e a única forma universalmente correta de produzir um é avaliar a fórmula. Esse é o predefinido certo para uma aplicação que edita livros, e o predefinido errado para um pipeline que os extrai. Pior, a avaliação não está isenta de efeitos secundários: escreve resultados de volta nas células, altera flags dirty, e pode resolver de forma diferente da aplicação produtora quando uma função não é suportada ou uma referência externa está partida. Um trabalho que descreveu à sua equipa de operações como só de leitura produz silenciosamente um livro que já não corresponde ao que está em disco, e se algo o guardar mais tarde, o ficheiro em disco também muda

A leitura de valores em cache é a outra metade do contrato. Responde a uma questão mais estreita — o que é que a aplicação produtora guardou aqui? — e recusa-se a responder a qualquer outra coisa. Quando quer genuinamente números frescos, o HotXLS continua a dar-lhe o recálculo incremental conduzido por um grafo de dependências; o ponto é que extração e avaliação deviam ser duas chamadas diferentes, não uma chamada com dois humores

Três factos ortogonais sobre uma célula

Primeiro a conclusão: um valor de fórmula em cache transporta três factos independentes, e colapsá-los num único Variant perde informação de que precisa. O TXLSFormulaCacheInfo mantém-nos separados como State, Kind e Value. O TXLSFormulaCacheState regista a proveniência em cinco casos — xlfcsNotFormula, xlfcsMissing, xlfcsLoaded, xlfcsCalculated e xlfcsInvalidated — enquanto o TXLSFormulaCacheValueKind classifica a carga útil como xlfcvBlank, xlfcvNumber, xlfcvDateTime, xlfcvString, xlfcvBoolean ou xlfcvError. Esta separação é o que permite reportar a presença honestamente: um vazio em cache, uma cadeia vazia em cache, um False em cache, um zero em cache e um erro em cache são todos valores reais, pelo que a presença nunca pode ser inferida de VarIsEmpty ou VarIsNull. O TryGetCachedFormulaValue devolve True apenas para xlfcsLoaded e xlfcsCalculated, e mesmo assim preenche um estado diagnosticável quando devolve False

O registo HotXLS TXLSFormulaCacheInfo mantém separados três factos ortogonais sobre uma célula de fórmula: a proveniência State em cinco casos, a Kind da carga útil em seis, e o Value Variant, pelo que um vazio ou False em cache nunca é confundido com uma cache ausente
Proveniência, tipo da carga útil e valor da carga útil permanecem separados, o que é a única forma de um vazio, zero, cadeia vazia ou erro em cache ser reportado como o valor real que é
var
  Book: TXLSXWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-model.xlsx');
    // SheetIndex, Row e Col são todos base um aqui
    if Book.TryGetCachedFormulaValue(1, 12, 5, Info) then
      Writeln('cached value: ', VarToStr(Info.Value))
    else
      Writeln('no usable cache, state ordinal ', Ord(Info.State));
  finally
    Book.Free;
  end;
end;

Porque é que o valor em cache está em falta?

Há exatamente quatro razões para o TryGetCachedFormulaValue devolver False, e o estado diz-lhe qual se aplica. xlfcsNotFormula significa que a célula contém um literal ou nada de todo, e coordenadas fora do intervalo colapsam na mesma resposta. xlfcsMissing significa que a célula é realmente uma fórmula mas o produtor não guardou nenhuma carga útil de valor para ela — um desfecho comum quando um gerador escreve fórmulas e deixa o Excel preencher os resultados na primeira abertura. xlfcsInvalidated significa que o texto da fórmula foi substituído depois do carregamento, pelo que o valor que lá estava descreve uma expressão que já não existe. xlfcsCalculated, em contrapartida, é um caso de sucesso: marca um valor que o seu próprio código ou o avaliador do HotXLS produziu durante esta sessão, por oposição a xlfcsLoaded, que veio do ficheiro

A honestidade sobre uma cache em falta importa mais do que a tapar. O HotXLS recusa-se a inventar um valor, e ao guardar é igualmente rigoroso — só xlfcsLoaded e xlfcsCalculated emitem um valor em cache, enquanto xlfcsMissing e xlfcsInvalidated escrevem apenas a fórmula em vez de congelar um número obsoleto no ficheiro. Isso deixa-lhe três respostas sensatas num pipeline: saltar a linha e registar a lacuna, recalcular deliberadamente esse livro e aceitar o custo, ou avaliar e reconciliar. Se o número avaliado discordar do que a aplicação produtora teria escrito, o traçador de avaliação de fórmulas é a ferramenta para descobrir onde as duas computações divergem, em vez de adivinhar a partir do resultado

Um leitor para os motores clássico, OOXML e ODF

Um pipeline não devia importar-se se o ficheiro que acabou de abrir era BIFF, OOXML ou ODF. O IXLSFormulaCacheReader é o único ponto de entrada só de leitura para os três: tanto o TXLSWorkbook.CreateFormulaCacheReader como o TXLSXWorkbook.CreateFormulaCacheReader devolvem um adaptador leve sobre a pesquisa esparsa de células que cada motor já usa, com coordenadas idênticas de folha, linha e coluna base um. As classes de livro deliberadamente não implementam a interface elas próprias — uma referência de interface ao livro mudaria a sua semântica de posse e deixaria os chamadores escapar ao lease de tempo de vida. Em vez disso, destruir o livro limpa o ponteiro em bruto dentro desse lease, e qualquer leitor ainda mantido pelo seu código lança EXLSFormulaCacheReaderInvalidated na sua próxima consulta em vez de desreferenciar memória libertada. É verificação de tempo de vida fail-fast, não uma garantia de concorrência

var
  Reader: IXLSFormulaCacheReader;
  Info: TXLSFormulaCacheInfo;
  Row, Missing, Errors: Integer;
  Total: Double;
begin
  Reader := Book.CreateFormulaCacheReader;
  Total := 0;
  Missing := 0;
  Errors := 0;
  for Row := 2 to LastRow do
    if Reader.TryGetCachedFormulaValue(1, Row, 7, Info) then
    begin
      case Info.Kind of
        xlfcvNumber: Total := Total + Double(Info.Value);
        xlfcvError:  Inc(Errors);
      end;
    end
    else if Info.State = xlfcsMissing then
      Inc(Missing);
  // Nenhuma calculadora correu, nenhuma flag dirty mudou, o Book fica inalterado
end;

Onde vivem realmente os bytes em cache

Para ficheiros .xls clássicos a cache é o campo FormulaValue do registo Formula, oito bytes descritos pelo [MS-XLS] §2.5.133. Quando a palavra alta é igual a $FFFF a carga útil não é um double IEEE 754 mas um variant etiquetado, e a disposição é fácil de errar subtilmente: o tipo do variant está em val[0] e a carga útil booleana ou BErr está em val[2], com val[1] indefinido. O HotXLS lia anteriormente a carga útil de val[1], que é o tipo de erro de um que só aparece nos ficheiros específicos que colocam em cache um booleano ou um erro em vez de um número. O leitor e o escritor de fórmulas partilhadas agora concordam nos mesmos desvios, pelo que um TRUE em cache sobrevive intacto a um carregamento e gravação em vez de decair em ruído

O campo FormulaValue de oito bytes de um registo Formula XLS clássico tal como o HotXLS o lê: um double IEEE 754 salvo se a palavra alta for igual a FFFF, caso em que o tipo do variant está em val zero e a carga útil booleana ou de erro em val dois
Quando a palavra alta é FFFF o campo é um variant etiquetado, e a carga útil está em val[2] com val[1] indefinido, que é exatamente o byte que o leitor costumava apanhar

A fidelidade de tipos nos formatos de pacote é um problema separado com a sua própria armadilha. No OOXML o valor em cache pende do elemento c como <v>, com o atributo t a nomear o tipo conforme a ECMA-376 Parte 1 §18.3.1.4. O HotXLS lê t="e" diretamente num Variant varError e mapeia-o de volta para o texto de erro padrão ao guardar, pelo que os erros nunca se disfarçam de inteiros ordinários — mas a RTL do Delphi não o ajuda aqui, porque VarAsType(Integer, varError) lança uma exceção de conversão. A construção que funciona define TVarData.VType e TVarData.VError diretamente. As datas seguem a mesma disciplina na direção oposta: t="d" e o tipo de valor de data ODF são declarações de tipo explícitas e tornam-se varDate, enquanto uma cache numérica BIFF não transporta flag de data nenhuma e por isso permanece um Double. O HotXLS nunca adivinha uma data a partir do formato numérico de uma célula, porque o formato numérico é apresentação e a cache é dados. O ODF acrescenta mais um caso que vale a pena conhecer — office:value-type="void" expressa uma cache que está presente mas não transporta valor, e como o ODF não tem tipo de valor de erro, texto com aparência de erro é preservado como texto em vez de ser promovido a erro

function DescribeCache(const Info: TXLSFormulaCacheInfo): string;
begin
  case Info.State of
    xlfcsNotFormula:  Result := 'not a formula cell';
    xlfcsMissing:     Result := 'formula stored with no cached value';
    xlfcsInvalidated: Result := 'formula replaced since load';
  else
    case Info.Kind of
      xlfcvError:    Result := 'error code ' + IntToStr(TVarData(Info.Value).VError);
      xlfcvDateTime: Result := DateTimeToStr(VarToDateTime(Info.Value));
      xlfcvBoolean:  Result := BoolToStr(Info.Value, True);
      xlfcvNumber:   Result := FloatToStr(Double(Info.Value));
      xlfcvString:   Result := VarToStr(Info.Value);
    else
      Result := 'present but blank';
    end;
  end;
end;

As fórmulas partilhadas partilham os seus valores em cache?

Não, e assumir o contrário é como uma varredura acaba por reportar o mesmo número para uma coluna inteira. Uma fórmula partilhada OOXML partilha apenas a expressão da fórmula e a otimização de armazenamento; cada célula membro continua a ter o seu próprio <v>. O HotXLS por isso nunca propaga a cache do membro raiz a um seguidor que chegou sem valor, e um seguidor que carregou como xlfcsMissing continua a reportar xlfcsMissing depois de guardar e reabrir. Se está a trabalhar como o grupo é armazenado e expandido em primeiro lugar, a mecânica do atributo si de fórmula partilhada e a sua expansão está coberta separadamente; para leitura de cache, a regra reduz-se a uma linha — pergunte a cada célula, não confie em nada que não tenha pedido

Uma vista HotXLS de um grupo de fórmula partilhada OOXML em que o atributo si partilha apenas a expressão e a disposição de armazenamento, enquanto cada célula membro tem o seu próprio valor em cache, pelo que um seguidor que carregou sem um continua a reportar xlfcsMissing
O grupo partilha a expressão, não os números, pelo que a cache da raiz nunca é propagada e um membro que chegou sem valor continua a reportar essa lacuna

A leitura de valores em cache, o leitor unificado entre motores e o motor de recálculo que pode escolher não invocar chegam todos no HotXLS Delphi Spreadsheet Component padrão para Delphi e C++Builder, sem dependência do Excel nem de qualquer servidor de automação OLE; a página do produto transporta a referência API completa para os pontos de entrada de livro e leitor aqui mostrados