Technisch artikel

Delphi constructor-exceptions en nil-safe destructors

Gooit een Delphi-constructor een exception, dan roept de RTL onmiddellijk de destructor aan op de half afgebouwde instantie, dus elke destructor moet omgaan met velden die nooit zijn toegewezen. HotXLS past die regel toe op zijn destructors, TXLSXRichText en TXLSXCells incluis: een allocatiefout binnen die constructors komt bij jouw code aan als de originele EOutOfMemory, niet als een secundaire access violation die tijdens de opruiming wordt gegooid

De bugcategorie is oud en makkelijk te missen. Een service converteert een workbook van 300 MB op een drukke server, het geheugen raakt op, en het log toont een access violation in een destructor. Niets aan die melding zegt "geen geheugen meer", niets suggereert dat later opnieuw proberen zou werken, en de stack trace wijst naar een destructor die er perfect correct uitziet. De originele exception bestond een paar microseconden en werd toen vervangen. Dit artikel loopt door waarom dat gebeurt, hoe HotXLS zijn destructors opbouwt om het te vermijden en hoe je je eigen klassen schrijft zodat ze het nooit doen

Wat gebeurt er wanneer een Delphi-constructor een exception gooit?

Het object wordt voor je vernietigd voordat de exception de aanroeper bereikt, en je destructor draait tegen welke staat de constructor ook heeft bereikt. De volgorde ligt vast bij de compiler en de RTL. NewInstance alloceert de instantie en InitInstance vult haar met nullen, dus elke objectverwijzing begint als nil, elke integer als 0 en elke string als leeg. De constructor-body draait daarna binnen een impliciete exception-handler. Als er iets in die body gooit, roept de gegenereerde code de virtuele destructor aan: BeforeDestruction draait, dan jouw Destroy, dan geeft FreeInstance het geheugen vrij en ten slotte wordt de exception opnieuw gegooid. AfterConstruction draait nooit, en de variabele links van X := TFoo.Create wordt nooit toegewezen

Delphi-objectlevenscyclus die laat zien hoe de RTL Destroy aanroept op een half af HotXLS-instantie wanneer een constructor gooit, voordat de originele exception de aanroeper bereikt
gooit een Delphi-constructor, dan vernietigt de RTL eerst de half afgebouwde instantie en gooit pas daarna de originele exception opnieuw, dus Destroy draait tegen welke staat de constructie ook bereikte

De destructor ziet dus een object dat op een willekeurige regel van zijn constructor stopte. Velden die vóór de fout waren toegewezen bevatten echte objecten. Velden erna zijn nog nil. Een destructor geschreven onder de aanname dat de constructie afrondde, zal een van die nil-velden dereferrer en een access violation van eigen makelij gooien

Die tweede exception is de echte schade. Delphi ketent exceptions die tijdens deze opruiming worden gegooid niet: de access violation vervangt de EOutOfMemory, de originele exception wordt weggegooid en de aanroeper ziet alleen de AV. De nuttige diagnose, "een allocatie faalde, probeer het opnieuw met meer geheugen", is verdwenen, en op die plek staat iets dat lijkt op geheugencorruptie

Waar komt de secundaire access violation vandaan?

Hij komt uit een destructor die ervan uitgaat dat zijn constructor afrondde en een lijstveld aanraakt dat nil is wanneer de allereerste interne allocatie faalt. Twee HotXLS-klassen laten de vorm duidelijk zien. Zowel de standalone TXLSXRichText.Create als de door een comment bezeten overload TXLSXRichText.Create(AOwner: TXLSXComment) roepen inherited Create aan en alloceren daarna FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) doet hetzelfde met FList := TList.Create voordat hij zijn packed-string-tabellen en packed cell storage opbouwt. In Lib/lxHandleX.pas bewaken de twee destructors die velden:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // de constructor kan zijn gestopt vóór FRuns
  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 is nil als TList.Create faalde
  // ... packed storage, stringtabellen en FList worden met Free vrijgegeven
  inherited Destroy;
end;

Het faalpunt ligt precies vast. Geteld naar heapallocaties tijdens de constructie is de eerste de instantie zelf; faalt NewInstance, dan draait geen constructor-body en wordt geen destructor aangeroepen, dus dat geval is altijd netjes. De tweede allocatie is de TList. Faalt die, dan blijft FRuns of FList nil, roept de RTL Destroy aan en leest een onbewaakte Destroy Count via een nil-verwijzing, wat precies de secundaire access violation is. Latere fouten binnen TXLSXCells.Create zijn ongevaarlijk, want zodra FList bestaat, controleert Clear FPackedStorage <> nil voordat hij de packed storage gebruikt

