Artykuł techniczny

Wyjątki konstruktorów Delphi i destruktory nil-safe

Gdy konstruktor Delphi rzuca wyjątek, RTL natychmiast woła destruktor na półzbudowanej instancji, więc każdy destruktor musi sobie radzić z polami, których nigdy nie przypisano. HotXLS stosuje tę regułę w swoich destruktorach, łącznie z TXLSXRichText i TXLSXCells: porażka alokacji wewnątrz tych konstruktorów dociera do twojego kodu jako oryginalne EOutOfMemory, nie jako wtórne access violation rzucone podczas sprzątania

Ta klasa bugów jest stara i łatwa do przeoczenia. Serwis konwertuje skoroszyt 300 MB na zajętym serwerze, pamięci zaczyna brakować, a w logu ląduje access violation wewnątrz destruktora. Nic w tym komunikacie nie mówi „brak pamięci", nic nie sugeruje, że ponowna próba później miałaby szansę, a stack trace wskazuje destruktor wyglądający całkowicie poprawnie. Oryginalny wyjątek istniał kilka mikrosekund i został podmieniony. Ten artykuł przechodzi przez to, dlaczego tak się dzieje, jak HotXLS konstruuje swoje destruktory, żeby tego uniknąć, i jak pisać własne klasy, żeby nigdy tego nie robiły

Co się dzieje, gdy konstruktor Delphi rzuca?

Obiekt jest niszczony za ciebie, zanim wyjątek dotrze do wywołującego, a twój destruktor działa na tym, do czego konstruktor doszedł. Sekwencja jest ustalona przez kompilator i RTL. NewInstance alokuje instancję, a InitInstance zeruje ją, więc każda referencja obiektu startuje jako nil, każdy integer jako 0 i każdy string jako pusty. Ciało konstruktora działa potem wewnątrz niejawnej obsługi wyjątków. Jeśli cokolwiek w tym ciele rzuci, wygenerowany kod woła wirtualny destruktor: BeforeDestruction działa, potem twój Destroy, potem FreeInstance zwalnia pamięć i na końcu wyjątek jest rzucany ponownie. AfterConstruction nigdy nie działa, a zmienna po lewej stronie X := TFoo.Create nigdy nie zostaje przypisana

Cykl życia obiektu Delphi pokazujący, jak RTL woła Destroy na półzbudowanej instancji HotXLS, gdy konstruktor rzuca, zanim oryginalny wyjątek dotrze do wywołującego
gdy konstruktor Delphi rzuca, RTL najpierw niszczy półzbudowaną instancję i dopiero potem rzuca oryginalny wyjątek ponownie, więc Destroy działa na tym, do czego budowa doszła

Destruktor widzi więc obiekt, który zatrzymał się na przypadkowej linii konstruktora. Pola przypisane przed porażką trzymają prawdziwe obiekty. Pola po niej są nadal nil. Destruktor napisany w założeniu, że konstrukcja się dokończyła, wyreferuje jedno z tych pól nil i rzuci własne access violation

Ten drugi wyjątek to prawdziwe szkody. Delphi nie łańcuchuje wyjątków rzuconych podczas tego sprzątania: access violation podmienia EOutOfMemory, oryginalny wyjątek ląduje w koszu, a wywołujący widzi wyłącznie AV. Użyteczna diagnoza — „alokacja padła, spróbuj z większą pamięcią" — znika, a na jej miejscu jest coś wyglądającego jak uszkodzenie pamięci

Skąd bierze się wtórne access violation?

Bierze się z destruktora zakładającego, że jego konstruktor się dokończył, i dotykającego pola listy, które jest nil, gdy pierwsza wewnętrzna alokacja pada. Dwie klasy HotXLS pokazują ten kształt wyraźnie. Zarówno samodzielny TXLSXRichText.Create, jak i overload należący do komentarza TXLSXRichText.Create(AOwner: TXLSXComment) wołają inherited Create, a potem alokują FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) robi to samo z FList := TList.Create, zanim zbuduje swoje tablice packed-string i magazyn packed cell. W Lib/lxHandleX.pas oba destruktory pilnują tych pól:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // konstruktor mógł zatrzymać się przed 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 jest nil, gdy TList.Create padł
  // ... packed storage, tablice stringów i FList są zwalniane przez Free
  inherited Destroy;
end;

Punkt porażki jest precyzyjny. Licząc alokacje sterty podczas konstrukcji, pierwsza to sama instancja; gdy NewInstance pada, żadne ciało konstruktora nie działa i żaden destruktor nie jest wołany, więc ten przypadek jest zawsze czysty. Druga alokacja to TList. Gdy pada, FRuns albo FList zostaje nil, RTL woła Destroy, a niepilnowany Destroy czyta Count przez referencję nil — to jest dokładnie to wtórne access violation. Późniejsze porażki wewnątrz TXLSXCells.Create są nieszkodliwe, bo gdy FList istnieje, Clear sprawdza FPackedStorage <> nil, zanim użyje magazynu packed

