Articolo tecnico

Matrice opzioni AGGREGATE e gate leak in HotXLS

HotXLS, il componente nativo per fogli di calcolo Excel per Delphi e C++Builder, ha rilasciato a settembre 2026 due correzioni correlate su AGGREGATE. La versione 2.382.0 ha corretto l'argomento options perché i codici 1/3/5/7 ignorino le righe nascoste, 2/3/6/7 ignorino gli errori, e da 0 a 3 ignorino le celle con SUBTOTAL e AGGREGATE annidati, esattamente come documenta Microsoft. La versione 2.382.3 ha poi impedito che quei flag di selezione filtrassero nella valutazione delle celle stesse a cui la funzione fa riferimento. Il primo difetto è imbarazzante nel modo in cui lo sono sempre i bug di trascrizione delle tabelle: le posizioni dei bit erano scambiate, quindi ogni formula che usava un codice options diverso da zero otteneva una politica che il suo autore non aveva chiesto. Il secondo è più interessante, perché è una forma che incontrerai in qualunque evaluator che usi un campo transitorio per passare contesto dentro una visita ricorsiva. Un'aggregazione esterna arma un flag, percorre un range, e pesca una cella la cui formula non è ancora stata calcolata. Quella formula gira sullo stesso calculator, vede lo stesso flag armato, e aggrega in silenzio le righe sbagliate, producendo un numero che è fuori di una quantità che nessuno riesce a spiegare dal solo testo della formula

Cosa selezionano davvero le opzioni AGGREGATE da 0 a 7?

L'argomento options di AGGREGATE è una matrice di tre bit, e i tre bit sono indipendenti. Il bit 0 (valore 1) significa ignora le righe nascoste, il bit 1 (valore 2) significa ignora i valori di errore, e il bit 2 (valore 4) significa smettere di ignorare le celle SUBTOTAL e AGGREGATE annidate, perché saltarle è il default per i codici bassi. Due cose al riguardo sono facili da invertire. Il bit delle righe nascoste è il bit basso, non quello centrale, quindi AGGREGATE(9,1,...) è la forma per il totale filtrato e AGGREGATE(9,2,...) è quella tollerante agli errori. E la politica sugli aggregati annidati è invertita rispetto alle altre due: solo i codici da 4 a 7 trattano una cella la cui formula è a sua volta un SUBTOTAL o un AGGREGATE come un valore ordinario. ECMA-376 Part 1 §18.17.7 definisce SUBTOTAL con la stessa divisione includi-o-escludi le righe nascoste tra i codici 1-11 e 101-111, e AGGREGATE, memorizzato nei file OOXML sotto il prefisso _xlfn., generalizza quella divisione nell'argomento options, quindi la tabella che Microsoft pubblica per la funzione AGGREGATE è il contratto che un motore deve rispettare, non una comodità

OpzioneRighe nascosteValori di erroreSUBTOTAL / AGGREGATE annidato
0inclusepropagatoignorato
1ignoratepropagatoignorato
2incluseignoratoignorato
3ignorateignoratoignorato
4inclusepropagatoincluso
5ignoratepropagatoincluso
6incluseignoratoincluso
7ignorateignoratoincluso

Perché HotXLS aveva le opzioni AGGREGATE al contrario?

Perché l'originale TXLSCalculator.CalcAggregateFunc era stato scritto da una parafrasi della tabella invece che dalla tabella. Calcolava ignoreErrors := (optCode >= 4) and (optCode <= 7) e armava il gate delle righe nascoste per i codici 2, 3, 6 e 7, mentre la politica sugli aggregati annidati non era implementata affatto. L'articolo precedente su SUBTOTAL e AGGREGATE con le righe nascoste elencava quella lacuna come limite aperto e descriveva la vecchia mappatura come era allora rilasciata; la descrizione era accurata sul codice e sbagliata su Excel, e nessuno se ne è accorto per molto tempo perché le due politiche che la maggior parte delle persone combina, nascoste più errori, cadono sui codici 3 e 7 in entrambe le tabelle. Solo un codice a bit singolo ha esposto lo scambio: AGGREGATE(9,1,A1:A4) restituiva la somma non filtrata, e AGGREGATE(9,2,...) saltava le righe nascoste pur propagando ancora #DIV/0!. Il difetto è emerso da una revisione statica di lxCalc.pas, registrato come HXLS-008 nel registro dei problemi noti del progetto, non da un file di cliente, il che dice qualcosa su quanto raramente i codici a bit singolo compaiano nei workbook di produzione. La versione 2.382.0 ha riscritto la decodifica come tre test di appartenenza a insiemi e ha aggiunto un secondo gate per la politica annidata, cablato attraverso una nuova callback TXLSIsSubtotalCell che il workbook fornisce accanto a TXLSIsRowHidden