Allocatievolgordekaart voor HotXLS-constructors in Delphi: een falende NewInstance is netjes, een falende TList.Create laat het veld nil en latere fouten blijven bewaakt
alleen de tweede allocatie telt: de TList-fout laat het veld nil en een onbewaakte Destroy is wat EOutOfMemory in een access violation verandert

Deze klassen zijn belangrijk omdat ze in bulk worden aangemaakt. Elke geladen worksheet bouwt een TXLSXCells-collectie en elke cel of comment met formatted runs bouwt een TXLSXRichText. Een groot workbook met veel rich text maakt er duizenden, dus onder geheugendruk heeft de allocatie die uiteindelijk faalt een reële kans om op een van deze regels te landen

Free is nil-safe, veldtoegang niet

TObject.Free controleert Self <> nil voordat hij Destroy aanroept, dus FRuns.Free op een nil-veld is een no-op. FRuns.Count lezen is dat niet: Count leest een veld op een offset vanaf de instantiepointer en een virtuele aanroep zoals FRuns.Clear leest eerst de VMT-pointer uit de instantie. Beide gaan op nil in de fout. Die asymmetrie is de hele valkuil. De vrijgeef-aanroepen in een typische destructor zijn al veilig; de lussen en opruimaanroepen die ervoor staan, zijn waar de ellende woont. Hier is het patroon in een klein klasseje van jezelf:

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 laat FRuns = nil
  FIndex := TStringList.Create;   // ... en hier laat FIndex = nil
end;

destructor TStyledText.Destroy;   // FOUT: gaat ervan uit dat de constructor afrondde
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // access violation wanneer FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

De gecorrigeerde destructor bewaakt elke dereferentie en geeft velden alleen via nil-safe aanroepen vrij:

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;

De regels die hieruit volgen zijn kort genoeg om in code review toe te passen:

  • Elk gebruik van een bezeten veld in een destructor, anders dan Free of FreeAndNil, heeft een nil- of Assigned-controle nodig, inclusief aanroepen naar helpers zoals Clear of ClearCore die over het veld itereren
  • Geef bezeten objecten vrij met Free of FreeAndNil, nooit door rechtstreeks Destroy aan te roepen, wat de nil-test overslaat
  • Houd inherited Destroy als laatste statement, zodat de voorouder zijn eigen velden afbouwt nadat de jouwe weg zijn
  • Redeneer niet vanuit de veldvolgorde in de constructor; de volgende onderhouder plakt een allocatie boven die van jou en schuift stilletjes het faalpunt op

Waar inherited Create thuishoort

Roep inherited Create eerst aan, zoals beide HotXLS-constructors doen. De velden van de voorouder bestaan dan voordat jouw code draait, en het spiegelbeeld in de destructor, inherited Destroy als laatste, werkt in omgekeerde volgorde af. Gooit de voorouderconstructor zelf een exception, dan draait jouw destructor nog steeds met al jouw velden op nil, wat een nil-safe destructor zonder speciale gevallen aankan. Expliciete toewijzingen zoals FOwner := nil in een constructor zijn na InitInstance overbodig, maar onschadelijk en soms de moeite waard als documentatie

Niet catchen en vrijgeven binnen de constructor

Twee goedbedoelende patronen maken het erger. Het eerste is de constructor-body in try ... except Free; raise; end wikkelen: de RTL gaat toch al de destructor aanroepen, dus het object wordt twee keer vernietigd. Het tweede is de exception inslikken om "door te gaan": de aanroeper krijgt dan een plausibel ogende verwijzing naar een object met nil-velden, en de fout verschuift naar een volkomen ongerelateerde methodeaanroep veel later. Maakt een constructor een tijdelijke die hij niet houdt, geef die tijdelijke dan vrij in een lokale try ... finally en laat de exception onaangetast omhoog lopen

Hoe test je constructor-faalpaden?

Laat elk allocatiepunt in een constructor met opzet falen, één voor één, en eist elke keer EOutOfMemory, gevolgd door een geslaagde normale constructie. Het gereedschap daarvoor is een vervangende memory manager waarvan GetMem, AllocMem en ReallocMem aanvragen met positieve grootte tellen en op een gekozen volgnummer nil teruggeven. Tel eerst hoeveel allocaties een constructor op je doelbuild maakt en draai dan één geïnjecteerde fout per volgnummer

Bouw na elke geïnjecteerde fout dezelfde klasse opnieuw met de herstelde allocator en oefen hem uit: voeg bij een rich text-klasse een run toe, zet zijn tekst en controleer dat de platte tekst zoals verwacht teruggkomt. Normale levenscycluscontroles horen ernaast, zoals een workbook met formatted runs en comments naar een bestand en naar een stream opslaan, het bronworkbook vrijgeven en beide kopieën opnieuw openen en vergelijken

