Technischer Artikel

AGGREGATE-Options-Matrix und Gate-Leak in HotXLS für Delphi

HotXLS, die native Excel-Spreadsheet-Komponente für Delphi und C++Builder, hat im September 2026 zwei verwandte AGGREGATE-Fixes ausgeliefert. Version 2.382.0 korrigierte das Options-Argument, sodass Codes 1/3/5/7 versteckte Zeilen ignorieren, 2/3/6/7 Fehler ignorieren und 0 bis 3 verschachtelte SUBTOTAL- und AGGREGATE-Zellen ignorieren – exakt so, wie Microsoft es dokumentiert. Version 2.382.3 hinderte dann diese Selektions-Flags daran, in die Auswertung genau der Zellen zu leaken, die die Funktion referenziert. Der erste Defekt ist peinlich auf die Art, wie Tabellen-Abschreib-Bugs es immer sind: Die Bitpositionen waren vertauscht, also bekam jede Formel mit einem Optionscode ungleich null eine Policy, die ihr Autor nie bestellt hat. Der zweite ist interessanter, denn es ist eine Form, der Sie in jedem Evaluator begegnen, der ein transientes Feld nutzt, um Kontext in einen rekursiven Durchlauf zu reichen. Eine äußere Aggregation bewaffnet ein Flag, läuft einen Range ab und zieht eine Zelle, deren Formel noch nicht berechnet wurde. Diese Formel läuft auf demselben Calculator, sieht dasselbe bewaffnete Flag und aggregiert stillschweigend die falschen Zeilen – das Ergebnis ist eine Zahl, die um einen Betrag danebenliegt, den niemand allein aus dem Formeltext erklären kann

Was wählen die AGGREGATE-Optionen 0 bis 7 tatsächlich aus?

Das Options-Argument von AGGREGATE ist eine Drei-Bit-Matrix, und die drei Bits sind unabhängig. Bit 0 (Wert 1) heißt versteckte Zeilen ignorieren, Bit 1 (Wert 2) heißt Fehlerwerte ignorieren, und Bit 2 (Wert 4) heißt aufhören, verschachtelte SUBTOTAL- und AGGREGATE-Zellen zu ignorieren, denn das Überspringen ist bei den niedrigen Codes der Default. Zwei Dinge daran sind leicht verdreht zu verstehen. Das Versteckt-Zeilen-Bit ist das niederwertige Bit, nicht das mittlere, also ist AGGREGATE(9,1,...) die Form für gefilterte Summen und AGGREGATE(9,2,...) die fehlertolerante. Und die Nested-Aggregate-Policy ist invertiert relativ zu den anderen beiden: Nur die Codes 4 bis 7 behandeln eine Zelle, deren eigene Formel ein SUBTOTAL oder AGGREGATE ist, als gewöhnlichen Wert. ECMA-376 Part 1 §18.17.7 definiert SUBTOTAL mit derselben Einschließen-oder-Ausschließen-Splitzung bei versteckten Zeilen über die Codes 1-11 und 101-111, und AGGREGATE, in OOXML-Dateien unter dem _xlfn.-Präfix gespeichert, verallgemeinert diese Splitzung ins Options-Argument, also ist die Tabelle, die Microsoft für die AGGREGATE-Funktion veröffentlicht, der Kontrakt, den eine Engine erfüllen muss, und keine Gefälligkeit

OptionVersteckte ZeilenFehlerwerteVerschachtelte SUBTOTAL / AGGREGATE
0eingeschlossenpropagiertignoriert
1ignoriertpropagiertignoriert
2eingeschlossenignoriertignoriert
3ignoriertignoriertignoriert
4eingeschlossenpropagierteingeschlossen
5ignoriertpropagierteingeschlossen
6eingeschlossenignorierteingeschlossen
7ignoriertignorierteingeschlossen

Warum hatte HotXLS die AGGREGATE-Optionen verdreht?

