Quando um construtor Delphi levanta uma exceção, a RTL chama de imediato o destrutor na instância meio construída, por isso todo o destrutor tem de lidar com campos que nunca foram atribuídos. O HotXLS aplica essa regra aos seus destrutores, TXLSXRichText e TXLSXCells incluídos: uma falha de alocação dentro desses construtores chega ao seu código como o EOutOfMemory original, não como uma violação de acesso secundária levantada durante a limpeza
A 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 esgota-se, e o log mostra uma violação de acesso dentro de um destrutor. Nada na mensagem diz "falta de 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 durante alguns microssegundos e depois foi substituída. Este artigo percorre porque é que isso acontece, como o HotXLS estrutura os seus destrutores para o evitar, e como escrever as suas próprias classes para que nunca o façam
O que acontece quando um construtor Delphi levanta?
O objeto é destruído por si antes de a exceção chegar ao chamador, e o seu destrutor corre contra o estado que o construtor alcançou. A sequência é fixada pelo compilador e pela RTL. O NewInstance aloca a instância e o InitInstance preenche-a com zeros, por isso toda a referência a objeto começa como nil, todo o inteiro como 0 e toda a string como vazia. O corpo do construtor depois corre dentro de um gestor de exceções implícito. Se algo nesse corpo levantar, o código gerado chama o destrutor virtual: o BeforeDestruction corre, depois o seu Destroy, depois o FreeInstance liberta a memória, e finalmente a exceção é re-levantada. O AfterConstruction nunca corre, e a variável à esquerda do X := TFoo.Create nunca é atribuída
Assim, o destrutor vê um objeto que parou numa linha arbitrária do seu construtor. Campos atribuídos antes da falha seguram objetos reais. Campos depois dela continuam nil. Um destrutor escrito sob o pressuposto de que a construção terminou vai desreferenciar um desses campos nil e levantar uma violação de acesso própria
Essa segunda exceção é o dano real. O Delphi não encadeia exceções levantadas durante essa limpeza: a violação de acesso substitui o EOutOfMemory, a exceção original é descartada, e o chamador só vê de sempre a AV. O diagnóstico útil, "falhou uma alocação, tente outra vez com mais memória", desaparece, e em seu lugar fica algo que parece corrupção de memória
De onde vem a violação de acesso secundária?
Vem de um destrutor que assume que o seu construtor completou e toca num campo de lista que está nil quando a primeira alocação interna falha. Duas classes HotXLS mostram a forma com clareza. Tanto o TXLSXRichText.Create autónomo como o overload possuído por 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 suas tabelas de strings empacotadas e o armazenamento de células empacotado. Em Lib/lxHandleX.pas os dois destrutores guardam 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
// ... armazenamento empacotado, tabelas de strings e FList são libertados 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 corre e nenhum destrutor é chamado, por isso esse caso é sempre limpo. A segunda alocação é a TList. Quando falha, FRuns ou FList fica nil, a RTL chama o Destroy, e um Destroy sem guarda lê Count através de uma referência nil, que é exatamente a violação de acesso secundária. Falhas posteriores dentro do TXLSXCells.Create são inofensivas, porque uma vez que FList existe, o Clear verifica FPackedStorage <> nil antes de usar o armazenamento empacotado
Estas classes importam porque são criadas em massa. Cada pasta de trabalho carregada constrói uma coleção TXLSXCells, e cada célula ou comentário com runs formatados constrói um TXLSXRichText. Uma pasta de trabalho grande com muito rich text cria milhares deles, por isso sob pressão de memória a alocação que finalmente falha tem uma probabilidade real de aterrar numa destas linhas
Free é nil-safe, acesso a campos não é
O TObject.Free verifica Self <> nil antes de chamar o Destroy, por isso um FRuns.Free num campo nil é um no-op. Ler FRuns.Count não é: o Count lê um campo a um offset do ponteiro de instância, e uma chamada virtual como FRuns.Clear primeiro lê o ponteiro VMT da instância. Ambos falham em nil. Essa assimetria é toda a armadilha. As chamadas de libertação num destrutor típico já são seguras; os loops e chamadas de limpeza colocados antes delas são onde o problema vive. Aqui está o padrão numa pequena classe 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; // MAU: assume que o construtor terminou
var
I: Integer;
begin
for I := 0 to FRuns.Count - 1 do // violação de acesso quando FRuns = nil
TObject(FRuns[I]).Free;
FIndex.Free;
FRuns.Free;
inherited Destroy;
end;
O destrutor corrigido guarda cada desreferência e liberta os campos só através de 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 daqui decorrem são curtas o suficiente para aplicar em code review:
- Todo o uso de um campo possuído num destrutor, fora
FreeouFreeAndNil, precisa de uma verificaçãonilouAssigned, incluindo chamadas a helpers comoClearouClearCoreque iteram o campo - Liberte objetos possuídos com
FreeouFreeAndNil, nunca chamandoDestroydiretamente, que salta o testenil - Mantenha o
inherited Destroycomo última instrução, para que o antecessor desmonte os seus próprios campos depois de os seus terem ido - Não raciocine a partir da ordem dos campos no construtor; o próximo mantenedor vai inserir uma alocação acima da sua e mover silenciosamente o ponto de falha
Onde o inherited Create pertence
Chame inherited Create primeiro, como ambos os construtores HotXLS fazem. Os campos do antecessor existem então antes de o seu código correr, e a imagem espelhada do destrutor, inherited Destroy em último, desenrola pela ordem inversa. Se o próprio construtor do antecessor levantar, o seu destrutor ainda corre 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 após o InitInstance, mas inofensivas e às vezes vale a pena mantê-las como documentação
Não apanhe e liberte dentro do construtor
Dois padrões bem-intencionados tornam isto pior. O primeiro é embrulhar o corpo do construtor em try ... except Free; raise; end: a RTL já vai chamar o destrutor, por isso o objeto é destruído duas vezes. O segundo é engolir a exceção para "continuar": o chamador recebe então uma referência de aparência válida para um objeto com campos nil, e a falha muda para uma chamada de método qualquer muito mais tarde. Se um construtor cria um temporário que não mantém, liberte esse temporário num try ... finally local e deixe a exceção subir intocada
Como testar os caminhos de falha de construtores?
Faça falhar de propósito cada ponto de alocação num construtor, um de cada vez, e exija EOutOfMemory todas as vezes, seguido de uma construção normal bem-sucedida. A ferramenta para isso é um gestor de memória de substituição cujo GetMem, AllocMem e ReallocMem contam pedidos de tamanho positivo e devolvem nil num ordinal escolhido. Conte primeiro as alocações que um construtor faz na sua build alvo, depois corra uma falha injetada por ordinal
Depois de cada falha injetada, construa a mesma classe outra vez com o alocador restaurado e exercite-a: para uma classe de rich text, acrescente um run, ponha-lhe o texto, e confirme que o texto simples volta como esperado. Verificações de ciclo de vida normais acompanham, como salvar uma pasta de trabalho com runs formatados e comentários para um ficheiro e para um stream, libertar a pasta de trabalho fonte, 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, por isso meça na build que distribui. O HotXLS aplica a mesma regra nil-safe por toda a biblioteca, das coleções de estilos, gráficos e pivot às tabelas de hash, ao writer de zip e ao exportador PDF. As tabelas de hash merecem uma nota específica: uma tabela que mude a sua máscara antes de uma realocação que depois falha deixa o seu destrutor a andar pela memória para além da tabela antiga, por isso a máscara só pode mudar depois de o novo armazenamento existir. Um caso assenta na própria RTL: o TInterfaceList falha no seu destrutor quando a sua alocação interna falha. Nada disto equivale a rollback de uma operação arbitrária que falha a meio, e não é uma garantia geral de recuperação de falta de memória
O que muda isto para código que abre pastas de trabalho grandes?
Na prática, o EOutOfMemory destes caminhos de construção é um sinal que pode apanhar e tratar, em vez de uma violação de acesso que tem de considerar um 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 re-levantadas, que é o comportamento descrito em o artigo de diagnósticos estruturados do HotXLS. Um wrapper de repetição parece-se com isto:
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 posse passa para o chamador
Exit;
finally
Book.Free; // nil-safe após a entrega
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // o seu código: largar caches, outras pastas
end;
end;
end;
end;
Alguns detalhes nesse wrapper são deliberados. Cada tentativa constrói uma TXLSXWorkbook fresca, porque destrutores nil-safe tornam as falhas de construção limpas mas não tornam um Open meio feito resumível, por isso a instância falhada é simplesmente libertada. Só o EOutOfMemory é repetido; EWorkbookOpenFailed, erros de password e tudo o resto propaga à primeira tentativa. Um EAccessViolation nunca deve ser repetido de todo, já que sinaliza um defeito e não uma escassez de recursos. E a repetição só faz sentido se algo mudar entre tentativas, e é por isso que o gestor liberta memória primeiro. Se precisar do rasto de diagnóstico da tentativa falhada, leia Book.Diagnostics antes de o bloco finally a libertar
Repetir é uma mitigação, não um plano de capacidade. Um processo de 32 bits que continua a levar com EOutOfMemory em ficheiros grandes normalmente anda curto de espaço de endereçamento contíguo em vez de memória física, e a resposta duradoura é uma build Win64. O mesmo raciocínio aplica-se no lado da escrita, onde as gravações crash-safe do HotXLS com ficheiros temporários escalonados impedem que uma gravação falhada danifique o ficheiro existente. Se várias threads partilham uma pasta de trabalho, os read leases e write guards do HotXLS descrevem como o acesso a essa instância é coordenado
Uma checklist para as suas próprias classes Delphi
As guardas em si são uma linha cada, e a mesma auditoria leva minutos no seu próprio código. Procure destrutores que chamem um método Clear, Reset ou ClearCore antes de libertar campos, depois verifique se esse método toca num campo que o construtor pode não ter alcançado. Procure loops sobre listas possuídas que não sejam precedidos de uma verificação nil. Procure construtores que apanhem exceções, e remova tanto o engolir como o Free manual. Se quiser prova em vez de inspeção, o gestor de memória contador 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 funcionalidades, versões Delphi e C++Builder suportadas e licenciamento, veja a página do produto do HotXLS Delphi Excel component