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
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
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
FreeofFreeAndNil, heeft eennil- ofAssigned-controle nodig, inclusief aanroepen naar helpers zoalsClearofClearCoredie over het veld itereren - Geef bezeten objecten vrij met
FreeofFreeAndNil, nooit door rechtstreeksDestroyaan te roepen, wat denil-test overslaat - Houd
inherited Destroyals 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
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