Weil das ursprüngliche TXLSCalculator.CalcAggregateFunc aus einer Paraphrase der Tabelle geschrieben war statt aus der Tabelle. Es berechnete ignoreErrors := (optCode >= 4) and (optCode <= 7) und bewaffnete das Versteckt-Zeilen-Gate für die Codes 2, 3, 6 und 7, während die Nested-Aggregate-Policy überhaupt nicht implementiert war. Der frühere Artikel zu SUBTOTAL- und AGGREGATE-Versteckt-Zeilen listete diese Lücke als offene Grenze und beschrieb das alte Mapping, wie es damals ausgeliefert war; die Beschreibung traf auf den Code zu und nicht auf Excel, und niemand ist lange darauf aufmerksam geworden, weil die beiden Policies, die die meisten kombinieren – versteckt plus Fehler –, in beiden Tabellen auf die Codes 3 und 7 fallen. Nur ein Einzel-Bit-Code hat den Tausch entlarvt: AGGREGATE(9,1,A1:A4) lieferte die ungefilterte Summe, und AGGREGATE(9,2,...) übersprang versteckte Zeilen, während es weiterhin #DIV/0! propagierte. Der Defekt kam aus einer statischen Durchsicht von lxCalc.pas, geloggt als HXLS-008 im Known-Issues-Register des Projekts, nicht aus einer Kundendatei – was etwas darüber sagt, wie selten die Einzel-Bit-Codes in Produktions-Workbooks auftauchen. Version 2.382.0 schrieb das Dekodieren als drei Mengenzugehörigkeitstests um und ergänzte ein zweites Gate für die Nested-Policy, verdrahtet über einen neuen TXLSIsSubtotalCell-Callback, den das Workbook neben TXLSIsRowHidden bereitstellt

Das HotXLS-AGGREGATE-Options-Dekodieren vor und nach v2.382.0: Das ursprüngliche CalcAggregateFunc bewaffnete das Versteckt-Zeilen-Gate für die Codes 2, 3, 6, 7 und ignorierte Fehler ab 4 aufwärts ohne Nested-Policy, während das korrigierte Dekodieren versteckte Zeilen in 1, 3, 5, 7, Fehler in 2, 3, 6, 7 und Nested-Skips in 0 bis 3 testet
Nur Einzel-Bit-Codes entlarvten den Tausch, weil die beliebte Versteckt-plus-Fehler-Kombination in beiden Tabellen auf die Codes 3 und 7 fällt, und Codes außerhalb 0 bis 7 geben jetzt lxErrorValue zurück, exakt so, wie Excel sie zurückweist
// TXLSCalculator.CalcAggregateFunc, Form ab v2.382.3
if (optCode < 0) or (optCode > 7) then
begin
  Result := lxErrorValue;            // Excel weist Codes außerhalb 0..7 zurück
  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
  // ... function_num auf das innere iftab mappen, ref1..refN ablaufen ...
finally
  FIgnoreHiddenRows := prevIgnoreHidden;
  FIgnoreSubtotalCells := prevIgnoreSubtotal;
end;

Beachten Sie, dass die beiden Flags bedingungslos zugewiesen werden statt nur gesetzt zu werden, wenn die Option es verlangt. Die Version v2.382.0 nutzte noch if ... then FIgnoreHiddenRows := True, was bedeutete, dass ein AGGREGATE mit Code 4, verschachtelt in einem SUBTOTAL(109, ...), das äußere Versteckt-Zeilen-Gate erbte, statt es zu löschen. Den dekodierten Wert beim Eintritt zuzuweisen und den vorherigen Wert im finally-Block wiederherzustellen lässt jeden AGGREGATE-Aufruf seine Policy für die Dauer seines Durchlaufs besitzen und sonst nichts. Version 2.382.0 machte auch die Array-Form ehrlich: Evaluiert ein Argument zu einem ein- oder zweidimensionalen Variant-Array, läuft CalcAggregateFunc jetzt über jedes Element und wendet die Fehler-Policy pro Element an, wo der alte Code nur auf ein NaN-Double testete und sonst das ganze Array an ExcelSum durchreichte

Warum leakt ein äußeres AGGREGATE in die Formeln, die es referenziert?