Allocatietellingen horen bij één compiler en platform; een andere compiler of RTL kan anders alloceren, dus meet op de build die je uitlevert. HotXLS past dezelfde nil-safe regel toe over de hele bibliotheek, van style-, chart- en pivot-collecties tot de hash-tabellen, de zip writer en de PDF-exporter. Hash-tabellen verdienen een eigen kanttekening: een tabel die zijn masker wijzigt vóór een herallocatie die dan faalt, laat zijn destructor door geheugen voorbij de oude tabel lopen, dus het masker mag pas wijzigen nadat de nieuwe opslag bestaat. Eén geval zit in de RTL zelf: TInterfaceList gaat in zijn destructor in de fout wanneer zijn interne allocatie faalt. Geen van dit alles is een rollback van een willekeurige operatie die halverwege faalt, en het is geen algemene out-of-memory-herstelgarantie

Wat verandert dit voor code die grote workbooks opent?

In de praktijk is EOutOfMemory uit deze constructiepaden een signaal dat je kunt vangen en op kunt handelen, in plaats van een access violation die je als crash moet behandelen. TXLSXWorkbook.Create neemt geen parameters en TXLSXWorkbook.Open(const FileName: WideString): Integer geeft 1 terug bij succes en -1 bij mislukking. Exceptions die tijdens het laden worden gegooid, worden vastgelegd in de Diagnostics-collectie van het workbook als xlsDiagnosticUnhandledException en daarna opnieuw gegooid, gedrag dat wordt beschreven in het HotXLS-artikel over structured diagnostics. Een retry-wrapper ziet er zo uit:

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;          // geen constructorargumenten
        if Book.Open(FileName) <> 1 then        // 1 = succes, -1 = mislukking
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // ownership gaat naar de aanroeper
        Exit;
      finally
        Book.Free;                             // nil-safe na een hand-off
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // jouw code: caches en andere books laten vallen
      end;
    end;
  end;
end;

Een paar details in die wrapper zijn bewust. Elke poging bouwt een frisse TXLSXWorkbook, want nil-safe destructors maken constructiefouten netjes maar maken een half afgeronde Open niet hervatbaar, dus de mislukte instantie wordt simpelweg vrijgegeven. Alleen EOutOfMemory wordt opnieuw geprobeerd; EWorkbookOpenFailed, wachtwoordfouten en al het andere propageren bij de eerste poging. Een EAccessViolation moet überhaupt nooit opnieuw worden geprobeerd, want die wijst op een defect, niet op een tekort aan resources. En de retry heeft alleen zin als er tussen pogingen iets verandert, en daarom geeft de handler eerst geheugen vrij. Heb je de diagnosesporen van de mislukte poging nodig, lees dan Book.Diagnostics voordat het finally-blok haar vrijgeeft

Retry-beslissingsmatrix in Delphi met HotXLS: EOutOfMemory wordt opnieuw geprobeerd met een frisse TXLSXWorkbook, andere fouten propagageren direct en een access violation wordt nooit opnieuw geprobeerd
elke poging bouwt een frisse workbook omdat een half afgeronde Open niet hervatbaar is en alleen EOutOfMemory verdient ooit een tweede poging

Opnieuw proberen is een mitigatie, geen capaciteitsplan. Een 32-bit proces dat bij grote bestanden steeds tegen EOutOfMemory aanloopt, mist meestal aaneengesloten adresruimte in plaats van fysiek geheugen, en het duurzame antwoord is een Win64-build. Hetzelfde denkpatroon geldt aan de schrijfkant, waar crash-safe saves van HotXLS met staged temp files voorkomen dat een mislukte opslag het bestaande bestand beschadigt. Delen meerdere threads één workbook, dan beschrijven de read leases en write guards van HotXLS hoe de toegang tot die instantie wordt gecoördineerd

Een checklist voor je eigen Delphi-klassen

De bewakingen zelf zijn per stuk één regel, en dezelfde audit kost op je eigen code enkele minuten. Zoek destructors die vóór het vrijgeven van velden een methode als Clear, Reset of ClearCore aanroepen, en controleer dan of die methode een veld aanraakt dat de constructor misschien niet heeft bereikt. Zoek lussen over bezeten lijsten die niet worden voorafgegaan door een nil-controle. Zoek constructors die exceptions vangen en verwijder zowel het inslikken als de handmatige Free. Wil je bewijs in plaats van inspectie, dan past de tellende memory manager van hierboven op één pagina code en maakt hij van elke allocatie in een constructor een testcase

Voor de volledige functieset, de ondersteunde Delphi- en C++Builder-versies en licenties, zie de productpagina van de HotXLS Delphi Excel-component