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
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
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
FreealboFreeAndNil, potrzebuje sprawdzenianilalboAssigned, łącznie z wywołaniami do helperów jakClearalboClearCore, które iterują po polu - Zwalniaj posiadane obiekty przez
FreealboFreeAndNil, nigdy wołającDestroywprost, co pomija testnil - Trzymaj
inherited Destroyjako 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
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