Weil FIgnoreHiddenRows und FIgnoreSubtotalCells Felder am Calculator sind, und der Calculator von jeder Formel geteilt wird, die während einer Neuberechnung evaluiert wird. Die Gates waren als Scratch-Felder entworfen, gerade damit sechs Zellablauf-Schleifen sie konsultieren konnten, ohne einen Parameter durch jede Signatur zu ziehen, und dieses Design ist solange stimmig, wie alles, was läuft, während ein Gate bewaffnet ist, zur Aggregation gehört, die es bewaffnet hat. Die Annahme bricht an einem konkreten Punkt: FGetValue. Fragt ein Walker das Workbook nach einem Zellwert und die Zelle hält eine Formel ohne gecachtes Ergebnis, kompiliert das Workbook die Formel und evaluiert sie auf der Stelle, auf demselben TXLSCalculator, mit den äußeren Gates noch gesetzt. Das Regression-Fixture in HotXLS.WorkbookApiTests.pas zeigt das Versagen mit vier Zellen. A1 hält 10, A2 hält 20 in einer versteckten Zeile, A3 hält =1/0, und A4 hält =SUBTOTAL(9,A1:A2), dessen korrekter Wert 30 ist. Evaluiert man nun =AGGREGATE(9,7,A1:A4): versteckte Zeilen ignorieren, Fehler ignorieren, das verschachtelte Subtotal als Wert zählen. Excel liefert 10 + 30 = 40. Mit ungecachedem A4 bewaffnete die Engine vor 2.382.3 das Versteckt-Zeilen-Gate, lief zu A4, löste dessen Evaluation aus, und CalcSubtotalFunc für Code 9 erbte das bewaffnete Gate, denn er setzt den Flag nur für die Codes 101 bis 111 und löscht ihn nie. A4 evaluierte zu 10 statt 30, und die äußere Summe kam als 20 zurück. Nichts in einer der beiden Formeln erwähnt versteckte Zeilen auf dem Pfad, der die falsche Zahl produziert hat

Wie ein äußeres HotXLS-AGGREGATE in seine Präzedenzen leakte: Mit FIgnoreHiddenRows bewaffnet für Code 7 erreicht der Durchlauf das ungecachede A4 mit SUBTOTAL 9 über A1:A2, FGetValue evaluiert es auf demselben Calculator, CalcSubtotalFunc erbt das Gate und liefert 10 statt 30, also meldet die Summe 20, wo Excel 40 liefert
Das Nested-Gate leakte auch in die andere Richtung, und CalcSubtotalFunc setzte FIgnoreSubtotalCells beim Austritt auf False statt es wiederherzustellen, wodurch die äußere Policy für jede Zelle nach einem ungecacheden Subtotal mitten im Durchlauf entwaffnet war

Das Nested-Aggregate-Gate leakte auf dieselbe Weise in die andere Richtung. Bei den Codes 0 bis 3 ist FIgnoreSubtotalCells bewaffnet, und der generische Range-Walker in GetValueItemRange ehrt es, also würde eine Präzedenz, deren Formel =SUM(B1:B3) ist, stillschweigend B2 fallen lassen, falls B2 zufällig ein SUBTOTAL enthielt. Schlimmer noch: CalcSubtotalFunc setzt FIgnoreSubtotalCells beim Austritt auf False, statt den vorherigen Wert wiederherzustellen, also entwaffnete eine ungecachede SUBTOTAL-Präzedenz, mitten im Durchlauf erreicht, das äußere Gate für jede Zelle nach ihr. Das Known-Issues-Register des Projekts führt das unter HXLS-008 als nested selection state leakage, und das ist der richtige Name für diese Bugklasse: ein globales transientes Flag, das für den Rahmen korrekt ist, der es setzt, und für jeden Rahmen falsch, der es erbt

Wie AggregateGetCellValue und AggregateGetItemValue den Durchlauf isolieren

Der Fix in v2.382.3 legt eine Grenze um jeden Punkt, an dem AGGREGATE einen Wert liest, den es nicht selbst berechnet hat. TXLSCalculator.AggregateGetCellValue hüllt den rohen FGetValue-Aufruf ein: Sie sichert beide Flags, löscht sie, führt den Fetch aus und stellt sie in einem finally-Block wieder her. Die äußere Aggregation wendet ihre eigene Policy weiterhin auf die Zelle an, die sie gerade gezogen hat, denn die Versteckt-Zeilen- und Nested-Cell-Tests passieren im Walker um den Fetch herum, aber die Präzedenzformel selbst läuft ohne jede Policy – genau wie bei Excel

Die HotXLS-Isolation in v2.382.3: AggregateGetCellValue sichert beide Gate-Flags, löscht sie, zieht über FGetValue und stellt sie in einem finally-Block wieder her, also evaluiert eine Präzedenzformel ohne jede Policy, während der äußere Walker weiterhin Versteckt-Zeilen- und Nested-Cell-Tests um den Fetch herum anwendet
AggregateGetItemValue tut dasselbe für berechnete Array-Argumente und mappt Fetch-Fehler auf VarAsError, während ein Resource-Limit-Code bewusst nie als ignorierbarer Fehler unter den Ignore-Errors-Optionen behandelt wird
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;        // eine Präzedenzformel besitzt ihre eigene Policy
  FIgnoreSubtotalCells := False;
  try
    Result := FGetValue(SheetIndex, Row, Col, Value, OutOfRange);
  finally
    FIgnoreHiddenRows := Hidden;
    FIgnoreSubtotalCells := Nested;
  end;
