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
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
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
FreeoFreeAndNil, vuole un controlloniloAssigned, incluse le chiamate a helper comeClearoClearCoreche iterano il campo - Liberate gli oggetti posseduti con
FreeoFreeAndNil, mai chiamandoDestroydirettamente, che salta il testnil - Tenete
inherited Destroycome 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
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