Teknisk artikel

Delphi constructor-exceptions og nil-sikre destructors

Når en Delphi constructor udløser en exception, kalder RTL'en straks destructoren på den halvbyggede instans, så hver destructor skal kunne håndtere felter, der aldrig blev tildelt. HotXLS anvender den regel på sine destructors, TXLSXRichText og TXLSXCells inkluderet: en allokeringsfejl inde i de constructors når din kode som den oprindelige EOutOfMemory, ikke som en sekundær access violation udløst under oprydningen

Fejlklassen er gammel og let at overse. En service konverterer en 300 MB workbook på en travl server, hukommelsen løber tør, og loggen viser en access violation inde i en destructor. Intet ved den besked siger "out of memory", intet antyder, at et forsøg senere ville lykkes, og stack trace'en peger på en destructor, der ser helt korrekt ud. Den oprindelige exception eksisterede i et par mikrosekunder og blev så erstattet. Denne artikel gennemgår, hvorfor det sker, hvordan HotXLS strukturerer sine destructors for at undgå det, og hvordan du skriver dine egne klasser, så de aldrig gør det

Hvad sker der, når en Delphi constructor udløser en fejl?

Objektet destrueres for dig, før exceptionen når den kaldende, og din destructor køres mod den tilstand, constructoren nåede. Sekvensen er fastlagt af compileren og RTL'en. NewInstance allokerer instansen og InitInstance nulfylder den, så hver objektreference starter som nil, hvert heltal som 0 og hver streng som tom. Constructor-body'en køres derefter inde i en implicit exception-handler. Udløser noget i den body en fejl, kalder den genererede kode den virtuelle destructor: BeforeDestruction køres, så din Destroy, så FreeInstance frigiver hukommelsen, og til sidst udløses exceptionen igen. AfterConstruction køres aldrig, og variablen på venstre side af X := TFoo.Create tildeles aldrig

Delphi-objektlivscyklus, der viser, hvordan RTL'en kalder Destroy på en halvbygget HotXLS-instans, når en constructor udløser en fejl, før den oprindelige exception når den kaldende
udløser en Delphi constructor en fejl, destruerer RTL'en først den halvbyggede instans og udløser først derefter den oprindelige exception igen, så Destroy køres mod den tilstand, konstruktionen nåede

Destructoren ser altså et objekt, der stoppede ved en vilkårlig linje i sin constructor. Felter tildelt før fejlen holder rigtige objekter. Felter efter den er stadig nil. En destructor skrev ud fra den antagelse, at konstruktionen blev færdig, vil dereferere et af de nil-felter og udløse sin egen access violation

Den anden exception er den reelle skade. Delphi kæder ikke exceptions, der udløses under denne oprydning: access violationen erstatter EOutOfMemory, den oprindelige exception kasseres, og den kaldende ser kun AV'en. Den nyttige diagnose, "en allokering fejlede, prøv igen med mere hukommelse", er væk, og i stedet er der noget, der ligner hukommelseskorruption

Hvor kommer den sekundære access violation fra?

Den kommer fra en destructor, der antager, at dens constructor blev færdig, og rører et listfelt, der er nil, når den allerførste interne allokering fejler. To HotXLS-klasser viser formen tydeligt. Både den selvstændige TXLSXRichText.Create og den comment-ejede overload TXLSXRichText.Create(AOwner: TXLSXComment) kalder inherited Create og allokerer derefter FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) gør det samme med FList := TList.Create, før den bygger sine packed-string-tabeller og packed cellelager. I Lib/lxHandleX.pas sikrer de to destructors de felter:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // constructoren kan være stoppet, fø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 er nil, hvis TList.Create fejlede
  // ... packed storage, strengtabeller og FList frigives med Free
  inherited Destroy;
end;

Fejlpunktet er præcist. Tæller du heap-allokeringer under konstruktionen, er den første instansen selv; fejler NewInstance, køres ingen constructor-body, og ingen destructor kaldes, så det tilfælde er altid rent. Den anden allokering er TList. Fejler den, forbliver FRuns eller FList nil, RTL'en kalder Destroy, og en usikret Destroy læser Count gennem en nil-reference, hvilket er præcis den sekundære access violation. Senere fejl inde i TXLSXCells.Create er harmløse, for når FList findes, tjekker Clear FPackedStorage <> nil, før det packede lager bruges

