Articolo tecnico

Valori cache delle formule Excel in Delphi senza ricalcolo

HotXLS, la libreria Excel nativa per Delphi e C++Builder, legge il valore che Excel ha già memorizzato accanto a una formula tramite TryGetCachedFormulaValue e IXLSFormulaCacheReader. Nessuno dei due punti di ingresso chiama il calcolatore, decompila token di formula, aggiorna lo stato dirty o scrive qualcosa nel modello, quindi una cartella di lavoro che fate solo leggere resta esattamente come l'avete aperta

Lo scenario che motiva tutto questo è banale e estremamente comune. Un job notturno apre qualche centinaio di cartelle di lavoro prodotte da altri, estrae una colonna di totali da ciascuna e spinge i numeri in un warehouse. I totali sono già nei file — Excel li ha calcolati e salvati. Eppure nel momento in cui il job chiede il valore a una cella formula, una libreria che ha una sola risposta per quella domanda costruisce un grafo di dipendenze e valuta l'intero foglio, e un job che dovrebbe essere limitato dall'I/O si trasforma in un benchmark di calcolo

Perché leggere una cella formula costa un ricalcolo completo?

Perché un getter di valore su una cella formula è una richiesta di produrre un valore, e l'unico modo universalmente corretto di produrlo è valutare la formula. È il default giusto per un'applicazione che modifica cartelle di lavoro, e il default sbagliato per una pipeline che le estrae. Peggio, la valutazione non è esente da effetti collaterali: riscrive i risultati nelle celle, commuta i flag dirty e può risolvere diversamente dall'applicazione produttrice quando una funzione non è supportata o un riferimento esterno è rotto. Un job che avete descritto al vostro team operativo come read-only produce silenziosamente una cartella di lavoro che non corrisponde più a quella su disco, e se qualcosa la salva in seguito, cambia anche il file su disco

La lettura dei valori cache è l'altra metà del contratto. Risponde a una domanda più stretta — cosa ha memorizzato qui l'applicazione produttrice? — e si rifiuta di rispondere a qualsiasi altra cosa. Quando volete davvero numeri freschi, HotXLS vi dà comunque il ricalcolo incrementale guidato da un grafo di dipendenze; il punto è che estrazione e valutazione dovrebbero essere due chiamate diverse, non una sola chiamata con due umori

Tre fatti ortogonali su una cella

Prima la conclusione: un valore cache di formula porta tre fatti indipendenti, e collassarli in un unico Variant perde informazioni di cui avete bisogno. TXLSFormulaCacheInfo li tiene separati come State, Kind e Value. TXLSFormulaCacheState registra la provenienza su cinque casi — xlfcsNotFormula, xlfcsMissing, xlfcsLoaded, xlfcsCalculated e xlfcsInvalidated — mentre TXLSFormulaCacheValueKind classifica il payload come xlfcvBlank, xlfcvNumber, xlfcvDateTime, xlfcvString, xlfcvBoolean o xlfcvError. Questa separazione è ciò che permette di riportare la presenza onestamente: un blank in cache, una stringa vuota in cache, un False in cache, uno zero in cache e un errore in cache sono tutti valori reali, quindi la presenza non può mai essere inferita da VarIsEmpty o VarIsNull. TryGetCachedFormulaValue restituisce True solo per xlfcsLoaded e xlfcsCalculated, e riempie comunque uno stato diagnostabile quando restituisce False

Il record HotXLS TXLSFormulaCacheInfo tiene separati tre fatti ortogonali su una cella formula: la provenienza State su cinque casi, il Kind del payload su sei e il Value Variant, così un blank o False in cache non viene mai scambiato per una cache assente
Provenienza, tipo del payload e valore del payload restano separati, che è l'unico modo in cui un blank, uno zero, una stringa vuota o un errore in cache può essere riportato come il valore reale che è
var
  Book: TXLSXWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-model.xlsx');
    // SheetIndex, Row e Col sono tutti a base uno qui
    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;

Perché il valore cache manca?

Ci sono esattamente quattro motivi per cui TryGetCachedFormulaValue restituisce False, e lo stato dice quale si applica. xlfcsNotFormula significa che la cella contiene un letterale o niente affatto, e coordinate fuori intervallo collassano nella stessa risposta. xlfcsMissing significa che la cella è davvero una formula ma il produttore non ha memorizzato alcun payload di valore per essa — esito comune quando un generatore scrive formule e lascia che Excel riempia i risultati alla prima apertura. xlfcsInvalidated significa che il testo della formula è stato sostituito dopo il caricamento, quindi il valore che c'era descrive un'espressione che non esiste più. xlfcsCalculated, al contrario, è un caso di successo: contrassegna un valore prodotto dal vostro codice o dall'evaluator di HotXLS durante questa sessione, in opposizione a xlfcsLoaded, che proveniva dal file

L'onestà su una cache mancante conta più del mascherarla. HotXLS si rifiuta di inventare un valore, e al salvataggio è altrettanto rigoroso — solo xlfcsLoaded e xlfcsCalculated emettono un valore cache, mentre xlfcsMissing e xlfcsInvalidated scrivono la formula da sola invece di congelare un numero stantio nel file. Questo lascia tre risposte sane in una pipeline: saltare la riga e registrare il gap, ricalcolare deliberatamente quella cartella di lavoro accettandone il costo, oppure valutare e riconciliare. Se il numero valutato non concorda con ciò che l'applicazione produttrice avrebbe scritto, il tracer di valutazione delle formule è lo strumento per scoprire dove le due computazioni divergono, invece di indovinare dal risultato

