Technischer Artikel

Konstruktor-Exceptions und nil-sichere Destruktoren

Wenn ein Delphi-Konstruktor eine Exception wirft, ruft die RTL sofort den Destruktor auf der halb gebauten Instanz auf, also muss jeder Destruktor mit Feldern klarkommen, die nie zugewiesen wurden. HotXLS wendet diese Regel auf seine Destruktoren an, TXLSXRichText und TXLSXCells eingeschlossen: Ein Allokationsfehler in diesen Konstruktoren erreicht deinen Code als die ursprüngliche EOutOfMemory, nicht als eine sekundäre Access Violation, geworfen beim Aufräumen

Die Bug-Klasse ist alt und leicht zu übersehen. Ein Service konvertiert ein 300-MB-Workbook auf einem ausgelasteten Server, der Speicher wird knapp, und das Log zeigt eine Access Violation in einem Destruktor. Nichts an dieser Meldung sagt "out of memory", nichts deutet darauf hin, dass ein späterer Versuch klappen würde, und der Stack-Trace zeigt auf einen Destruktor, der perfekt korrekt aussieht. Die ursprüngliche Exception existierte ein paar Mikrosekunden und wurde dann ersetzt. Dieser Artikel geht durch, warum das passiert, wie HotXLS seine Destruktoren strukturiert, um es zu vermeiden, und wie du eigene Klassen schreibst, die es nie tun

Was passiert, wenn ein Delphi-Konstruktor wirft?

Das Objekt wird für dich zerstört, bevor die Exception den Aufrufer erreicht, und dein Destruktor läuft gegen welchen Zustand der Konstruktor auch erreicht hat. Die Sequenz ist vom Compiler und der RTL festgelegt. NewInstance allokiert die Instanz und InitInstance füllt sie mit Nullen, also startet jede Objektreferenz als nil, jeder Integer als 0 und jeder String als leer. Der Konstruktorkörper läuft dann innerhalb eines impliziten Exception-Handlers. Wirft irgendetwas in jenem Körper, ruft der generierte Code den virtuellen Destruktor: BeforeDestruction läuft, dann dein Destroy, dann gibt FreeInstance den Speicher frei, und zuletzt wird die Exception erneut geworfen. AfterConstruction läuft nie, und die Variable links von X := TFoo.Create wird nie zugewiesen

Delphi-Objektlebenszyklus, der zeigt, wie die RTL Destroy auf einer halb gebauten HotXLS-Instanz ruft, wenn ein Konstruktor wirft, bevor die ursprüngliche Exception den Aufrufer erreicht
wirft ein Delphi-Konstruktor, zerstört die RTL zuerst die halb gebaute Instanz und wirft erst dann die ursprüngliche Exception erneut, Destroy läuft also gegen welchen Zustand die Konstruktion erreicht hat

Der Destruktor sieht also ein Objekt, das an einer beliebigen Zeile seines Konstruktors stehen blieb. Felder, die vor dem Fehler zugewiesen wurden, halten echte Objekte. Felder danach sind noch nil. Ein Destruktor, geschrieben unter der Annahme, die Konstruktion sei fertig, wird eines dieser nil-Felder dereferenzieren und eine eigene Access Violation werfen

Diese zweite Exception ist der echte Schaden. Delphi verkettet keine Exceptions, die während dieses Aufräumens geworfen werden: Die Access Violation ersetzt die EOutOfMemory, die ursprüngliche Exception wird weggeworfen, und der Aufrufer sieht nur je die AV. Die nützliche Diagnose, "eine Allokation schlug fehl, versuch es mit mehr Speicher erneut", ist weg, und an ihrer Stelle steht etwas, das wie Speicherkorruption aussieht

Woher kommt die sekundäre Access Violation?

Sie kommt von einem Destruktor, der annimmt, sein Konstruktor sei fertig, und ein Listenfeld anfasst, das nil ist, wenn die allererste interne Allokation scheitert. Zwei HotXLS-Klassen zeigen die Form deutlich. Sowohl das eigenständige TXLSXRichText.Create als auch der Overload mit Comment-Besitzer TXLSXRichText.Create(AOwner: TXLSXComment) rufen inherited Create auf und allokieren dann FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) macht dasselbe mit FList := TList.Create, bevor es seine Packed-String-Tabellen und den Packed-Cell-Speicher baut. In Lib/lxHandleX.pas sichern die beiden Destruktoren diese Felder ab:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // der Konstruktor kann vor FRuns gestoppt haben
  for i:= 0 to FRuns.Count- 1 do
    TXLSXRichTextRun(FRuns[i]).Free;
  FRuns.Clear;
