När en Delphi-konstruktor kastar ett exception anropar RTL:n omedelbart destructorn på den halvfärdiga instansen, så varje destructor måste hantera fält som aldrig tilldelades. HotXLS tillämpar den regeln på sina destructorer, TXLSXRichText och TXLSXCells inkluderade: ett allokeringfel inuti de konstruktorerna når din kod som det ursprungliga EOutOfMemory, inte som en sekundär access violation kastad under städningen
Den bugklassen är gammal och lätt att missa. En tjänst konverterar en 300 MB arbetsbok på en belastad server, minnet tar slut, och loggen visar en access violation inuti en destructor. Ingenting vid det meddelandet säger ”slut på minne”, ingenting antyder att ett försök senare skulle funka, och stack trace:n pekar på en destructor som ser helt korrekt ut. Det ursprungliga exceptionet existerade i några mikrosekunder och blev sedan utbytt. Den här artikeln går igenom varför det händer, hur HotXLS strukturerar sina destructorer för att undvika det, och hur du skriver dina egna klasser så att de aldrig gör det
Vad händer när en Delphi-konstruktor kastar?
Objektet förstörs åt dig innan exceptionet når anroparen, och din destructor körs mot vilket tillstånd konstruktorn än nått. Sekvensen är fixerad av kompilatorn och RTL:n. NewInstance allokerar instansen och InitInstance nollställer den, så varje objektreferens börjar som nil, varje heltal som 0 och varje sträng som tom. Konstruktorkroppen körs sedan inuti en implicit exceptionhanterare. Kastar något i den kroppen anropar den genererade koden den virtuella destructorn: BeforeDestruction körs, sedan din Destroy, sedan frigör FreeInstance minnet, och till sist kastas exceptionet på nytt. AfterConstruction körs aldrig, och variabeln på vänster sida om X := TFoo.Create tilldelas aldrig
Så destructorn ser ett objekt som stannade på en godtycklig rad i sin konstruktor. Fält tilldelade före felet håller riktiga objekt. Fält efter det är fortfarande nil. En destructor skriven i tron att konstruktionen blev klar kommer dereferera ett av de nil-fälten och kasta en access violation på egen hand
Det andra exceptionet är den verkliga skadan. Delphi kedjar inte exception kastade under den här städningen: access violationen ersätter EOutOfMemory, det ursprungliga exceptionet kastas bort, och anroparen ser bara AV:en. Den användbara diagnosen, ”en allokering misslyckades, försök igen med mer minne”, är borta, och i dess ställe finns något som ser ut som minneskorruption
Varifrån kommer den sekundära access violationen?
Den kommer från en destructor som antar att sin konstruktor blev klar och rör ett listfält som är nil när den allra första interna allokeringen misslyckas. Två HotXLS-klasser visar formen tydligt. Både fristående TXLSXRichText.Create och kommentarsägda overloaden TXLSXRichText.Create(AOwner: TXLSXComment) anropar inherited Create och allokerar sedan FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) gör samma sak med FList := TList.Create innan den bygger sina packed-strängtabeller och packed cell-lagring. I Lib/lxHandleX.pas bevakar de två destructorerna de fälten:
procedure TXLSXRichText.ClearCore;
var
i: Integer;
begin
if FRuns=nil then Exit; // konstruktorn kan ha stannat före 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 är nil om TList.Create misslyckades
// ... packed storage, strängtabeller och FList frigörs med Free
inherited Destroy;
end;
Felpunkten är precis. Räknar vi heap-allokeringar under konstruktionen är den första instansen själv; fallerar NewInstance körs ingen konstruktorkropp och ingen destructor anropas, så det fallet är alltid rent. Den andra allokeringen är TList:en. Fallerar den stannar FRuns eller FList som nil, RTL:n anropar Destroy, och en obevakad Destroy läser Count via en nil-referens, vilket är exakt den sekundära access violationen. Senare fel inuti TXLSXCells.Create är harmlösa, för när väl FList finns kontrollerar Clear FPackedStorage <> nil innan packed-lagringen används
De här klasserna spelar roll för de skapas i bulk. Varje laddat kalkylblad bygger en TXLSXCells-samling, och varje cell eller kommentar med formaterade runs bygger en TXLSXRichText. En stor arbetsbok med tung rich text skapar tusentals av dem, så under minnestryck har allokeringen som till slut fallerar en reell chans att landa på en av de raderna
Free är nil-säkert, fältåtkomst är det inte
TObject.Free kontrollerar Self <> nil innan Destroy anropas, så FRuns.Free på ett nil-fält är en no-op. Att läsa FRuns.Count är det inte: Count läser ett fält vid en offset från instanspekaren, och ett virtuellt anrop som FRuns.Clear läser först VMT-pekaren från instansen. Båda fallerar på nil. Den asymmetrin är hela fällan. Frigöringsanropen i en typisk destructor är redan säkra; looparna och städanropen före dem är där trubbel bor. Här är mönstret i en liten klass av 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 här lämnar FRuns = nil
FIndex := TStringList.Create; // ... och här lämnar FIndex = nil
end;
destructor TStyledText.Destroy; // DÅLIGT: antar att konstruktorn blev klar
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 korrigerade destructorn bevakar varje dereferens och frigör fält bara genom nil-säkra anrop:
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;
Reglerna som faller ut ur det här är korta nog att tillämpa i kodgranskning:
- Varje användning av ett ägt fält i en destructor, utöver
FreeellerFreeAndNil, behöver ennil- ellerAssigned-kontroll, inklusive anrop in i hjälpare somClearellerClearCoresom itererar fältet - Frigör ägda objekt med
FreeellerFreeAndNil, aldrig genom att anropaDestroydirekt, vilket hoppar övernil-testet - Behåll
inherited Destroysom sista sats, så förfadern river ner sina egna fält efter att dina är borta - Resonera inte utifrån fältordning i konstruktorn; nästa underhållare stoppar in en allokering ovanför din och flyttar tyst felpunkten
Var inherited Create hör hemma
Anropa inherited Create först, som båda HotXLS-konstruktorerna gör. Förfaderns fält finns då innan din kod körs, och destructorns spegelbild, inherited Destroy sist, viker ut i omvänd ordning. Kastar förfaderkonstruktorn själv körs din destructor ändå med vart och ett av dina fält som nil, vilket en nil-säker destructor hanterar utan specialfall. Explicita tilldelningar som FOwner := nil i en konstruktor är överflödiga efter InitInstance, men harmlösa och ibland värda att behålla som dokumentation
Fånga inte och frigör inte inuti konstruktorn
Två välmeninga mönster gör det här värre. Det första är att svepa in konstruktorkroppen i try ... except Free; raise; end: RTL:n ska ändå anropa destructorn, så objektet förstörs två gånger. Det andra är att svälja exceptionet för att ”komma vidare”: anroparen får då en trovärdigt seende referens till ett objekt med nil-fält, och felet flyttar till något orelaterat metodanrop mycket senare. Skapar en konstruktor en temporär den inte behåller, frigör den temporära i en lokal try ... finally och låt exceptionet fortsätta uppåt orört
Hur testar du konstruktorfelsvägar?
Få varje allokeringspunkt i en konstruktor att misslyckas med flit, en i taget, och kräv EOutOfMemory varje gång, följt av en lyckad normal konstruktion. Verktyget för det är en ersättningsminneshanterare vars GetMem, AllocMem och ReallocMem räknar anrop med positiv storlek och returnerar nil vid ett valt tal. Räkna först de allokeringar en konstruktor gör på din målbuild, och kör sedan ett injicerat fel per tal
Efter vart och ett injicerat fel, konstruera samma klass igen med allokeraren återställd och öva på den: för en rich text-klass, lägg till en run, sätt dess text, och bekräfta att klartexten kommer tillbaka som väntat. Normala livscykelkontroller hör dit bredvid, som att spara en arbetsbok med formaterade runs och kommentarer till en fil och till en ström, frigöra källarbetsboken, och öppna på nytt och jämföra båda kopiorna
Allokeringsantal gäller en kompilator och plattform; en annan kompilator eller RTL kan allokera annorlunda, så mät på den build du levererar. HotXLS tillämpar samma nil-säkra regel genom biblioteket, från style-, chart- och pivot-samlingar till hashtabellerna, zip-skrivaren och PDF-exportören. Hashtabeller förtjänar en särskild notis: en tabell som ändrar sin mask före en reallokering som sedan fallerar lämnar sin destructor att vandra i minnet förbi den gamla tabellen, så masken får bara ändras efter att den nya lagringen finns. Ett fall sitter i RTL:n själv: TInterfaceList fallerar i sin destructor när dess interna allokering misslyckas. Inget av det här utgör en återrullning av en godtycklig operation som fallerar halvvägs, och det är ingen allmän garanti för återhämtning ur minnesbrist
Vad ändrar det här för kod som öppnar stora arbetsböcker?
I praktiken är EOutOfMemory från de här konstruktionsvägarna en signal du kan fånga och agera på, i stället för en access violation du måste behandla som en krasch. TXLSXWorkbook.Create tar inga parametrar, och TXLSXWorkbook.Open(const FileName: WideString): Integer returnerar 1 vid framgång och -1 vid misslyckande. Exception kastade under laddningen registreras i arbetsbokens Diagnostics-samling som xlsDiagnosticUnhandledException och kastas sedan på nytt, vilket är beteendet som beskrivs i HotXLS-artikeln om strukturerad diagnostik. Ett retry-skal ser ut så här:
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; // inga konstruktorargument
if Book.Open(FileName) <> 1 then // 1 = framgång, -1 = misslyckande
raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
[string(FileName)]);
Result := Book;
Book := nil; // ägarskapet flyttar till anroparen
Exit;
finally
Book.Free; // nil-säkert efter en överlämning
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // din kod: släng cachar, andra böcker
end;
end;
end;
end;
Några detaljer i det skalet är medvetna. Varje försök bygger en färsk TXLSXWorkbook, för nil-säkra destructorer gör konstruktionsfel rena men gör inte ett halvfärdigt Open återupptagbart, så den fallerade instansen frigörs helt enkelt. Bara EOutOfMemory får försökas igen; EWorkbookOpenFailed, lösenordsfel och allt annat propagerar vid första försöket. En EAccessViolation ska aldrig försökas igen alls, eftersom den signalerar en defekt snarare än en resursbrist. Och retries funkar bara om något ändras mellan försöken, vilket är varför handlern frigör minne först. Behöver du diagnostikspåret från det fallerade försöket, läs Book.Diagnostics innan finally-blocket frigör det
Att försöka igen är en lindring, inte en kapacitetsplan. En 32-bitars process som ständigt stöter på EOutOfMemory på stora filer lider oftast av brist på sammanhängande adressrymd snarare än fysiskt minne, och den varaktiga svararen är en Win64-build. Samma tänk gäller på skrivsidan, där HotXLS kraschsäkra sparningar med stegade temporärfiler hindrar en fallerad sparning från att skada den befintliga filen. Delar flera trådar en arbetsbok beskriver HotXLS read leases och write guards hur åtkomst till den instansen koordineras
En checklista för dina egna Delphi-klasser
Bevakningarna själva är en rad styck, och samma granskning tar minuter på din egen kod. Leta efter destructorer som anropar en Clear-, Reset- eller ClearCore-metod innan fält frigörs, och kontrollera sedan om den metoden rör ett fält konstruktorn kanske inte nått. Sök efter loopar över ägda listor som inte föregås av en nil-kontroll. Leta efter konstruktorer som fångar exception, och ta bort både sväljningen och det manuella Free:et. Vill du ha bevis i stället för granskning ryms den räknande minneshanteraren ovan på en sida kod och förvandlar varje allokering i en konstruktor till ett testfall
För hela funktionsuppsättningen, stödda Delphi- och C++Builder-versioner och licensiering, se produktsidan för HotXLS Delphi Excel-komponenten