end;

AggregateGetItemValue tut dasselbe für Nicht-Range-Argumente, und es muss mehr tun als Flags zu löschen, denn ein Argument wie A1:A4/(B1:B4-20) ist ein berechnetes Array, dessen Elementform überleben muss. Der Wrapper materialisiert einen nackten Range über AggregateGetCellValue in ein zweidimensionales Variant-Array und mappt eine Zelle, die einen Fehlercode zurückgab, auf VarAsError, sodass die Fehler-Policy weiterhin pro Element angewendet werden kann, und er rekursiert durch die binären und unären Operator-Knoten (SA_ADD, SA_DIV, SA_UNARMINUS und die übrigen) mit ApplyArrayBinaryOp und ApplyArrayUnaryOp; alles andere fällt durch zum normalen GetValueItem. Zwei Guards sitzen vor der Materialisierung: Ein Range größer als EffectiveFormulaArrayMemoryLimit gibt lxErrorResourceLimit zurück, und ein Multi-Sheet- oder invertierter Range gibt #VALUE! zurück. Ein Resource-Limit-Code wird bewusst nicht als ignorierbarer Zellfehler behandelt, auch unter den Optionen 2/3/6/7, denn eine Engine, die ihr eigenes Out-of-Memory-Signal verschluckte, weil der Benutzer bat, #N/A zu überspringen, würde lügen. Alle drei AGGREGATE-Walker, AggregateCollectRange für die SUM-Familie, AggregateReduceVariance für STDEV, VAR und PRODUCT sowie AggregateReduceWithK für MEDIAN und die Quantilformen, wurden von FGetValue und GetValueItem auf die beiden Wrapper umgestellt, und jeder bekam den Nested-Cell-Test über FIsSubtotalCell

Welchen Fehler gibt AGGREGATE zurück, wenn es Fehler nicht ignoriert?

Den ursprünglichen, seit v2.382.3. Version 2.382.0 erkannte Fehlerzellen korrekt, kollabierte aber jede davon zu lxErrorValue, also lieferte AGGREGATE(9,4,A1:A3) über eine #DIV/0!-Zelle #VALUE!, wo Excel den ersten Fehler, dem es begegnet, unverändert propagiert. Der Ersatzhelfer AggregateErrorCode mappt ein Variant auf den passenden lxError*-Code, ob das Variant ein echtes varError ist oder einer der sieben Fehler-Strings, und AggregateValueIsError ist jetzt nur noch ein Test auf ein Ergebnis ungleich null. Jeder Walker notiert den ersten Fehlercode, den er sieht, und gibt diesen Code zurück, was auch bedeutet, dass eine Zelle, deren Formel nie berechnet wurde und deren Fehler deshalb als Rückgabecode von FGetValue ankommt statt als gecachtes Variant, sich genauso propagiert wie eine gecachte. Zwei Zählfunktionen bekommen in AggregateCollectRange eine Sonderbehandlung, und die Behandlung folgt SUBTOTAL statt SUM. Für die innere Funktion 0, COUNT, wird eine Fehlerzelle nie gezählt und nie propagiert, ungeachtet des Optionscodes, denn COUNT zählt nur Zahlen. Für die innere Funktion 169, COUNTA, ist eine Fehlerzelle ein nicht-leerer Wert und zählt als 1, außer der Optionscode ignoriert Fehler, in welchem Fall sie übersprungen wird. Diese Asymmetrie ist auch außerhalb von AGGREGATE, wie Excel COUNT und COUNTA behandelt, und sie ist die Sorte Detail, die eine generische „wenn Fehler dann propagiere“-Regel stillschweigend falsch bekommt

Was die Acht-Optionen-Regression-Matrix verifiziert

