Teknisk artikel

Delphi-konstruktor-exceptioner och nil-säkra destructorer

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

Delphi-objektlivscykel som visar hur RTL:n anropar Destroy på en halvfärdig HotXLS-instans när en konstruktor kastar, innan det ursprungliga exceptionet når anroparen
när en Delphi-konstruktor kastar förstör RTL:n först den halvfärdiga instansen och kastar först därefter det ursprungliga exceptionet på nytt, så Destroy körs mot vilket tillstånd konstruktionen än nått

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

Kartläggning av allokeringsordning för HotXLS-konstruktorer i Delphi: en fallerad NewInstance är ren, en fallerad TList.Create lämnar fältet nil, senare fel förblir bevakade
bara den andra allokeringen spelar roll: TList-felet lämnar fältet nil, och en obevakad Destroy är det som förvandlar EOutOfMemory till en access violation

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 Free eller FreeAndNil, behöver en nil- eller Assigned-kontroll, inklusive anrop in i hjälpare som Clear eller ClearCore som itererar fältet
  • Frigör ägda objekt med Free eller FreeAndNil, aldrig genom att anropa Destroy direkt, vilket hoppar över nil-testet
  • Behåll inherited Destroy som 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

Retry-beslutsmatris i Delphi med HotXLS: EOutOfMemory försöks igen med en färsk TXLSXWorkbook, andra fel propagerar direkt, en access violation försöks aldrig igen
varje försök bygger en färsk arbetsbok för ett halvfärdigt Open inte är återupptagbart, och bara EOutOfMemory förtjänar någonsin ett andra försök

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