Mapa kolejności alokacji dla konstruktorów HotXLS w Delphi: nieudane NewInstance jest czyste, nieudane TList.Create zostawia pole nil, późniejsze porażki są pilnowane
liczy się tylko druga alokacja: porażka TList zostawia pole nil, a niepilnowany Destroy to właśnie to, co zamienia EOutOfMemory w access violation

Te klasy mają znaczenie, bo są tworzone hurtem. Każdy wczytany arkusz buduje kolekcję TXLSXCells, a każda komórka albo komentarz z formatowanymi biegami buduje TXLSXRichText. Duży skoroszyt z ciężkim rich textem tworzy ich tysiące, więc pod presją pamięci alokacja, która w końcu padnie, ma realną szansę wylądować na jednej z tych linii

Free jest nil-safe, dostęp do pola nie jest

TObject.Free sprawdza Self <> nil przed wołaniem Destroy, więc FRuns.Free na polu nil to no-op. Czytanie FRuns.Count już nie: Count czyta pole pod offsetem od wskaźnika instancji, a wirtualne wywołanie jak FRuns.Clear najpierw czyta wskaźnik VMT z instancji. Oba wywalają się na nil. Ta asymetria to cała pułapka. Wywołania zwalniające w typowym destruktorze są już bezpieczne; na pętlach i wywołaniach sprzątających ustawionych przed nimi mieszka kłopot. Oto wzorzec w małej klasie twojej własnej:

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 tutaj zostawia FRuns = nil
  FIndex := TStringList.Create;   // ... a tutaj zostawia FIndex = nil
end;

destructor TStyledText.Destroy;   // ŹLE: zakłada, że konstruktor się dokończył
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // access violation, gdy FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

Naprawiony destruktor pilnuje każdego wyreferowania i zwalnia pola wyłącznie przez wywołania nil-safe:

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;

Reguły, które z tego wypadają, są na tyle krótkie, by zastosować je w code review:

  • Każde użycie posiadanego pola w destruktorze, poza Free albo FreeAndNil, potrzebuje sprawdzenia nil albo Assigned, łącznie z wywołaniami do helperów jak Clear albo ClearCore, które iterują po polu
  • Zwalniaj posiadane obiekty przez Free albo FreeAndNil, nigdy wołając Destroy wprost, co pomija test nil
  • Trzymaj inherited Destroy jako ostatnią instrukcję, żeby przodek rozebrał własne pola po twoich
  • Nie rozumuj z kolejności pól w konstruktorze; następny opiekun wsadzi alokację ponad twoją i po cichu przesunie punkt porażki

Gdzie należy się inherited Create

Wołaj inherited Create na początku, jak robią to oba konstruktory HotXLS. Pola przodka istnieją wtedy, zanim ruszy twój kod, a lustrzane odbicie destruktora, inherited Destroy na końcu, rozwija w odwrotnej kolejności. Jeśli sam konstruktor przodka rzuci, twój destruktor nadal działa z każdym twoim polem nil, co nil-safe destruktor obsługuje bez specjalnych przypadków. Jawne przypisania jak FOwner := nil w konstruktorze są po InitInstance redundantne, ale nieszkodliwe i czasem warte zachowania jako dokumentacja

Nie łap i nie zwalniaj wewnątrz konstruktora

Dwa dobrze źle pomyślane wzorce pogarszają sprawę. Pierwszy to owinięcie ciała konstruktora w try ... except Free; raise; end: RTL i tak zamierza wołać destruktor, więc obiekt jest niszczony dwa razy. Drugi to połykanie wyjątku, żeby „jakoś leciało": wywołujący dostaje wtedy wyglądające na ważne referencję do obiektu z polami nil, a porażka przenosi się do jakiejś niezwiązanej metody dużo później. Jeśli konstruktor tworzy tymczasowy obiekt, którego nie zatrzymuje, zwolnij go w lokalnym try ... finally i pozwól wyjątkowi lecieć w górę nietkniętemu

Jak testować ścieżki porażki konstruktora?

Wysypuj każdą alokację w konstruktorze celowo, po jednej naraz, i wymagaj za każdym razem EOutOfMemory, po którym następuje udana normalna konstrukcja. Narzędziem do tego jest zastępczy memory manager, którego GetMem, AllocMem i ReallocMem liczą żądania o dodatnim rozmiarze i zwracają nil pod wybranym numerem porządkowym. Policz najpierw alokacje, jakie robi konstruktor na twoim buildzie docelowym, potem puszczasz jedną wstrzykniętą porażkę per numer

Po każdej wstrzykniętej porażce skonstruuj tę samą klasę ponownie z przywróconym alokatorem i ją przećwicz: dla klasy rich text dodaj bieg, ustaw jego tekst i potwierdź, że goły tekst wraca taki, jak trzeba. Obok należą zwykłe kontrole cyklu życia, jak zapisanie skoroszytu z formatowanymi biegami i komentarzami do pliku i do strumienia, zwolnienie skoroszytu źródłowego oraz ponowne otwarcie i porównanie obu kopii

