Articolo tecnico

Eccezioni dei costruttori Delphi e distruttori nil-safe

Quando un costruttore Delphi solleva un'eccezione, la RTL chiama subito il distruttore sull'istanza mezzo costruita, quindi ogni distruttore deve saper gestire campi mai assegnati. HotXLS applica quella regola ai suoi distruttori, TXLSXRichText e TXLSXCells inclusi: un fallimento di allocazione dentro quei costruttori arriva al vostro codice come l'originale EOutOfMemory, non come una violazione di accesso secondaria sollevata durante la pulizia

La classe di bug è vecchia e facile da perdere. Un servizio converte un workbook da 300 MB su un server affollato, la memoria scarseggia, e il log mostra una violazione di accesso dentro un distruttore. Nulla in quel messaggio dice "out of memory", nulla suggerisce che ritentare più tardi funzionerebbe, e lo stack trace punta a un distruttore che sembra perfettamente corretto. L'eccezione originale è esistita per qualche microsecondo e poi è stata rimpiazzata. Questo articolo percorre perché succede, come HotXLS struttura i suoi distruttori per evitarlo, e come scrivere le vostre classi perché non lo facciano mai

Cosa succede quando un costruttore Delphi solleva?

L'oggetto viene distrutto per voi prima che l'eccezione raggiunga chi chiama, e il vostro distruttore gira contro lo stato qualsiasi in cui il costruttore si è fermato. La sequenza è fissata dal compilatore e dalla RTL. NewInstance alloca l'istanza e InitInstance la riempie di zeri, così ogni riferimento a oggetto parte nil, ogni intero come 0 e ogni stringa come vuota. Il corpo del costruttore poi gira dentro un gestore di eccezioni implicito. Se qualcosa in quel corpo solleva, il codice generato chiama il distruttore virtuale: gira BeforeDestruction, poi il vostro Destroy, poi FreeInstance libera la memoria, e infine l'eccezione viene rilanciata. AfterConstruction non gira mai, e la variabile a sinistra di X := TFoo.Create non viene mai assegnata

Ciclo di vita di un oggetto Delphi che mostra come la RTL chiama Destroy su un'istanza HotXLS mezzo costruita quando un costruttore solleva, prima che l'eccezione originale raggiunga chi chiama
quando un costruttore Delphi solleva, la RTL distrugge prima l'istanza mezzo costruita e solo dopo rilancia l'eccezione originale, così Destroy gira contro lo stato qualsiasi raggiunto dalla costruzione

Così il distruttore vede un oggetto che si è fermato a una riga arbitraria del suo costruttore. I campi assegnati prima del fallimento tengono oggetti veri. I campi dopo sono ancora nil. Un distruttore scritto assumendo che la costruzione fosse finita dereferenzierà uno di quei campi nil e solleverà una violazione di accesso sua

Quella seconda eccezione è il danno vero. Delphi non incatena le eccezioni sollevate durante questa pulizia: la violazione di accesso rimpiazza l'EOutOfMemory, l'eccezione originale viene buttata, e chi chiama vede solo l'AV. La diagnosi utile, "un'allocazione è fallita, ritentate con più memoria", è sparita, e al suo posto c'è qualcosa che somiglia a una corruzione di memoria

Da dove viene la violazione di accesso secondaria?

Viene da un distruttore che assume il suo costruttore completato e tocca un campo lista che è nil quando fallisce proprio la prima allocazione interna. Due classi HotXLS mostrano la forma chiaramente. Sia la TXLSXRichText.Create autonoma sia l'overload posseduto dal commento TXLSXRichText.Create(AOwner: TXLSXComment) chiamano inherited Create e poi allocano FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) fa lo stesso con FList := TList.Create prima di costruire le sue tabelle di stringhe packed e lo storage celle packed. In Lib/lxHandleX.pas i due distruttori proteggono quei campi:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // il costruttore può essersi fermato prima di 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 è nil se TList.Create è fallita
  // ... packed storage, tabelle di stringhe e FList vengono liberati con Free
  inherited Destroy;
end;

Il punto di fallimento è preciso. Contando le allocazioni heap durante la costruzione, la prima è l'istanza stessa; se NewInstance fallisce, nessun corpo di costruttore gira e nessun distruttore viene chiamato, quindi quel caso è sempre pulito. La seconda allocazione è la TList. Quando fallisce, FRuns o FList resta nil, la RTL chiama Destroy, e un Destroy non protetto legge Count attraverso un riferimento nil, che è esattamente la violazione di accesso secondaria. I fallimenti successivi dentro TXLSXCells.Create sono innocui, perché una volta che FList esiste, Clear controlla FPackedStorage <> nil prima di usare lo storage packed