La decodifica delle opzioni AGGREGATE di HotXLS prima e dopo la v2.382.0: l'originale CalcAggregateFunc armava il gate delle righe nascoste per i codici 2, 3, 6, 7 e ignorava gli errori da 4 in su senza alcuna politica su quelli annidati, mentre la decodifica corretta verifica le righe nascoste in 1, 3, 5, 7, gli errori in 2, 3, 6, 7 e i salti degli annidati da 0 a 3
Solo i codici a bit singolo hanno esposto lo scambio perché la popolare combinazione nascoste-più-errori cade sui codici 3 e 7 in entrambe le tabelle, e i codici fuori da 0 a 7 ora restituiscono lxErrorValue esattamente come Excel li rifiuta
// TXLSCalculator.CalcAggregateFunc, forma v2.382.3
if (optCode < 0) or (optCode > 7) then
begin
  Result := lxErrorValue;            // Excel rifiuta i codici fuori da 0..7
  Exit;
end;
ignoreErrors := optCode in [2, 3, 6, 7];
prevIgnoreHidden := FIgnoreHiddenRows;
prevIgnoreSubtotal := FIgnoreSubtotalCells;
FIgnoreHiddenRows := (optCode in [1, 3, 5, 7]) and Assigned(FIsRowHidden);
FIgnoreSubtotalCells := (optCode in [0, 1, 2, 3]) and Assigned(FIsSubtotalCell);
try
  // ... mappa function_num sull'iftab interno, percorre ref1..refN ...
finally
  FIgnoreHiddenRows := prevIgnoreHidden;
  FIgnoreSubtotalCells := prevIgnoreSubtotal;
end;

Nota che i due flag vengono assegnati incondizionatamente invece di essere impostati solo quando l'opzione li richiede. La versione 2.382.0 usava ancora if ... then FIgnoreHiddenRows := True, il che significava che un AGGREGATE con codice 4 annidato dentro un SUBTOTAL(109, ...) ereditava il gate delle righe nascoste esterno invece di azzerarlo. Assegnare il valore decodificato all'ingresso e ripristinare il valore precedente nel blocco finally fa sì che ogni chiamata ad AGGREGATE possieda la propria politica per la durata della sua visita e nient'altro. La versione 2.382.0 ha anche reso onesta la forma ad array: quando un argomento si valuta in un Variant array mono o bidimensionale, CalcAggregateFunc ora percorre ogni elemento e applica la politica sugli errori elemento per elemento, dove il vecchio codice verificava solo la presenza di un double NaN e altrimenti passava l'intero array a ExcelSum

Perché un AGGREGATE esterno filtra nelle formule a cui fa riferimento?