Allokeringsrækkefølge-kort for HotXLS-constructors i Delphi: en fejlet NewInstance er ren, en fejlet TList.Create efterlader feltet nil, senere fejl forbliver sikret
kun den anden allokering betyder noget: TList-fejlen efterlader feltet nil, og en usikret Destroy er det, der forvandler EOutOfMemory til en access violation

Disse klasser betyder noget, for de oprettes i bulk. Hvert loadet worksheet bygger en TXLSXCells-samling, og hver celle eller comment med formaterede runs bygger en TXLSXRichText. En stor workbook med tung rich text opretter tusindvis af dem, så under hukommelsestryk har den allokering, der til sidst fejler, en reel chance for at lande på én af disse linjer

Free er nil-sikker, feltadgang er ikke

TObject.Free tjekker Self <> nil, før den kalder Destroy, så FRuns.Free på et nil-felt er en no-op. At læse FRuns.Count er ikke: Count læser et felt på en offset fra instanspointeren, og et virtuelt kald som FRuns.Clear læser først VMT-pointeren fra instansen. Begge fejler på nil. Den asymmetri er hele fælden. Frigivelseskaldene i en typisk destructor er allerede sikre; løkkerne og oprydningskaldene placeret før dem er der, hvor problemerne bor. Her er mønsteret i en lille klasse af din egen:

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 her efterlader FRuns = nil
  FIndex := TStringList.Create;   // ... og her efterlades FIndex = nil
end;

destructor TStyledText.Destroy;   // DÅRLIGT: antager, at constructoren blev færdig
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // access violation når FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

Den korrigerede destructor sikrer hver dereference og frigiver felter kun gennem nil-sikre kald:

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 regler, der falder ud af det, er korte nok til at bruge i kode review:

  • Enhver brug af et ejet felt i en destructor, ud over Free eller FreeAndNil, behøver et nil- eller Assigned-tjek, inklusive kald ind i hjælpere som Clear eller ClearCore, der itererer feltet
  • Frigiv ejede objekter med Free eller FreeAndNil, aldrig ved at kalde Destroy direkte, hvilket springer nil-tjekket over
  • Hold inherited Destroy som den sidste sætning, så forfaderen river sine egne felter ned efter dine er væk
  • Resonnér ikke ud fra feltrækkefølgen i constructoren; den næste vedligeholder indsætter en allokering over din og flytter lydløst fejlpunktet

Hvor inherited Create hører hjemme

Kald inherited Create først, som begge HotXLS-constructors gør. Forfaderens felter findes så, før din kode kører, og destructorens spejlbillede, inherited Destroy sidst, vikles op i omvendt rækkefølge. Udløser forfader-constructoren selv en fejl, kører din destructor stadig med alle dine felter nil, hvilket en nil-sikker destructor håndterer uden specialtilfælde. Eksplicitte tildelinger som FOwner := nil i en constructor er overflødige efter InitInstance, men harmløse og nogle gange værd at beholde som dokumentation

Fang ikke og frigiv ikke inde i constructoren

To velmente mønstre gør det værre. Det første er at pakke constructor-body'en ind i try ... except Free; raise; end: RTL'en kommer allerede til at kalde destructoren, så objektet destrueres to gange. Det andet er at synke exceptionen for at "komme videre": den kaldende modtager så en plausibel reference til et objekt med nil-felter, og fejlen flytter sig til et helt urelateret metodekald meget senere. Opretter en constructor en midlertidig, den ikke beholder, så frigiv den midlertidige i en lokal try ... finally og lad exceptionen fortsætte opad urørt

Hvordan tester man constructor-fejlveje?

Fejl hvert allokeringsspunkt i en constructor med vilje, ét ad gangen, og kræv EOutOfMemory hver gang, efterfulgt af en succesfuld normal konstruktion. Værktøjet til det er en erstattende memory manager, hvis GetMem, AllocMem og ReallocMem tæller anmodninger med positiv størrelse og returnerer nil ved et valgt nummer. Tæl først de allokeringer, en constructor laver på dit target-build, og kør så én injiceret fejl pr. nummer

Efter hver injiceret fejl, konstruér samme klasse igen med allocatoren gendannet og brug den: til en rich text-klasse, tilføj et run, sæt dets tekst og bekræft, at den rene tekst kommer tilbage som forventet. Normale livscyklus-tjek hører ved siden af, som at gemme en workbook med formaterede runs og comments til fil og stream, frigive kilde-workbooken og genåbne og sammenligne begge kopier

