Když konstruktor v Delphi vyhodí výjimku, RTL okamžitě zavolá destruktor na napůl postavené instanci, takže každý destruktor musí počítat s poli, ke kterým nikdy nedošlo přiřazení. HotXLS tohle pravidlo aplikuje na své destruktory, včetně TXLSXRichText a TXLSXCells: selhání alokace uvnitř těch konstruktorů se k vašemu kódu dostane jako původní EOutOfMemory, ne jako sekundární access violation vyhozená během úklidu
Třída bugů je stará a snadno se přehlédne. Služba konvertuje 300 MB sešit na vytíženém serveru, paměť dojde a v logu svítí access violation uvnitř destruktoru. Nic na té zprávě neříká „došla paměť", nic nenaznačuje, že by pozdější pokus mohl vyjít, a stack trace ukazuje na destruktor, který vypadá naprosto správně. Původní výjimka existovala pár mikrosekund a pak ji nahradila. Tenhle článek prochází, proč se to stává, jak HotXLS destruktory staví, aby se tomu vyhnul, a jak psát vlastní třídy, aby to nikdy neudělaly
Co se stane, když konstruktor v Delphi selže?
Objekt se zničí za vás, ještě než výjimka dosáhne volajícího, a váš destruktor běží nad tím stavem, kterého konstruktor dosáhl. Posloupnost je daná kompilátorem a RTL. NewInstance alokuje instanci a InitInstance ji vyplní nulami, takže každá odkaz na objekt startuje jako nil, každé celé číslo jako 0 a každý řetězec jako prázdný. Tělo konstruktoru pak běží uvnitř implicitního exception handleru. Pokud cokoliv v tom těle selže, vygenerovaný kód zavolá virtuální destruktor: běží BeforeDestruction, pak váš Destroy, pak FreeInstance uvolní paměť a nakonec se výjimka vyhodí znovu. AfterConstruction se nespustí nikdy a proměnná vlevo od X := TFoo.Create se nikdy nepřiřadí
Destruktor tedy vidí objekt, který se zastavil na libovolném řádku svého konstruktoru. Pole přiřazená před selháním drží skutečné objekty. Pole za ním jsou pořád nil. Destruktor psaný v domnění, že stavba doběhla, přistoupí na jedno z těch nil polí a vyhodí access violation vlastní
Ta druhá výjimka je skutečná škoda. Delphi neřetězí výjimky vyhozené během tohohle úklidu: access violation nahradí EOutOfMemory, původní výjimka se zahodí a volající vidí jen AV. Užitečná diagnóza „selhala alokace, zkuste to znovu s více pamětí" je pryč a na jejím místě je něco, co vypadá jako poškozená paměť
Odkud pochází sekundární access violation?
Pochází z destruktoru, který předpokládá, že jeho konstruktor doběhl, a sáhne po listovém poli, které je nil, když selže hned první interní alokace. Dvě třídy HotXLS ukazují tvar jasně. Samostatný TXLSXRichText.Create i overload vlastněný komentářem TXLSXRichText.Create(AOwner: TXLSXComment) volají inherited Create a pak alokují FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) dělá totéž s FList := TList.Create, ještě než postaví své tabulky packed stringů a packed cell storage. V Lib/lxHandleX.pas oba destruktory ta pole chrání:
procedure TXLSXRichText.ClearCore;
var
i: Integer;
begin
if FRuns=nil then Exit; // konstruktor mohl skončit před 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 je nil, když selže TList.Create
// ... packed storage, tabulky stringů i FList se uvolňují přes Free
inherited Destroy;
end;
Bod selhání je přesný. Spočítáte-li heap alokace během stavby, první je instance sama; když selže NewInstance, žádné tělo konstruktoru neběží a žádný destruktor se nevolá, takže ten případ je vždy čistý. Druhá alokace je TList. Když selže, FRuns nebo FList zůstává nil, RTL volá Destroy a nechráněný Destroy čte Count přes nil referenci, což je přesně ta sekundární access violation. Pozdější selhání uvnitř TXLSXCells.Create jsou neškodná, protože jakmile FList existuje, kontroluje Clear přes FPackedStorage <> nil, než packed storage použije
Ty třídy jsou podstatné, protože vznikají ve velkém. Každý načtený worksheet si staví kolekci TXLSXCells a každá buňka nebo komentář s formátovanými runy si staví TXLSXRichText. Velký sešit s těžkým rich textem jich vytvoří tisíce, takže pod paměťovým tlakem má alokace, která nakonec selže, reálnou šanci dopadnout na jednu z těch řádek
Free je nil-safe, přístup k poli není
TObject.Free zkontroluje Self <> nil, než zavolá Destroy, takže FRuns.Free nad nil polem je no-op. Čtení FRuns.Count no-op není: Count čte pole na offsetu od pointeru instance a virtuální volání jako FRuns.Clear nejdřív čte VMT pointer z instance. Obojí nad nil spadne. Ta asymetrie je celá past. Uvolňovací volání v typickém destruktoru už bezpečná jsou; smyčky a úklidová volání před nimi jsou místo, kde bydlí potíže. Tady je ten vzorec v malé vlastní třídě:
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 tady nechá FRuns = nil
FIndex := TStringList.Create; // ... a tady nechá FIndex = nil
end;
destructor TStyledText.Destroy; // ŠPATNĚ: počítá s doběhlým konstruktorem
var
I: Integer;
begin
for I := 0 to FRuns.Count - 1 do // access violation, když FRuns = nil
TObject(FRuns[I]).Free;
FIndex.Free;
FRuns.Free;
inherited Destroy;
end;
Opravený destruktor chrání každý dereferencování a pole uvolňuje jen přes nil-safe volání:
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;
Pravidla, která z toho vyplyvají, jsou natolik krátká, že je uplatníte při code review:
- Každé použití vlastněného pole v destruktoru, jiné než
FreeneboFreeAndNil, potřebuje kontrolunilneboAssigned, včetně volání do helperů jakoClearneboClearCore, které pole iterují - Vlastněné objekty uvolňujte přes
FreeneboFreeAndNil, nikdy přímým volánímDestroy, kteréniltest přeskočí - Držte
inherited Destroyjako poslední příkaz, aby předek rozebral svá pole poté, co vaše zmizí - Neusuzujte z pořadí polí v konstruktoru; příští udržovatel nad něj vloží alokaci a bod selhání tiše posune
Kam patří inherited Create
Volávejte inherited Create jako první, tak jako oba konstruktory HotXLS. Pole předka pak existují, ještě než běží váš kód, a zrcadlový obraz destruktoru, inherited Destroy naposled, rozebírá v opačném pořadí. Pokud samotný konstruktor předka selže, váš destruktor stejně poběží se všemi vašimi poli nil, což nil-safe destruktor zvládne bez zvláštních případů. Explicitní přiřazení jako FOwner := nil v konstruktoru jsou po InitInstance nadbytečná, ale neškodná a občas stojí za to je držet jako dokumentaci
Nelovit a neuvolňovat uvnitř konstruktoru
Dva dobře míněné vzorce to zhorší. První je obalení těla konstruktoru do try ... except Free; raise; end: RTL stejně zavolá destruktor, takže se objekt zničí dvakrát. Druhý je spolknutí výjimky, aby „to pokračovalo dál": volající pak dostane vypadající validně referenci na objekt s poli nil a selhání se přesune k nějaké nesouvisející volání metody mnohem později. Pokud konstruktor vytvoří dočasné, které si nenechá, uvolněte ho v lokálním try ... finally a nechte výjimku pokračovat nahoru nedotčenou
Jak testovat chybové cesty konstruktorů?
Znejistěte každý bod alokace v konstruktoru záměrně, po jednom, a vyžadujte pokaždé EOutOfMemory, následované úspěšnou normální stavbou. Nástrojem na to je náhradní memory manager, jehož GetMem, AllocMem a ReallocMem počítají požadavky s kladnou velikostí a na zvoleném pořadovém čísle vrátí nil — nejprve spočítejte, kolik alokací konstruktor na vašem cílovém buildu dělá, pak spusťte jedno vstříknuté selhání na číslo
Po každém vstříknutém selhání postavte tutéž třídu znovu s obnoveným alokátorem a protáhněte ji: u rich text třídy přidejte run, nastavte jeho text a ověřte, že prostý text se vrátí tak, jak má. Vedle toho patří běžné kontroly lifecycle, jako uložení sešitu s formátovanými runy a komentáři do souboru a do streamu, uvolnění zdrojového sešitu a znovunačtení a porovnání obou kopií
Počty alokací patří jednomu kompilátoru a platformě; jiný kompilátor nebo RTL alokuje jinak, takže měřte na buildu, který posíláte ven. HotXLS aplikuje totéž nil-safe pravidlo napříč knihovnou, od kolekcí stylů, chartů a pivotů po hash tabulky, zip writer a PDF exporter. Hash tabulky si zaslouží specifickou poznámku: tabulka, která změní masku před realokací, která pak selže, zanechá svůj destruktor procházet paměť za starou tabulkou, takže maska se smí měnit až po tom, co nové uložení existuje. Jeden případ sedí přímo v RTL: TInterfaceList spadne ve svém destruktoru, když selže jeho interní alokace. Nic z toho není rollback libovolné operace, která selže v půlce, a není to obecná záruka zotavení z nedostatku paměti
Co tohle mění pro kód otevírající velké sešity?
V praxi je EOutOfMemory z těchto stavebních cest signál, který se dá chytit a na který se dá reagovat, místo access violation, kterou musíte brát jako pád. TXLSXWorkbook.Create nebere žádné parametry a TXLSXWorkbook.Open(const FileName: WideString): Integer vrací 1 při úspěchu a -1 při selhání. Výjimky vyhozené během načítání se zaznamenají do kolekce Diagnostics sešitu jako xlsDiagnosticUnhandledException a pak se vyhodí znovu, což popisuje článek o strukturovaných diagnostikách HotXLS. Obálka s opakováním vypadá takhle:
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; // žádné argumenty konstruktoru
if Book.Open(FileName) <> 1 then // 1 = úspěch, -1 = selhání
raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
[string(FileName)]);
Result := Book;
Book := nil; // vlastnictví přechází na volajícího
Exit;
finally
Book.Free; // nil-safe po předání
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // váš kód: zahoďte cache, jiné sešity
end;
end;
end;
end;
Pár detailů té obálky je záměrných. Každý pokus staví čerstvý TXLSXWorkbook, protože nil-safe destruktory dělají stavební selhání čistými, ale nedělají z napůl dotaženého Open resumovatelné, takže neúspěšná instance se prostě uvolní. Opakuje se jen EOutOfMemory; EWorkbookOpenFailed, chyby hesel a cokoliv dalšího propagate při prvním pokusu. EAccessViolation se nemá opakovat vůbec, protože signalizuje defekt, ne nedostatek prostředků. A opakování dává smysl jen tehdy, když se mezi pokusy něco změní, proto handler nejdřív paměť uvolní. Pokud potřebujete diagnostickou stopu neúspěšného pokusu, přečtěte Book.Diagnostics, ještě než ji blok finally uvolní
Opakování je zmírnění, ne plán kapacit. 32bitový proces, který na velkých souborech opakovaně naráží na EOutOfMemory, většinou postrádá souvislý adresní prostor, ne fyzickou paměť, a trvalá odpověď je Win64 build. Totožné uvažování platí na straně zápisu, kde crash-safe ukládání HotXLS s staged temp soubory drží neúspěšné ukládání od poškození stávajícího souboru. Pokud o jeden sešit sdílí několik threadů, read leases a write guards HotXLS popisují, jak se přístup k té instanci koordinuje
Checklist pro vaše vlastní třídy v Delphi
Samotné pojistky jsou po jednom řádku a tutéž revizi zvládnete ve vlastním kódu za minuty. Hledejte destruktory, které před uvolněním polí volají metodu Clear, Reset nebo ClearCore, a ověřte, jestli tahle metoda nesáhá po poli, ke kterému se konstruktor nemusel dostat. Hledejte smyčky přes vlastněné listy, kterým nepředchází kontrola nil. Hledejte konstruktory, které chytají výjimky, a odstraňte jak spolknutí, tak ruční Free. Pokud chcete důkaz místo inspekce, počítající memory manager popsaný výše se vejde na stránku kódu a promění každou alokaci v konstruktoru na test case
Kompletní sadu funkcí, podporované verze Delphi a C++Builder a licencování najdete na produktové stránce HotXLS Delphi Excel component