Perché FIgnoreHiddenRows e FIgnoreSubtotalCells sono campi del calculator, e il calculator è condiviso da ogni formula valutata durante un ricalcolo. I gate erano stati progettati come campi di lavoro proprio perché sei cicli di visita delle celle potessero consultarli senza infilare un parametro in ogni firma, e quel progetto è sano finché tutto ciò che gira mentre un gate è armato appartiene all'aggregazione che lo ha armato. L'assunzione si rompe in un punto preciso: FGetValue. Quando un walker chiede al workbook il valore di una cella e quella cella contiene una formula senza risultato in cache, il workbook compila la formula e la valuta sul momento, sullo stesso TXLSCalculator, con i gate esterni ancora impostati. La fixture di regressione in HotXLS.WorkbookApiTests.pas mostra il guasto con quattro celle. A1 contiene 10, A2 contiene 20 su una riga nascosta, A3 contiene =1/0, e A4 contiene =SUBTOTAL(9,A1:A2), il cui valore corretto è 30. Ora valuta =AGGREGATE(9,7,A1:A4): ignora le righe nascoste, ignora gli errori, conta il subtotale annidato come valore. Excel restituisce 10 + 30 = 40. Con A4 non in cache, il motore precedente alla 2.382.3 armava il gate delle righe nascoste, arrivava ad A4, ne attivava la valutazione, e CalcSubtotalFunc per il codice 9 ereditava il gate armato, perché non fa che impostare il flag per i codici da 101 a 111 e non lo azzera mai. A4 si valutava in 10 invece di 30, e il totale esterno tornava come 20. Niente in nessuna delle due formule menziona righe nascoste sul percorso che ha prodotto il numero sbagliato

Come un AGGREGATE esterno di HotXLS filtrava nei suoi precedenti: con FIgnoreHiddenRows armato per il codice 7, la visita raggiunge A4 non in cache che contiene SUBTOTAL 9 su A1:A2, FGetValue la valuta sullo stesso calculator, CalcSubtotalFunc eredita il gate e restituisce 10 invece di 30, quindi il totale riporta 20 dove Excel restituisce 40
Anche il gate degli aggregati annidati filtrava nella direzione opposta, e CalcSubtotalFunc azzerava FIgnoreSubtotalCells in uscita invece di ripristinarlo, disarmando la politica esterna per ogni cella dopo un subtotale non in cache raggiunto a metà visita

Il gate degli aggregati annidati filtrava allo stesso modo nella direzione opposta. Con i codici da 0 a 3, FIgnoreSubtotalCells è armato, e il walker generico dei range in GetValueItemRange lo rispetta, quindi un precedente la cui formula è =SUM(B1:B3) scarterebbe in silenzio B2 se B2 contenesse per caso un SUBTOTAL. Peggio, CalcSubtotalFunc azzera FIgnoreSubtotalCells a False in uscita invece di ripristinare il valore precedente, quindi un precedente SUBTOTAL non in cache raggiunto a metà visita disarmava il gate esterno per ogni cella successiva. Il registro dei problemi noti del progetto classifica questo sotto HXLS-008 come nested selection state leakage, ed è il nome giusto per questa classe di bug: un flag transitorio globale che è corretto per il frame che lo ha impostato e sbagliato per ogni frame che lo eredita

Come AggregateGetCellValue e AggregateGetItemValue isolano la visita

La correzione della v2.382.3 mette un confine intorno a ogni punto in cui AGGREGATE legge un valore che non ha calcolato da sé. TXLSCalculator.AggregateGetCellValue avvolge la chiamata grezza a FGetValue: salva entrambi i flag, li azzera, esegue il recupero, e li ripristina in un blocco finally. L'aggregazione esterna applica comunque la propria politica alla cella che ha appena recuperato, perché i test su righe nascoste e celle annidate avvengono nel walker intorno al recupero, ma la formula precedente in sé gira senza alcuna politica, che è quello che fa Excel

L'isolamento della v2.382.3 di HotXLS: AggregateGetCellValue salva entrambi i flag dei gate, li azzera, recupera tramite FGetValue e li ripristina in un blocco finally, così una formula precedente si valuta senza politica mentre il walker esterno applica comunque i test su righe nascoste e celle annidate intorno al recupero
AggregateGetItemValue fa lo stesso per gli argomenti array calcolati e mappa gli errori di recupero su VarAsError, mentre un codice di limite di risorse di proposito non viene mai trattato come errore ignorabile sotto le opzioni ignore-errors
function TXLSCalculator.AggregateGetCellValue(SheetIndex, Row, Col: Integer;
  var Value: Variant; var OutOfRange: Boolean): Integer;
var
  Hidden, Nested: Boolean;
