Technický článek

Výjimky konstruktorů a nil-safe destruktory v Delphi

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í

Lifecycle objektu Delphi ukazující, jak RTL volá Destroy na napůl postavené instanci HotXLS, když konstruktor selže, ještě než původní výjimka dosáhne volajícího
když konstruktor v Delphi selže, RTL napůl postavenou instanci nejdřív zničí a teprve potom vyhodí původní výjimku znovu, takže Destroy běží nad tím stavem, kterého stavba dosáhla

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

Mapa pořadí alokací konstruktorů HotXLS v Delphi: selhání NewInstance je čisté, selhání TList.Create nechá pole nil, pozdější selhání zůstávají chráněná
na druhé alokaci záleží jediné: selhání TList nechá pole nil a nechráněný Destroy je to, co z EOutOfMemory udělá access violation

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ž Free nebo FreeAndNil, potřebuje kontrolu nil nebo Assigned, včetně volání do helperů jako Clear nebo ClearCore, které pole iterují
  • Vlastněné objekty uvolňujte přes Free nebo FreeAndNil, nikdy přímým voláním Destroy, které nil test přeskočí
  • Držte inherited Destroy jako 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í

Matice rozhodování o opakování v Delphi s HotXLS: EOutOfMemory se opakuje s čerstvým TXLSXWorkbook, ostatní selhání propagují hned, access violation se nikdy neopakuje
každý pokus staví čerstvý sešit, protože napůl dotažený Open není resumovatelný, a druhý pokus si zaslouží jen EOutOfMemory

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