Artigo Técnico

Exceções de construtor Delphi e destrutores nil-safe

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

Ciclo de vida de objeto Delphi mostrando como a RTL chama Destroy numa instância HotXLS meio construída quando um construtor levanta, antes de a exceção original chegar ao chamador
quando um construtor Delphi levanta, a RTL destrói primeiro a instância meio construída e só depois relança a exceção original, então o Destroy roda contra o estado que a construção alcançou

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

Mapa da ordem de alocação para construtores HotXLS no Delphi: um NewInstance que falha é limpo, um TList.Create que falha deixa o campo nil, falhas posteriores seguem protegidas
só a segunda alocação importa: a falha da TList deixa o campo nil, e um Destroy desprotegido é o que transforma o EOutOfMemory numa access violation

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 Free ou FreeAndNil, precisa de uma checagem nil ou Assigned, incluindo chamadas a helpers como Clear ou ClearCore que iteram o campo
  • Libere objetos próprios com Free ou FreeAndNil, nunca chamando Destroy diretamente, que pula o teste de nil
  • Mantenha o inherited Destroy como 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

Matriz de decisão de retry no Delphi com HotXLS: EOutOfMemory é retentado com um TXLSXWorkbook novo, outras falhas propagam de imediato, uma access violation nunca é retentada
cada tentativa constrói uma pasta de trabalho nova porque um Open pela metade não é retomável, e só o EOutOfMemory jamais ganha uma segunda tentativa

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