Liczby alokacji należą do jednego kompilatora i platformy; inny kompilator albo RTL może alokować inaczej, więc mierz na buildzie, który wysyłasz. HotXLS stosuje tę samą regułę nil-safe w całej bibliotece, od kolekcji stylów, wykresów i pivotów po tablice hashowe, writer zip i eksporter PDF. Tablice hashowe zasługują na osobną notę: tabela, która zmienia maskę przed realokacją, która potem pada, zostawia swój destruktor chodzący po pamięci za starą tabelą, więc maska może się zmienić dopiero, gdy nowy magazyn istnieje. Jeden przypadek siedzi w samym RTL: TInterfaceList wywala się w swoim destruktorze, gdy jego wewnętrzna alokacja pada. Nic z tego nie jest rollbackiem dowolnej operacji zawodzącej w połowie i nie jest to ogólna gwarancja odzyskiwania po braku pamięci

Co to zmienia dla kodu otwierającego duże skoroszyty?

W praktyce EOutOfMemory z tych ścieżek konstrukcji to sygnał, który można złapać i na który można zareagować, zamiast access violation traktowanego jak crash. TXLSXWorkbook.Create nie bierze parametrów, a TXLSXWorkbook.Open(const FileName: WideString): Integer zwraca 1 przy sukcesie i -1 przy porażce. Wyjątki rzucone podczas ładowania są zapisywane w kolekcji Diagnostics skoroszytu jako xlsDiagnosticUnhandledException i potem rzucane ponownie, co opisuje artykuł o ustrukturyzowanej diagnostyce HotXLS. Wrapper ponawiający wygląda tak:

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;          // konstruktor bez argumentów
        if Book.Open(FileName) <> 1 then        // 1 = sukces, -1 = porażka
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // własność przechodzi na wywołującego
        Exit;
      finally
        Book.Free;                             // nil-safe po przekazaniu
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // twój kod: zwolnij cache i inne skoroszyty
      end;
    end;
  end;
end;

Kilka szczegółów tego wrappera jest zamierzonych. Każda próba buduje świeży TXLSXWorkbook, bo nil-safe destruktory robią porażki konstrukcji czystymi, ale nie czynią niedokończonego Open wznawialnym, więc nieudana instancja jest po prostu zwalniana. Ponawiane jest tylko EOutOfMemory; EWorkbookOpenFailed, błędy hasła i cokolwiek innego leci dalej przy pierwszej próbie. EAccessViolation nie powinno być ponawiane w ogóle, bo sygnalizuje defekt, nie brak zasobu. I ponawianie ma sens tylko, gdy coś między próbami się zmienia, dlatego handler najpierw zwalnia pamięć. Jeśli potrzebujesz śladu diagnostycznego nieudanej próby, przeczytaj Book.Diagnostics, zanim blok finally go zwolni

Macierz decyzji ponawiania w Delphi z HotXLS: EOutOfMemory jest ponawiane ze świeżym TXLSXWorkbook, inne porażki lecą od razu, access violation nie jest nigdy ponawiane
każda próba buduje świeży skoroszyt, bo niedokończone Open nie jest wznawialne, a tylko EOutOfMemory kiedykolwiek dostaje drugą szansę

Ponawianie to mitygacja, nie plan pojemności. Proces 32-bitowy, który w kółko łapie EOutOfMemory na dużych plikach, zwykle cierpi na brak ciągłej przestrzeni adresowej, nie pamięci fizycznej, a trwała odpowiedź to build Win64. To samo myślenie działa po stronie zapisu, gdzie crash-safe zapisy HotXLS z etapowanymi plikami tymczasowymi chronią istniejący plik przed zepsutym zapisem. Jeśli kilka wątków dzieli jeden skoroszyt, read lease i write guard HotXLS opisują, jak dostęp do tej instancji jest koordynowany

Checklista dla własnych klas Delphi

Same pilnowania to po jednej linijce każde, a ten sam audyt na własnym kodzie zajmuje minuty. Szukaj destruktorów wołających metodę Clear, Reset albo ClearCore przed zwalnianiem pól, potem sprawdź, czy ta metoda nie dotyka pola, do którego konstruktor mógł nie dotrzeć. Szukaj pętli po posiadanych listach niepoprzedzonych sprawdzeniem nil. Szukaj konstruktorów łapiących wyjątki i usuń i połykanie, i ręczne Free. Jeśli chcesz dowodu zamiast inspekcji, opisany wyżej liczący memory manager mieści się na stronie kodu i zamienia każdą alokację w konstruktorze w przypadek testowy

Pełny zestaw funkcji, wspierane wersje Delphi i C++Builder oraz licencjonowanie znajdziesz na stronie produktu HotXLS Delphi Excel component