Quando um construtor Delphi levanta uma exceção, a RTL chama imediatamente o destrutor na instância meio construída, então todo destrutor precisa lidar com campos que nunca foram atribuídos. O HotXLS aplica essa regra aos destrutores dele, incluindo TXLSXRichText e TXLSXCells: uma falha de alocação dentro desses construtores chega ao seu código como o EOutOfMemory original, não como uma access violation secundária levantada durante a limpeza
Essa classe de bug é velha e fácil de perder. Um serviço converte uma pasta de trabalho de 300 MB num servidor ocupado, a memória fica curta, e o log mostra uma access violation dentro de um destrutor. Nada naquela mensagem diz "sem memória", nada sugere que tentar mais tarde funcionaria, e o stack trace aponta para um destrutor que parece perfeitamente correto. A exceção original existiu por alguns microssegundos e depois foi substituída. Este artigo percorre por que isso acontece, como o HotXLS estrutura os destrutores dele para evitar, e como escrever as suas próprias classes para que nunca façam isso
O que acontece quando um construtor Delphi levanta?
O objeto é destruído para você antes de a exceção chegar ao chamador, e o seu destrutor roda contra o estado que quer que o construtor tenha alcançado. A sequência é fixada pelo compilador e pela RTL. O NewInstance aloca a instância e o InitInstance a preenche com zeros, então toda referência de objeto começa como nil, todo inteiro como 0 e toda string como vazia. O corpo do construtor então roda dentro de um exception handler implícito. Se qualquer coisa nesse corpo levanta, o código gerado chama o destrutor virtual: o BeforeDestruction roda, depois o seu Destroy, depois o FreeInstance libera a memória, e finalmente a exceção é relançada. O AfterConstruction nunca roda, e a variável à esquerda de X := TFoo.Create nunca é atribuída
Então o destrutor vê um objeto que parou numa linha arbitrária do construtor dele. Campos atribuídos antes da falha seguram objetos reais. Campos depois dela ainda estão nil. Um destrutor escrito assumindo que a construção terminou vai desreferenciar um desses campos nil e levantar uma access violation própria
Essa segunda exceção é o dano real. O Delphi não encadeia exceções levantadas durante essa limpeza: a access violation substitui o EOutOfMemory, a exceção original é descartada, e o chamador só vê a AV. O diagnóstico útil, "uma alocação falhou, tente de novo com mais memória", se foi, e no lugar dele fica algo que parece corrupção de memória
De onde vem a access violation secundária?
Ela vem de um destrutor que assume o construtor completo e toca um campo de lista que está nil quando a primeira alocação interna falha. Duas classes do HotXLS mostram a forma com clareza. Tanto o TXLSXRichText.Create standalone quanto o overload de dono de comentário TXLSXRichText.Create(AOwner: TXLSXComment) chamam inherited Create e depois alocam FRuns := TList.Create. O TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) faz o mesmo com FList := TList.Create antes de construir as tabelas de strings empacotadas e o armazenamento de células empacotado. Em Lib/lxHandleX.pas os dois destrutores protegem esses campos:
procedure TXLSXRichText.ClearCore;
var
i: Integer;
begin
if FRuns=nil then Exit; // o construtor pode ter parado antes do 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 falhou
// ... packed storage, tabelas de strings e FList são liberados com Free
inherited Destroy;
end;
O ponto de falha é preciso. Contando as alocações de heap durante a construção, a primeira é a própria instância; se o NewInstance falha, nenhum corpo de construtor roda e nenhum destrutor é chamado, então esse caso é sempre limpo. A segunda alocação é a TList. Quando ela falha, FRuns ou FList fica nil, a RTL chama o Destroy, e um Destroy desprotegido lê Count através de uma referência nil, que é exatamente a access violation secundária. Falhas posteriores dentro do TXLSXCells.Create são inofensivas, porque uma vez que o FList existe, o Clear confere FPackedStorage <> nil antes de usar o armazenamento empacotado
Essas classes importam porque são criadas em massa. Toda planilha carregada constrói uma coleção TXLSXCells, e toda célula ou comentário com runs formatados constrói um TXLSXRichText. Uma pasta de trabalho grande com rich text pesado cria milhares deles, então sob pressão de memória a alocação que finalmente falha tem uma chance real de cair numa dessas linhas
Free é nil-safe, acesso a campo não é
O TObject.Free confere Self <> nil antes de chamar o Destroy, então FRuns.Free num campo nil é um no-op. Ler FRuns.Count não é: Count lê um campo num offset a partir do ponteiro da instância, e uma chamada virtual como FRuns.Clear primeiro lê o ponteiro da VMT da instância. Ambos falham em nil. Essa assimetria é a armadilha inteira. As chamadas de liberação num destrutor típico já são seguras; os loops e as chamadas de limpeza postas antes delas é onde mora o problema. Aqui está o padrão numa classe pequena sua:
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 aqui deixa FRuns = nil
FIndex := TStringList.Create; // ... e aqui deixa FIndex = nil
end;
destructor TStyledText.Destroy; // RUIM: assume que o construtor terminou
var
I: Integer;
begin
for I := 0 to FRuns.Count - 1 do // access violation quando FRuns = nil
TObject(FRuns[I]).Free;
FIndex.Free;
FRuns.Free;
inherited Destroy;
end;
O destrutor corrigido protege cada desreferência e libera os campos só por chamadas 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;
As regras que caem disso são curtas o bastante para aplicar numa revisão de código:
- Todo uso de um campo próprio num destrutor, exceto
FreeouFreeAndNil, precisa de uma checagemnilouAssigned, incluindo chamadas a helpers comoClearouClearCoreque iteram o campo - Libere objetos próprios com
FreeouFreeAndNil, nunca chamandoDestroydiretamente, que pula o teste denil - Mantenha o
inherited Destroycomo a última instrução, para que o ancestral desmonte os próprios campos depois que os seus se foram - Não raciocine pela ordem de campos no construtor; o próximo mantenedor vai inserir uma alocação acima da sua e mover o ponto de falha silenciosamente
Onde o inherited Create pertence
Chame inherited Create primeiro, como os dois construtores do HotXLS fazem. Os campos do ancestral então existem antes de o seu código rodar, e a imagem espelhada do destrutor, inherited Destroy por último, desenrola na ordem reversa. Se o próprio construtor do ancestral levanta, o seu destrutor ainda roda com todos os seus campos nil, o que um destrutor nil-safe trata sem casos especiais. Atribuições explícitas como FOwner := nil num construtor são redundantes depois do InitInstance, mas inofensivas e às vezes valem manter como documentação
Não capture e libere dentro do construtor
Dois padrões bem-intencionados pioram isso. O primeiro é envolver o corpo do construtor em try ... except Free; raise; end: a RTL já vai chamar o destrutor, então o objeto é destruído duas vezes. O segundo é engolir a exceção para "seguir em frente": o chamador então recebe uma referência de aparência válida para um objeto com campos nil, e a falha se move para alguma chamada de método não relacionada muito depois. Se um construtor cria um temporário que não mantém, libere esse temporário num try ... finally local e deixe a exceção seguir para cima intocada
Como você testa caminhos de falha de construtor?
Falhe de propósito cada ponto de alocação num construtor, um de cada vez, e exija EOutOfMemory toda vez, seguido de uma construção normal bem-sucedida. A ferramenta para isso é um memory manager substituto cujos GetMem, AllocMem e ReallocMem contam requisições de tamanho positivo e devolvem nil num ordinal escolhido. Conte as alocações que um construtor faz no seu build alvo primeiro, depois rode uma falha injetada por ordinal
Depois de cada falha injetada, construa a mesma classe de novo com o alocador restaurado e exercite-a: para uma classe de rich text, adicione um run, defina o texto dele e confirme que o texto puro volta como esperado. Checagens de ciclo de vida normais ficam ao lado, como salvar uma pasta de trabalho com runs formatados e comentários num arquivo e num stream, liberar a pasta de trabalho de origem, e reabrir e comparar as duas cópias
Contagens de alocação pertencem a um compilador e plataforma; outro compilador ou RTL pode alocar de forma diferente, então meça no build que você distribui. O HotXLS aplica a mesma regra nil-safe pela biblioteca, das coleções de estilo, gráfico e pivot às tabelas hash, ao writer zip e ao exportador PDF. Tabelas hash merecem uma nota específica: uma tabela que muda a máscara antes de uma realocação que depois falha deixa o destrutor dela percorrendo memória além da tabela antiga, então a máscara só pode mudar depois que o novo armazenamento existe. Um caso está na própria RTL: o TInterfaceList falha no destrutor dele quando a alocação interna falha. Nada disso equivale a rollback de uma operação arbitrária que falha no meio, e não é uma garantia geral de recuperação de falta de memória
O que isso muda para código que abre pastas de trabalho grandes?
Na prática, o EOutOfMemory desses caminhos de construção é um sinal que você pode capturar e tratar, em vez de uma access violation que precisa ser tratada como crash. O TXLSXWorkbook.Create não recebe parâmetros, e o TXLSXWorkbook.Open(const FileName: WideString): Integer devolve 1 em sucesso e -1 em falha. Exceções levantadas durante o carregamento são registradas na coleção Diagnostics da pasta de trabalho como xlsDiagnosticUnhandledException e depois relançadas, que é o comportamento descrito em o artigo de diagnósticos estruturados do HotXLS. Um wrapper de retry fica assim:
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; // sem argumentos de construtor
if Book.Open(FileName) <> 1 then // 1 = sucesso, -1 = falha
raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
[string(FileName)]);
Result := Book;
Book := nil; // a propriedade passa ao chamador
Exit;
finally
Book.Free; // nil-safe após uma entrega
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // seu código: descarte caches, outras pastas
end;
end;
end;
end;
Alguns detalhes nesse wrapper são deliberados. Cada tentativa constrói um TXLSXWorkbook novo, porque destrutores nil-safe tornam as falhas de construção limpas mas não tornam um Open pela metade retomável, então a instância falha é simplesmente liberada. Só o EOutOfMemory é retentado; EWorkbookOpenFailed, erros de senha e qualquer outra coisa propagam na primeira tentativa. Um EAccessViolation nunca deve ser retentado, já que sinaliza um defeito e não uma falta de recurso. E o retry só faz sentido se algo muda entre tentativas, por isso o handler libera memória primeiro. Se você precisa do rastro de diagnóstico da tentativa falha, leia Book.Diagnostics antes de o bloco finally liberar
Retentar é uma mitigação, não um plano de capacidade. Um processo de 32 bits que vive esbarrando em EOutOfMemory com arquivos grandes costuma estar com espaço de endereçamento contíguo curto, não memória física, e a resposta duradoura é um build Win64. O mesmo raciocínio se aplica no lado da escrita, onde os saves crash-safe do HotXLS com arquivos temporários em estágios impedem que um save falho danifique o arquivo existente. Se várias threads compartilham uma pasta de trabalho, os read leases e write guards do HotXLS descrevem como o acesso àquela instância é coordenado
Um checklist para as suas próprias classes Delphi
As proteções em si são de uma linha cada, e a mesma auditoria leva minutos no seu próprio código. Procure destrutores que chamam um método Clear, Reset ou ClearCore antes de liberar campos, depois verifique se esse método toca um campo que o construtor pode não ter alcançado. Procure loops sobre listas próprias que não são precedidos de uma checagem nil. Procure construtores que capturam exceções, e remova tanto o engolir quanto o Free manual. Se você quer prova em vez de inspeção, o memory manager de contagem descrito acima cabe numa página de código e transforma cada alocação num construtor num caso de teste
Para o conjunto completo de recursos, versões suportadas de Delphi e C++Builder e licenciamento, veja a página do produto HotXLS Delphi Excel component