end;

destructor TXLSXCells.Destroy;
begin
  FPackedStorageEnabled:= False;
  if FList<>nil then Clear;          // FList ist nil, wenn TList.Create scheiterte
  // ... packed storage, String-Tabellen und FList werden mit Free freigegeben
  inherited Destroy;
end;

Der Fehlerpunkt ist präzise. Die Heap-Allokationen während der Konstruktion gezählt, ist die erste die Instanz selbst; scheitert NewInstance, läuft kein Konstruktorkörper und es wird kein Destruktor gerufen, dieser Fall ist also immer sauber. Die zweite Allokation ist die TList. Scheitert sie, bleibt FRuns oder FList nil, die RTL ruft Destroy, und ein ungesicherter Destroy liest Count durch eine nil-Referenz, was exakt die sekundäre Access Violation ist. Spätere Fehler innerhalb von TXLSXCells.Create sind harmlos, denn sobald FList existiert, prüft Clear FPackedStorage <> nil, bevor es den Packed-Speicher verwendet

Allokationsreihenfolge-Karte für HotXLS-Konstruktoren in Delphi: ein gescheitertes NewInstance ist sauber, ein gescheitertes TList.Create lässt das Feld nil, spätere Fehler bleiben gesichert
nur die zweite Allokation zählt: das TList-Scheitern lässt das Feld nil, und ein ungesicherter Destroy macht aus EOutOfMemory die Access Violation

Diese Klassen zählen, weil sie in Masse erzeugt werden. Jedes geladene Worksheet baut eine TXLSXCells-Sammlung, und jede Zelle oder jeder Kommentar mit formatierten Runs baut ein TXLSXRichText. Ein großes Workbook mit viel Rich Text erzeugt tausende davon, also hat die Allokation, die schließlich scheitert, unter Speicherdruck eine reale Chance, auf einer dieser Zeilen zu landen

Free ist nil-sicher, Feldzugriff nicht

TObject.Free prüft Self <> nil, bevor es Destroy ruft, also ist FRuns.Free auf einem nil-Feld ein No-op. FRuns.Count zu lesen ist das nicht: Count liest ein Feld an einem Offset vom Instanzzeiger, und ein virtueller Aufruf wie FRuns.Clear liest zuerst den VMT-Zeiger aus der Instanz. Beide krachen bei nil. Diese Asymmetrie ist die ganze Falle. Die Freigabe-Aufrufe in einem typischen Destruktor sind bereits sicher; die Schleifen und Aufräumaufrufe davor sind der Ort, an dem der Ärger wohnt. Hier das Muster in einer kleinen eigenen Klasse:

type
  TStyledText = class
  private
    FRuns: TList;
    FIndex: TStringList;
  public
    constructor Create;
    destructor Destroy; override;
  end;

constructor TStyledText.Create;
begin
  inherited Create;
  FRuns := TList.Create;          // EOutOfMemory hier lässt FRuns = nil zurück
  FIndex := TStringList.Create;   // ... und hier lässt FIndex = nil zurück
end;

destructor TStyledText.Destroy;   // SCHLECHT: nimmt an, der Konstruktor sei fertig
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // Access Violation, wenn FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

Der korrigierte Destruktor sichert jede Dereferenzierung ab und gibt Felder nur über nil-sichere Aufrufe frei:

destructor TStyledText.Destroy;
var
  I: Integer;
begin
  if FRuns <> nil then
    for I := 0 to FRuns.Count - 1 do
      TObject(FRuns[I]).Free;
  FreeAndNil(FIndex);
  FreeAndNil(FRuns);
  inherited Destroy;
end;

Die Regeln, die daraus fallen, sind kurz genug für die Anwendung im Code-Review:

  • Jede Verwendung eines besessenen Felds in einem Destruktor, außer Free oder FreeAndNil, braucht eine nil- oder Assigned-Prüfung, inklusive Aufrufe in Helfer wie Clear oder ClearCore, die das Feld durchlaufen
  • Gib besessene Objekte mit Free oder FreeAndNil frei, nie durch direkten Destroy-Aufruf, der die nil-Prüfung überspringt
  • Halte inherited Destroy als letzte Anweisung, damit der Vorfahre seine eigenen Felder abreißt, nachdem deine weg sind
  • Schalte nicht von der Feldreihenfolge im Konstruktor her; der nächste Maintainer wird eine Allokation über deine einfügen und den Fehlerpunkt stillschweigend verschieben