Allokeringsantal gælder én compiler og platform; en anden compiler eller RTL kan allokere anderledes, så mål på det build, du udsender. HotXLS anvender samme nil-sikre regel på tværs af biblioteket, fra style-, chart- og pivot-samlinger til hash-tabellerne, zip-writeren og PDF-eksportøren. Hash-tabeller fortjener en specifik bemærkning: en tabel, der skifter maske før en reallokering, der så fejler, efterlader sin destructor til at gå i hukommelsen forbi den gamle tabel, så masken må først skifte, efter det nye lager findes. Ét tilfælde sidder i RTL'en selv: TInterfaceList fejler i sin destructor, når dens interne allokering fejler. Intet af dette udgør rollback af en vilkårlig operation, der fejler halvvejs, og det er ikke en generel out-of-memory-gendannelsesgaranti

Hvad ændrer det for kode, der åbner store workbooks?

I praksis er EOutOfMemory fra disse konstruktionsveje et signal, du kan fange og handle på, i stedet for en access violation, du er nødt til at behandle som et crash. TXLSXWorkbook.Create tager ingen parametre, og TXLSXWorkbook.Open(const FileName: WideString): Integer returnerer 1 ved succes og -1 ved fejl. Exceptions, der udløses under indlæsning, noteres i workbookens Diagnostics-samling som xlsDiagnosticUnhandledException og udløses derefter igen, hvilket er adfærden beskrevet i HotXLS-artiklen om struktureret diagnostik. En retry-wrapper ser sådan ud:

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;          // ingen constructor-argumenter
        if Book.Open(FileName) <> 1 then        // 1 = succes, -1 = fejl
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // ejerskabet går til den kaldende
        Exit;
      finally
        Book.Free;                             // nil-sikker efter en overdragelse
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // din kode: dropp caches, andre books
      end;
    end;
  end;
end;

Et par detaljer i den wrapper er bevidste. Hvert forsøg bygger en frisk TXLSXWorkbook, for nil-sikre destructors gør konstruktionsfejl rene, men gør ikke en halvfærdig Open genoptagelig, så den fejlede instans frigives simpelthen. Kun EOutOfMemory genprøves; EWorkbookOpenFailed, passwordfejl og alt andet propagerer ved første forsøg. En EAccessViolation skal aldrig genprøves overhovedet, da den signalerer en defekt frem for en ressourcemangel. Og genprøvningen giver kun mening, hvis noget ændrer sig mellem forsøgene, og derfor frigiver handleren hukommelse først. Har du brug for diagnosessporet fra det fejlede forsøg, så læs Book.Diagnostics, før finally-blokken frigiver den

Genprøvnings-beslutningsmatrix i Delphi med HotXLS: EOutOfMemory genprøves med en frisk TXLSXWorkbook, andre fejl propagerer med det samme, en access violation genprøves aldrig
hvert forsøg bygger en frisk workbook, for en halvfærdig Open er ikke genoptagelig, og kun EOutOfMemory får nogensinde et andet forsøg

Genprøvning er en afbødning, ikke en kapacitetsplan. En 32-bit proces, der vedvarende rammer EOutOfMemory på store filer, mangler normalt sammenhængende adresserum frem for fysisk hukommelse, og den holdbare løsning er et Win64-build. Samme tankegang gælder på skrivesiden, hvor HotXLS crash-sikre gemninger med stagede temp-filer forhindrer en fejlet gemning i at beskadige den eksisterende fil. Deler flere tråde én workbook, beskriver HotXLS read leases og write guards, hvordan adgangen til den instans koordineres

En tjekliste til dine egne Delphi-klasser

Sikringerne selv er én linje hver, og samme audit tager minutter på din egen kode. Kig efter destructors, der kalder en Clear-, Reset- eller ClearCore-metode, før felter frigives, og tjek derefter, om den metode rører et felt, constructoren måske ikke nåede. Søg efter løkker over ejede lister uden et forudgående nil-tjek. Kig efter constructors, der fanger exceptions, og fjern både synkningen og den manuelle Free. Vil du have bevis i stedet for inspektion, passer den tællende memory manager beskrevet ovenfor på en side kode og gør hver allokering i en constructor til en testcase

Til det fulde featuresæt, understøttede Delphi- og C++Builder-versioner og licensering, se HotXLS Delphi Excel components productsiden