begin
  Hidden := FIgnoreHiddenRows;
  Nested := FIgnoreSubtotalCells;
  FIgnoreHiddenRows := False;        // una formula precedente possiede la propria politica
  FIgnoreSubtotalCells := False;
  try
    Result := FGetValue(SheetIndex, Row, Col, Value, OutOfRange);
  finally
    FIgnoreHiddenRows := Hidden;
    FIgnoreSubtotalCells := Nested;
  end;
end;

AggregateGetItemValue fa lo stesso per gli argomenti non-range, e deve fare più che azzerare flag, perché un argomento come A1:A4/(B1:B4-20) è un array calcolato la cui forma degli elementi deve sopravvivere. Il wrapper materializza un range semplice in un Variant array bidimensionale tramite AggregateGetCellValue, mappando una cella che ha restituito un codice di errore su VarAsError così la politica sugli errori può comunque essere applicata elemento per elemento, e ricorre attraverso i nodi operatori binari e unari (SA_ADD, SA_DIV, SA_UNARMINUS e gli altri) con ApplyArrayBinaryOp e ApplyArrayUnaryOp; qualunque altra cosa ricade nel normale GetValueItem. Davanti alla materializzazione stanno due guardie: un range più grande di EffectiveFormulaArrayMemoryLimit restituisce lxErrorResourceLimit, e un range multi-foglio o invertito restituisce #VALUE!. Un codice di limite di risorse di proposito non viene trattato come errore di cella ignorabile nemmeno sotto le opzioni 2/3/6/7, dato che un motore che inghiottisse il proprio segnale di out-of-memory perché l'utente ha chiesto di saltare #N/A starebbe mentendo. Tutti e tre i walker di AGGREGATE, AggregateCollectRange per la famiglia SUM, AggregateReduceVariance per STDEV, VAR e PRODUCT, e AggregateReduceWithK per MEDIAN e le forme quantile, sono passati da FGetValue e GetValueItem ai due wrapper, e ognuno ha guadagnato il test sulle celle annidate tramite FIsSubtotalCell

Quale errore restituisce AGGREGATE quando non ignora gli errori?

Quello originale, dalla v2.382.3 in poi. La versione 2.382.0 rilevava correttamente le celle di errore ma le collassava tutte in lxErrorValue, quindi AGGREGATE(9,4,A1:A3) su una cella #DIV/0! restituiva #VALUE!, dove Excel propaga invariato il primo errore che incontra. L'helper sostitutivo AggregateErrorCode mappa un Variant sul codice lxError* corrispondente, sia che il Variant sia un vero varError sia che sia una delle sette stringhe di errore, e AggregateValueIsError ora è solo un test su un risultato diverso da zero. Ogni walker registra il primo codice di errore che vede e restituisce quel codice, il che significa anche che una cella la cui formula non è mai stata calcolata, e il cui errore arriva quindi come codice di ritorno da FGetValue invece che come Variant in cache, si propaga allo stesso modo di una in cache. Due funzioni di conteggio ricevono un trattamento speciale dentro AggregateCollectRange, e il trattamento corrisponde a SUBTOTAL invece che a SUM. Per la funzione interna 0, COUNT, una cella di errore non viene mai contata e mai propagata, qualunque sia il codice options, perché COUNT conta solo i numeri. Per la funzione interna 169, COUNTA, una cella di errore è un valore non vuoto e conta come 1, a meno che il codice options non ignori gli errori, nel qual caso viene saltata. Quell'asimmetria è come Excel tratta COUNT e COUNTA anche fuori da AGGREGATE, ed è il tipo di dettaglio che una regola generica "se errore allora propaga" sbaglia in silenzio

Cosa verifica la matrice di regressione a otto opzioni