Wo inherited Create hingehört

Rufe inherited Create zuerst auf, wie beide HotXLS-Konstruktoren es tun. Die Felder des Vorfahren existieren dann, bevor dein Code läuft, und das Spiegelbild im Destruktor, inherited Destroy zuletzt, wickelt in umgekehrter Reihenfolge ab. Wirft der Vorfahren-Konstruktor selbst, läuft dein Destruktor dennoch mit jedem deiner Felder auf nil, was ein nil-sicherer Destruktor ohne Sonderfälle handhabt. Explizite Zuweisungen wie FOwner := nil in einem Konstruktor sind nach InitInstance redundant, aber harmlos und manchmal als Dokumentation wert aufzuheben

Nicht im Konstruktor fangen und freigeben

Zwei gut gemeinte Muster machen es schlimmer. Das erste ist, den Konstruktorkörper in try ... except Free; raise; end zu wickeln: Die RTL ruft den Destruktor ohnehin, das Objekt wird also zweimal zerstört. Das zweite ist, die Exception zu verschlucken, um "weiterzumachen": Der Aufrufer erhält dann eine gültig aussehende Referenz auf ein Objekt mit nil-Feldern, und der Fehler wandert zu irgendeinem unzusammenhängenden Methodenaufruf viel später. Erzeugt ein Konstruktor ein Temporäres, das er nicht behält, gib jenes Temporäre in einem lokalen try ... finally frei und lass die Exception unangetastet nach oben weiterlaufen

Wie testest du Konstruktor-Fehlerpfade?

Lass jeden Allokationspunkt in einem Konstruktor absichtlich scheitern, einen nach dem anderen, und verlange jedes Mal EOutOfMemory, gefolgt von einer erfolgreichen normalen Konstruktion. Das Werkzeug dafür ist ein Ersatz-Memory-Manager, dessen GetMem, AllocMem und ReallocMem Anfragen positiver Größe zählen und an einer gewählten Ordinalzahl nil liefern. Zähle zuerst die Allokationen, die ein Konstruktor auf deinem Zielbuild macht, dann fahre je Ordinalzahl einen injizierten Fehler

Konstruiere nach jedem injizierten Fehler dieselbe Klasse erneut mit restauriertem Allokator und übe sie aus: Bei einer Rich-Text-Klasse füge einen Run hinzu, setze seinen Text und bestätige, dass der Plain-Text wie erwartet zurückkommt. Normale Lebenszyklus-Prüfungen gehören daneben, etwa ein Workbook mit formatierten Runs und Kommentaren in eine Datei und in einen Stream zu sichern, das Quell-Workbook freizugeben und beide Kopien wiederzuöffnen und zu vergleichen

Allokationszählungen gehören zu einem Compiler und einer Plattform; ein anderer Compiler oder eine andere RTL kann anders allokieren, also miss auf dem Build, den du auslieferst. HotXLS wendet dieselbe nil-sichere Regel bibliotheksweit an, von Style-, Chart- und Pivot-Sammlungen bis zu Hash-Tabellen, Zip-Writer und PDF-Exporter. Hash-Tabellen verdienen eine eigene Notiz: Eine Tabelle, die ihre Maske ändert, bevor eine dann scheiternde Reallokation kommt, lässt ihren Destruktor über die alte Tabelle hinaus in den Speicher laufen, die Maske darf sich also erst ändern, nachdem der neue Speicher existiert. Ein Fall sitzt in der RTL selbst: TInterfaceList verunglückt in ihrem Destruktor, wenn ihre interne Allokation scheitert. Kein Teil davon ist ein Rollback einer beliebigen Operation, die halb scheitert, und es ist keine allgemeine Out-of-Memory-Wiederherstellungsgarantie

Was ändert das für Code, der große Workbooks öffnet?