Das oben beschriebene Fixture wird als volle Matrix in AggregateFunc_OptionMatrixCoversHiddenErrorsAndNestedAggregates gefahren: Für jeden Optionscode von 0 bis 7 evaluiert es sowohl die SUM-Form als auch die MEDIAN-Form über A1:A4 und prüft das Ergebnis gegen eine von Hand abgeleitete Erwartung. Die Codes 0, 1, 4 und 5 müssen das #DIV/0! aus A3 propagieren, denn keiner von ihnen ignoriert Fehler. Code 2 liefert SUM 30 und MEDIAN 15, aus 10 und 20 mit übersprungenem verschachteltem A4. Code 3 liefert 10 und 10. Code 6 liefert 60 und 20, denn die 30 in A4 zählt jetzt. Code 7 liefert 40 und 20 – der Fall, der vor dem Leak-Fix 20 zurückgab. Der weitere Abnahme-Lauf, im Known-Issues-Register festgehalten, deckt alle neunzehn Funktionsnummern gegen alle acht Codes ab, mit jeder Präzedenz sowohl gecached als auch ungecached, für 304 Szenarien auf Win32 und 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)';   // Gruppen-Subtotal = 30
    Sheet.RowHidden[2] := True;

    Sheet.Cells[6, 1].Formula := '=AGGREGATE(9,1,A1:A4)'; // #DIV/0!  versteckte übersprungen, Fehler propagiert
    Sheet.Cells[7, 1].Formula := '=AGGREGATE(9,3,A1:A4)'; // 10       versteckt + Fehler + verschachtelt übersprungen
    Sheet.Cells[8, 1].Formula := '=AGGREGATE(9,6,A1:A4)'; // 60       nur Fehler übersprungen
    Sheet.Cells[9, 1].Formula := '=AGGREGATE(9,7,A1:A4)'; // 40       vor v2.382.3 war es 20
    Book.Recalculate;
    Book.SaveAs('aggregate-options.xlsx');
  finally
    Book.Free;
  end;
end;

Wo die Grenze weiterhin liegt

Drei Grenzen sollte man kennen, bevor man darauf aufbaut. Erstens ist das Nested-Aggregate-Prädikat textuell. TXLSXWorkbook.GetCalcIsSubtotalCell und sein Classic-Engine-Zwilling antworten True, wenn die Formel einer Zelle mit SUBTOTAL(, AGGREGATE( oder _xlfn.AGGREGATE( beginnt, mit oder ohne führendes Gleichheitszeichen, also wird eine Formel wie =IF(C1,SUBTOTAL(9,B1:B9),0) oder =SUBTOTAL(9,B1:B9)*2 nicht als verschachtelt erkannt und von den Codes 0 bis 3 doppelt gezählt, wo Excel sie überspringen würde; ein Generator, der berechnete Subtotals emittiert, sollte den Aggregationsaufruf am Kopf der Formel halten. Zweitens lebt die Isolation in den drei AGGREGATE-Walkern. CalcSubtotalFunc läuft weiterhin durch GetValueItemRange, CollectRangeValues und SubtotalReduceVariance, die FGetValue direkt aufrufen, also kann ein SUBTOTAL(109, ...), dessen Range eine ungecachede Präzedenzformel enthält, sein Versteckt-Zeilen-Gate weiterhin in diese Präzedenz hineinreichen. Ein volles Recalculate evaluiert Präzedenzen vor Abhängigen, also wird der gecachte Pfad genommen, und das Gate wird nie geerbt; die Exposition beschränkt sich auf Ad-hoc-Evaluation über Calculate und auf Workbooks, die ohne gecachte Werte geladen wurden, und wenn Sie sich auf die inkrementelle Neuberechnung über den Dependency Graph verlassen, um große Modelle reaktionsschnell zu halten, ist dieselbe Ordnungsgarantie das, was dieses Leak schlafend hält. Drittens sind beide Gates an Assigned(FIsRowHidden) und Assigned(FIsSubtotalCell) geknüpft. Beide Workbook-Fassaden verdrahten die Callbacks in ihren Konstruktoren, aber Code, der einen TXLSCalculator von Hand mit nur den zwei ursprünglichen Argumenten baut, bekommt das Legacy-Verhalten alles einzuschließen für jeden Optionscode, stillschweigend. Wenn eine Summe falsch aussieht und der Formeltext richtig aussieht, ist die Evaluation Schritt für Schritt nachzuverfolgen der schnellste Weg zu sehen, ob eine Präzedenz unter einem geerbten Gate evaluiert wurde oder ob ein Callback schlicht nie angeschlossen wurde

Die hier beschriebene Berechnungs-Engine, der Options-Decoder, die isolierten Fetch-Wrapper und die Regression-Matrix, die alles festnagelt, kommen als Quelltext mit der HotXLS Delphi spreadsheet component daher, die XLS-, XLSX- und ODS-Workbooks in Delphi und C++Builder liest, schreibt und neu berechnet, ohne Excel-Installation