Mappa dell'ordine di allocazione per i costruttori HotXLS in Delphi: un NewInstance fallito è pulito, un TList.Create fallito lascia il campo nil, i fallimenti successivi restano protetti
solo la seconda allocazione conta: il fallimento della TList lascia il campo nil, e un Destroy non protetto è ciò che trasforma EOutOfMemory in una violazione di accesso

Queste classi contano perché vengono create in massa. Ogni worksheet caricato costruisce una collezione TXLSXCells, e ogni cella o commento con run formattati costruisce una TXLSXRichText. Un workbook grande con ricco testo formattato ne crea migliaia, così sotto pressione di memoria l'allocazione che alla fine fallisce ha una probabilità reale di atterrare su una di queste righe

Free è nil-safe, l'accesso ai campi no

TObject.Free controlla Self <> nil prima di chiamare Destroy, quindi FRuns.Free su un campo nil è un no-op. Leggere FRuns.Count no: Count legge un campo a un offset dal puntatore istanza, e una chiamata virtuale come FRuns.Clear prima legge il puntatore VMT dall'istanza. Entrambe faultano su nil. Quell'asimmetria è l'intera trappola. Le chiamate di rilascio in un distruttore tipico sono già sicure; i cicli e le chiamate di pulizia piazzate prima sono dove vive il guaio. Ecco il pattern in una piccola classe vostra:

type
  TStyledText = class
  private
    FRuns: TList;
    FIndex: TStringList;
  public
    constructor Create;
    destructor Destroy; override;
  end;

constructor TStyledText.Create;
begin
  inherited Create;
  FRuns := TList.Create;          // un EOutOfMemory qui lascia FRuns = nil
  FIndex := TStringList.Create;   // ... e qui lascia FIndex = nil
end;

destructor TStyledText.Destroy;   // SBAGLIATO: assume il costruttore finito
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // violazione di accesso quando FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

Il distruttore corretto protegge ogni dereferenziazione e libera i campi solo attraverso chiamate 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;

Le regole che ne scendono sono abbastanza brevi da applicare in code review:

  • Ogni uso di un campo posseduto in un distruttore, al di fuori di Free o FreeAndNil, vuole un controllo nil o Assigned, incluse le chiamate a helper come Clear o ClearCore che iterano il campo
  • Liberate gli oggetti posseduti con Free o FreeAndNil, mai chiamando Destroy direttamente, che salta il test nil
  • Tenete inherited Destroy come ultima istruzione, così l'antenato smonta i suoi campi dopo che i vostri sono andati
  • Non ragionate dall'ordine dei campi nel costruttore; il prossimo manutentore inserirà un'allocazione sopra la vostra e sposterà in silenzio il punto di fallimento

Dove sta il posto di inherited Create

Chiamate inherited Create per primo, come fanno entrambi i costruttori HotXLS. I campi dell'antenato esistono così prima che il vostro codice giri, e l'immagine speculare del distruttore, inherited Destroy per ultimo, si svolge nell'ordine inverso. Se il costruttore dell'antenato stesso solleva, il vostro distruttore gira comunque con ogni vostro campo nil, cosa che un distruttore nil-safe gestisce senza casi speciali. Assegnazioni esplicite come FOwner := nil in un costruttore sono ridondanti dopo InitInstance, ma innocue e a volte vale la pena tenerle come documentazione

Non beccare e liberare dentro il costruttore

Due pattern ben intenzionati peggiorano la situazione. Il primo è avvolgere il corpo del costruttore in try ... except Free; raise; end: la RTL sta già per chiamare il distruttore, quindi l'oggetto viene distrutto due volte. Il secondo è ingoiare l'eccezione per "andare avanti": chi chiama riceve così un riferimento dall'aspetto valido a un oggetto con campi nil, e il fallimento si sposta su qualche chiamata di metodo non correlata molto più tardi. Se un costruttore crea un temporaneo che non tiene, liberate quel temporaneo in un try ... finally locale e lasciate che l'eccezione continui verso l'alto intoccata

Come si testano i percorsi di fallimento del costruttore?

Fate fallire di proposito ogni punto di allocazione in un costruttore, uno alla volta, ed esigete EOutOfMemory ogni volta, seguita da una costruzione normale riuscita. Lo strumento è un memory manager sostitutivo il cui GetMem, AllocMem e ReallocMem contano le richieste di dimensione positiva e restituiscono nil a un ordinale scelto. Contate prima quante allocazioni fa un costruttore sulla vostra build obiettivo, poi eseguite un fallimento iniettato per ordinale