In der Praxis ist EOutOfMemory aus diesen Konstruktionspfaden ein Signal, das du fangen und beantworten kannst, statt einer Access Violation, die du als Crash behandeln musst. TXLSXWorkbook.Create nimmt keine Parameter entgegen, und TXLSXWorkbook.Open(const FileName: WideString): Integer liefert 1 bei Erfolg und -1 bei Scheitern. Exceptions, die beim Laden geworfen werden, werden in der Diagnostics-Sammlung des Workbooks als xlsDiagnosticUnhandledException vermerkt und dann erneut geworfen, das Verhalten, das der Artikel zu HotXLS-Structured-Diagnostics beschreibt. Ein Retry-Wrapper sieht so aus:

type
  EWorkbookOpenFailed = class(Exception);

function OpenWorkbookWithRetry(const FileName: WideString;
  MaxAttempts: Integer): TXLSXWorkbook;
var
  Attempt: Integer;
  Book: TXLSXWorkbook;
begin
  Result := nil;
  for Attempt := 1 to MaxAttempts do
  begin
    Book := nil;
    try
      try
        Book := TXLSXWorkbook.Create;          // keine Konstruktorargumente
        if Book.Open(FileName) <> 1 then        // 1 = Erfolg, -1 = Fehler
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // Besitz geht an den Aufrufer über
        Exit;
      finally
        Book.Free;                             // nil-sicher nach einer Übergabe
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // dein Code: Caches und andere Books fallen lassen
      end;
    end;
  end;
end;

Ein paar Details in jenem Wrapper sind bewusst. Jeder Versuch baut ein frisches TXLSXWorkbook, denn nil-sichere Destruktoren machen Konstruktionsfehler sauber, aber machen ein halbfertiges Open nicht fortsetzbar, also wird die gescheiterte Instanz schlicht freigegeben. Nur EOutOfMemory wird wiederholt; EWorkbookOpenFailed, Passwortfehler und alles andere pflanzen sich beim ersten Versuch fort. Eine EAccessViolation sollte gar nie wiederholt werden, sie signalisiert einen Defekt statt einer Ressourcenknappheit. Und der Retry ergibt nur Sinn, wenn sich zwischen den Versuchen etwas ändert, weshalb der Handler zuerst Speicher freigibt. Brauchst du die Diagnose-Spur des gescheiterten Versuchs, lies Book.Diagnostics, bevor der finally-Block sie freigibt

Retry-Entscheidungsmatrix in Delphi mit HotXLS: EOutOfMemory wird mit frischem TXLSXWorkbook wiederholt, andere Fehler pflanzen sich sofort fort, eine Access Violation wird nie wiederholt
jeder Versuch baut ein frisches Workbook, denn ein halbfertiges Open ist nicht fortsetzbar, und nur EOutOfMemory verdient sich je einen zweiten Versuch

Wiederholen ist eine Abmilderung, kein Kapazitätsplan. Einem 32-Bit-Prozess, der bei großen Dateien ständig auf EOutOfMemory läuft, mangelt es meist an zusammenhängendem Adressraum statt an physischem Speicher, und die dauerhafte Antwort ist ein Win64-Build. Dasselbe Denken gilt auf der Schreibseite, wo HotXLS-crash-safe-Speicherungen mit gestaffelten Temp-Dateien ein gescheitertes Sichern davon abhalten, die bestehende Datei zu beschädigen. Teilen mehrere Threads ein Workbook, beschreiben HotXLS-Read-Leases und Write-Guards, wie der Zugriff auf jene Instanz koordiniert wird

Eine Checkliste für deine eigenen Delphi-Klassen

Die Sicherungen selbst sind je eine Zeile, und dieselbe Prüfung dauert Minuten an deinem eigenen Code. Suche nach Destruktoren, die eine Clear-, Reset- oder ClearCore-Methode rufen, bevor sie Felder freigeben, und prüfe dann, ob jene Methode ein Feld anfasst, das der Konstruktor vielleicht nie erreicht hat. Suche nach Schleifen über besessene Listen, denen keine nil-Prüfung vorangeht. Suche nach Konstruktoren, die Exceptions fangen, und entferne sowohl das Verschlucken als auch das manuelle Free. Willst du Beweis statt Sichtprüfung, passt der oben beschriebene zählende Memory-Manager auf eine Seite Code und macht aus jeder Allokation in einem Konstruktor einen Testfall

Für den vollen Funktionsumfang, unterstützte Delphi- und C++Builder-Versionen und Lizenzierung siehe die Produktseite des HotXLS Delphi Excel component