Un solo reader per i motori classic, OOXML e ODF

Una pipeline non dovrebbe preoccuparsi se il file appena aperto era BIFF, OOXML o ODF. IXLSFormulaCacheReader è l'unico punto di ingresso read-only per tutti e tre: sia TXLSWorkbook.CreateFormulaCacheReader sia TXLSXWorkbook.CreateFormulaCacheReader restituiscono un adattatore leggero sopra la lookup sparsa di celle che ciascun motore già usa, con coordinate identiche a base uno per foglio, riga e colonna. Le classi workbook deliberatamente non implementano esse stesse l'interfaccia — un riferimento a interfaccia verso il workbook cambierebbe le sue semantiche di ownership e lascerebbe i chiamanti scivolare oltre il lease di lifetime. Invece, distruggere il workbook azzera il puntatore grezzo dentro quel lease, e qualsiasi reader ancora trattenuto dal vostro codice solleva EXLSFormulaCacheReaderInvalidated alla query successiva invece di dereferenziare memoria liberata. È verifica fail-fast del lifetime, non una garanzia di concorrenza

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);
  // Nessun calcolatore eseguito, nessun flag dirty mosso, Book invariato
end;

Dove vivono davvero i byte in cache

Per i file classic .xls la cache è il campo FormulaValue del record Formula, otto byte descritti da [MS-XLS] §2.5.133. Quando la word alta vale $FFFF il payload non è un double IEEE 754 ma una variant con tag, e il layout è facile da sbagliare in modo sottile: il tipo della variant siede in val[0] e il payload booleano o BErr siede in val[2], con val[1] non definito. HotXLS in precedenza leggeva il payload da val[1], che è il tipo di off-by-one che emerge solo sui file specifici che mettono in cache un booleano o un errore anziché un numero. Il reader e il writer delle formule condivise ora concordano sugli stessi offset, così un TRUE in cache sopravvive intatto a un load e save invece di degradare in rumore

Il campo FormulaValue di otto byte di un record Formula XLS classic così come HotXLS lo legge: un double IEEE 754 a meno che la word alta valga FFFF, nel qual caso il tipo della variant siede in val zero e il payload Boolean o errore in val due
Quando la word alta è FFFF il campo è una variant con tag, e il payload siede in val[2] con val[1] non definito, che è esattamente il byte che il reader prendeva un tempo

La fedeltà dei tipi nei formati package è un problema separato con la sua trappola. In OOXML il valore cache pende dall'elemento c come <v>, con l'attributo t che nomina il tipo secondo ECMA-376 Part 1 §18.3.1.4. HotXLS legge t="e" direttamente in un Variant varError e lo rimappa al testo di errore standard al salvataggio, così gli errori non si mascherano mai da interi ordinari — ma la RTL Delphi non vi aiuta qui, perché VarAsType(Integer, varError) solleva un'eccezione di conversione. La costruzione funzionante imposta direttamente TVarData.VType e TVarData.VError. Le date seguono la stessa disciplina nella direzione opposta: t="d" e il tipo valore data di ODF sono dichiarazioni di tipo esplicite e diventano varDate, mentre una cache numerica BIFF non porta alcun flag data e quindi resta un Double. HotXLS non indovina mai una data dal formato numerico di una cella, perché il formato numerico è presentazione e la cache è dati. ODF aggiunge un caso in più che vale la pena conoscere — office:value-type="void" esprime una cache presente ma priva di valore, e poiché ODF non ha un tipo valore errore, il testo dall'aspetto di errore viene preservato come testo anziché promosso a errore

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;

Le formule condivise condividono i propri valori cache?

No, e presupporre il contrario è il modo in cui una scansione finisce per riportare lo stesso numero per un'intera colonna. Una formula condivisa OOXML condivide solo l'espressione della formula e l'ottimizzazione di memorizzazione; ogni cella membro possiede ancora il proprio <v>. HotXLS quindi non propaga mai la cache del membro radice a un follower arrivato senza valore, e un follower caricato come xlfcsMissing continua a riportare xlfcsMissing dopo un salvataggio e riapertura. Se state studiando come il gruppo viene memorizzato ed espanso in partenza, la meccanica dell'attributo si della formula condivisa e la sua espansione è trattata separatamente; per la lettura della cache, la regola si riduce a una riga — chiedete a ogni cella, non fidatevi di nulla che non abbiate chiesto

Una vista HotXLS di un gruppo di formule condivise OOXML in cui l'attributo si condivide solo l'espressione e il layout di memorizzazione, mentre ogni cella membro possiede il proprio valore cache, così un follower caricato senza valore continua a riportare xlfcsMissing
Il gruppo condivide l'espressione, non i numeri, quindi la cache della radice non viene mai propagata e un membro arrivato senza valore continua a riportare quel gap

La lettura dei valori cache, il reader unificato cross-engine e il motore di ricalcolo che potete scegliere di non invocare arrivano tutti nel componente foglio di calcolo HotXLS per Delphi standard per Delphi e C++Builder, senza dipendenza da Excel né da alcun server di automazione OLE; la pagina del prodotto porta il riferimento API completo per i punti di ingresso workbook e reader mostrati qui