Dopo ogni fallimento iniettato, costruite di nuovo la stessa classe con l'allocatore ripristinato ed esercitatela: per una classe di testo formattato, aggiungete un run, impostate il suo testo, e confermate che il testo semplice torna come atteso. Accanto stanno i controlli normali del ciclo di vita, come salvare un workbook con run formattati e commenti su file e su stream, liberare il workbook sorgente, e riaprire e confrontare entrambe le copie

I conteggi di allocazione appartengono a un compilatore e a una piattaforma; un altro compilatore o RTL può allocare diversamente, quindi misurate sulla build che spedite. HotXLS applica la stessa regola nil-safe attraverso la libreria, dalle collezioni di stili, grafici e pivot alle hash table, allo zip writer e all'exporter PDF. Le hash table meritano una nota specifica: una tabella che cambia la sua mask prima di una riallocazione che poi fallisce lascia il suo distruttore passeggiare in memoria oltre la vecchia tabella, quindi la mask deve cambiare solo dopo che il nuovo storage esiste. Un caso sta nella RTL stessa: TInterfaceList faulta nel suo distruttore quando la sua allocazione interna fallisce. Nulla di questo equivale al rollback di un'operazione arbitraria che fallisce a metà, e non è una garanzia generale di recupero da out-of-memory

Cosa cambia per il codice che apre workbook grandi?

In pratica, l'EOutOfMemory da questi percorsi di costruzione è un segnale che potete beccare e su cui agire, invece di una violazione di accesso da trattare come crash. TXLSXWorkbook.Create non prende parametri, e TXLSXWorkbook.Open(const FileName: WideString): Integer restituisce 1 in caso di successo e -1 in caso di fallimento. Le eccezioni sollevate durante il caricamento vengono registrate nella collezione Diagnostics del workbook come xlsDiagnosticUnhandledException e poi rilanciate, che è il comportamento descritto in articolo sulla diagnostica strutturata HotXLS. Un wrapper di retry si presenta così:

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;          // nessun argomento di costruttore
        if Book.Open(FileName) <> 1 then        // 1 = successo, -1 = fallimento
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // la proprietà passa a chi chiama
        Exit;
      finally
        Book.Free;                             // nil-safe dopo un passaggio di consegne
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // il vostro codice: butta le cache, altri libri
      end;
    end;
  end;
end;

Alcuni dettagli in quel wrapper sono deliberati. Ogni tentativo costruisce un TXLSXWorkbook fresco, perché i distruttori nil-safe rendono puliti i fallimenti di costruzione ma non rendono riprendibile un Open mezzo finito, quindi l'istanza fallita viene semplicemente liberata. Solo EOutOfMemory viene ritentato; EWorkbookOpenFailed, errori di password e qualunque altra cosa propagano al primo tentativo. Un EAccessViolation non andrebbe mai ritentato, dato che segnala un difetto e non una carenza di risorse. E il retry ha senso solo se qualcosa cambia tra i tentativi, ecco perché il gestore libera prima la memoria. Se vi serve la scia diagnostica del tentativo fallito, leggete Book.Diagnostics prima che il blocco finally lo liberi

Matrice decisionale del retry in Delphi con HotXLS: EOutOfMemory viene ritentato con un TXLSXWorkbook fresco, gli altri fallimenti propagano subito, una violazione di accesso non viene mai ritentata
ogni tentativo costruisce un workbook fresco perché un Open mezzo finito non è riprendibile, e solo EOutOfMemory si guadagna mai un secondo tentativo

Il retry è una mitigazione, non un piano di capacità. Un processo a 32 bit che continua a beccare EOutOfMemory su file grandi di solito è a corto di spazio di indirizzi contiguo più che di memoria fisica, e la risposta durevole è una build Win64. Lo stesso ragionamento vale sul lato scrittura, dove salvataggi crash-safe HotXLS con file temporanei a stadi tengono un salvataggio fallito lontano dal danneggiare il file esistente. Se più thread condividono un workbook, read lease e write guard HotXLS descrivono come viene coordinato l'accesso a quell'istanza

Una checklist per le vostre classi Delphi

Le protezioni in sé sono una riga ciascuna, e lo stesso audit richiede minuti sul vostro codice. Cercate distruttori che chiamano un metodo Clear, Reset o ClearCore prima di liberare i campi, poi verificate se quel metodo tocca un campo che il costruttore potrebbe non aver raggiunto. Cercate cicli su liste possedute non preceduti da un controllo nil. Cercate costruttori che beccano eccezioni, e togliete sia l'ingoiare sia la Free manuale. Se volete prove invece di ispezioni, il memory manager contatore descritto sopra sta in una pagina di codice e trasforma ogni allocazione in un costruttore in un caso di test

Per il set completo di funzionalità, le versioni Delphi e C++Builder supportate e le licenze, vedete la pagina del prodotto HotXLS Delphi Excel component