La fixture descritta sopra viene esercitata come matrice completa in AggregateFunc_OptionMatrixCoversHiddenErrorsAndNestedAggregates: per ogni codice options da 0 a 7 valuta sia la forma SUM sia la forma MEDIAN su A1:A4 e confronta il risultato con un'aspettativa derivata a mano. I codici 0, 1, 4 e 5 devono propagare il #DIV/0! di A3, dato che nessuno di essi ignora gli errori. Il codice 2 dà SUM 30 e MEDIAN 15, da 10 e 20 con l'annidata A4 saltata. Il codice 3 dà 10 e 10. Il codice 6 dà 60 e 20, perché il 30 in A4 ora conta. Il codice 7 dà 40 e 20, che è il caso che restituiva 20 prima della correzione del leak. L'esecuzione di accettazione più ampia registrata nel registro dei problemi noti copre tutti e diciannove i numeri di funzione contro tutti e otto i codici, con ogni precedente sia in cache sia non in cache, per 304 scenari su Win32 e Win64

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Data');
    Sheet.Cells[1, 1].Value := 10;
    Sheet.Cells[2, 1].Value := 20;
    Sheet.Cells[3, 1].Formula := '=1/0';
    Sheet.Cells[4, 1].Formula := '=SUBTOTAL(9,A1:A2)';   // subtotale di gruppo = 30
    Sheet.RowHidden[2] := True;

    Sheet.Cells[6, 1].Formula := '=AGGREGATE(9,1,A1:A4)'; // #DIV/0!  nascoste saltate, errore propagato
    Sheet.Cells[7, 1].Formula := '=AGGREGATE(9,3,A1:A4)'; // 10       nascoste + errori + annidati saltati
    Sheet.Cells[8, 1].Formula := '=AGGREGATE(9,6,A1:A4)'; // 60       solo gli errori saltati
    Sheet.Cells[9, 1].Formula := '=AGGREGATE(9,7,A1:A4)'; // 40       era 20 prima della v2.382.3
    Book.Recalculate;
    Book.SaveAs('aggregate-options.xlsx');
  finally
    Book.Free;
  end;
end;

Dove sta ancora il confine

Tre limiti vale la pena conoscerli prima di costruirci sopra. Primo, il predicato sugli aggregati annidati è testuale. TXLSXWorkbook.GetCalcIsSubtotalCell e il suo gemello del motore classico rispondono True quando la formula di una cella inizia con SUBTOTAL(, AGGREGATE( o _xlfn.AGGREGATE(, con o senza il segno di uguale iniziale, quindi una formula come =IF(C1,SUBTOTAL(9,B1:B9),0) o =SUBTOTAL(9,B1:B9)*2 non viene riconosciuta come annidata e verrà contata due volte dai codici da 0 a 3 dove Excel la salterebbe; un generatore che emette subtotali calcolati dovrebbe tenere la chiamata di aggregazione in testa alla formula. Secondo, l'isolamento vive nei tre walker di AGGREGATE. CalcSubtotalFunc passa ancora per GetValueItemRange, CollectRangeValues e SubtotalReduceVariance, che chiamano FGetValue direttamente, quindi un SUBTOTAL(109, ...) il cui range contiene una formula precedente non in cache può ancora passare il proprio gate delle righe nascoste a quella precedente. Un Recalculate completo valuta i precedenti prima dei dipendenti, quindi si prende il percorso in cache e il gate non viene mai ereditato; l'esposizione è limitata alla valutazione ad hoc tramite Calculate e ai workbook caricati senza valori in cache, e se ti affidi al ricalcolo incrementale sul grafo delle dipendenze per tenere reattivi i modelli grandi, la stessa garanzia sull'ordine è ciò che tiene dormiente questo leak. Terzo, entrambi i gate sono condizionati a Assigned(FIsRowHidden) e Assigned(FIsSubtotalCell). Entrambe le facade del workbook cablano le callback nei loro costruttori, ma codice che costruisce un TXLSCalculator a mano con i soli due argomenti originali ottiene il comportamento legacy che include tutto per ogni codice options, in silenzio. Quando un totale sembra sbagliato e il testo della formula sembra giusto, tracciare la valutazione passo passo è il modo più rapido per vedere se un precedente è stato valutato sotto un gate ereditato o se una callback semplicemente non è mai stata agganciata

Il motore di calcolo descritto qui, il decoder delle opzioni, i wrapper di recupero isolati e la matrice di regressione che li fissa sono tutti distribuiti come sorgente con il componente per fogli di calcolo HotXLS per Delphi, che legge, scrive e ricalcola workbook XLS, XLSX e ODS in Delphi e C++Builder senza un'installazione di Excel