Artigo Técnico

FillChar em um Resultado de Função Vaza Strings em Object Pascal

Uma função Delphi ou FPC que retorna um registro não recebe um Result novo e zerado a cada chamada. Essa variável Result oculta começa zerada exatamente uma vez, e nada a rezera automaticamente entre chamadas, de modo que limpá-la na entrada é trabalho da própria função. Faça essa limpeza com FillChar(Result, SizeOf(Result), 0) e, a partir da segunda chamada em diante, a rotina sobrescreve uma referência viva de string ou array dinâmico em vez de liberá-la, orfanando qualquer bloco de heap para o qual essa referência apontava

O cenário onde isso morde é mundano. Um processo em lote abre uma pilha de PDFs de terceiros e percorre cada anotação em cada página, puxando o texto de comentário para um log de auditoria. Nada nesse loop parece perigoso: cada chamada é uma simples função retornando um simples registro, nenhum ponteiro à vista, nada que se pareça com gerenciamento manual de memória de forma alguma. Contagem de referências dentro de um registro é uma regra de contabilidade comum do Object Pascal, não uma peculiaridade específica de nenhuma biblioteca, e qualquer código-base Delphi ou FPC que mistura FillChar com tipos de registro contendo strings ou arrays dinâmicos está exposto ao mesmo defeito

Por que o FillChar em um resultado de registro vaza strings?

O FillChar vaza strings porque não tem ideia de que tipo de dado está sobrescrevendo. FillChar(X, Count, Value) funciona em qualquer variável: recebe um bloco não tipado de Count bytes e carimba cada um deles com Value, e esse é o contrato inteiro. É exatamente isso que torna o FillChar rápido e de propósito geral, porque nunca inspeciona o tipo de X e nunca ramifica com base no que os bytes subjacentes significam. Um campo UnicodeString ou WideString dentro de um registro não são os caracteres em si; é um ponteiro para um bloco de heap que carrega uma contagem de referências antes dos dados de caractere. O FillChar vê um punhado de bytes que por acaso guardam um valor de ponteiro e os sobrescreve com zero exatamente como sobrescreveria um campo Integer ou Double. O ponteiro desaparece, a contagem de referências que deveria ter sido decrementada primeiro nunca é tocada, e o bloco para o qual apontava fica alocado sem nada mais o referenciando

Como o compilador rastreia strings e arrays dinâmicos dentro de um registro

O Object Pascal chama um tipo de gerenciado quando o compilador precisa rodar código extra para mantê-lo correto ao longo de atribuição e saída de escopo. Tipos de string longa como AnsiString, UnicodeString e WideString se qualificam, assim como arrays dinâmicos, interfaces e Variants, junto com qualquer registro ou array de tamanho fixo que contenha um desses como campo. Para cada campo gerenciado, o compilador silenciosamente emite a contabilidade que de outra forma seria tediosa e fácil de errar à mão: incrementar uma contagem de referências na atribuição, decrementá-la quando a variável que a detém é sobrescrita ou sai de escopo, e liberar o bloco subjacente assim que essa contagem chega a zero. Essa maquinaria é o motivo pelo qual código Pascal comum nunca aloca ou libera manualmente uma string, e por que atribuir um array dinâmico a outro é uma operação barata e segura, em vez de um loop de cópia manual. System.Default e Finalize são as duas formas documentadas de invocar essa mesma lógica de liberação sob demanda, e são o que o código de limpeza de um registro deveria chamar, em vez de um preenchimento bruto de memória

type
  TLineItem = record
    Description: string;  // managed: reference-counted
    Quantity: Integer;    // unmanaged: plain ordinal
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // clears bytes, not the reference
  Result.Quantity := Source[Index].Qty;
  Result.Description := Source[Index].Text;
end;

var
  Item: TLineItem;
  I: Integer;
begin
  for I := 0 to High(Source) do
  begin
    Item := GetLineItem(I);  // second pass onward: leaks the prior Description
    Log.Add(Item.Description);
  end;
end;

Por que o vazamento só começa na segunda chamada?

A primeira chamada em um loop é sempre inofensiva, que é exatamente o que torna esse defeito fácil de perder em teste. Uma variável local de um tipo de registro gerenciado começa zerada, e nada a rezera automaticamente entre uma passada de loop e a próxima, de modo que, na primeira vez que um loop atribui o valor de retorno de uma função a essa variável, seu campo Description ou ContentsText ainda é nil. O FillChar sobrescreve nil com zero, o que não muda nada no que diz respeito à contagem de referências, e a chamada retorna parecendo inteiramente correta. A segunda chamada é diferente: a mesma variável local já guarda o que quer que a primeira chamada tenha escrito nela, e o Result da nova chamada é escrito diretamente nesse mesmo armazenamento, em vez de em memória nova e vazia. O FillChar no topo dessa segunda chamada zera um campo que não é mais nil, e tudo rio abaixo desse padrão de bytes está silenciosamente errado a partir daí. Um teste que chama a função uma vez e inspeciona o resultado nunca vai ver o problema; só um loop, ou qualquer caminho de código que chama a função repetidamente contra o mesmo destino, o expõe

Um vazamento real: anotações, marcadores e registros de link

O PDFiumPas apresentava exatamente esse defeito antes da versão 1.56.4, em três funções que cada uma retorna um registro contendo pelo menos um campo gerenciado: o leitor de anotação em nível de página retorna um TPdfAnnotation carregando strings ContentsText e AuthorText, o leitor de marcador retorna um TBookmark carregando uma string Title, e o leitor de anotação de link retorna um TLinkAnnotation carregando uma string ActionPath e um array dinâmico Points. Todas as três abriam com a mesma forma mostrada abaixo: limpar Result com um FillChar bruto, depois preencher os campos um de cada vez a partir dos dados de página subjacentes. Percorrer cada anotação em uma página uma de cada vez, a forma comum de construir uma lista de auditoria ou um painel de revisão, chamava o leitor de anotação em um loop e vazava o texto da anotação anterior em cada passada depois da primeira; um PDF criado com um número incomumente grande de anotações portadoras de texto podia fazer crescer a memória de um processo de longa duração enquanto esse processo continuasse rodando. A correção tocou uma linha em cada função: substituir FillChar(Result, SizeOf(Result), 0) por Result := Default(TPdfAnnotation) foi suficiente, porque atribuir Default a um registro gerenciado roda a sequência comum do compilador de liberar-depois-limpar, em vez de um preenchimento bruto de memória

function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
  Annotation: FPDF_ANNOTATION;
  ContentLength: LongWord;
begin
  Annotation := FPDFPage_GetAnnot(Page, Index);
  FillChar(Result, SizeOf(Result), 0);   // clears bytes, not a live reference
  Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
  ContentLength := FPDFAnnot_GetStringValue(Annotation,
    FPDFANNOT_TEXTTYPE_Contents, nil, 0);
  if ContentLength >= 4 then
  begin
    SetLength(Result.ContentsText, ContentLength div 2 - 1);
    FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
      Pointer(Result.ContentsText), ContentLength);
  end;
end;

O mesmo risco por trás de um parâmetro var

O leitor de marcador mostra uma versão mais sutil do mesmo problema, porque o registro que é limpo com FillChar não é o próprio Result da função, mas um parâmetro var uma chamada abaixo. SetBookmarkData recebe sua saída como var Data: TBookmark e costumava limpar Data no topo de seu corpo com FillChar; GetBookmark, a função pública que de fato retorna um TBookmark, chama SetBookmarkData e passa seu próprio Result diretamente como esse argumento var. Um parâmetro var é passado por referência, de modo que Data dentro de SetBookmarkData e Result dentro de GetBookmark são o mesmo armazenamento sob dois nomes, e qualquer risco de aliasing que se aplique ao próprio Result de uma função se aplica igualmente diretamente a qualquer rotina auxiliar que o recebe por referência. Revisar apenas as funções que literalmente declaram um tipo de retorno de registro perde essa forma; a busca precisa seguir também cada parâmetro var e out para o qual um Result é encaminhado

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // fixed: was FillChar(Data, SizeOf(Data), 0)
  Data.Handle := Bookmark;
  if Bookmark <> nil then
  begin
    BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
    if BufferSize >= 4 then
    begin
      SetLength(Data.Title, BufferSize div 2 - 1);
      FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
    end;
  end;
end;

function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
  CheckActive;
  SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;

Quando o FillChar ainda é a chamada certa?

O FillChar ainda é correto, e geralmente um pouco mais barato, para um registro construído inteiramente de ordinais, campos de ponto flutuante, arrays de tamanho fixo desses, ou outros registros comuns feitos dos mesmos, porque não há nada nele para o compilador finalizar. O próprio tipo de retângulo do PDFiumPas é exatamente esse caso: TPdfRectangle guarda quatro campos Double e nada mais, e limpar um com FillChar não libera nada porque não há nada com contagem de referências para liberar. A verificação que separa os dois casos é simples de enunciar: algum campo do registro, em qualquer profundidade de aninhamento, tem o tipo string, AnsiString, WideString, um array dinâmico, uma interface, ou um Variant? Um registro pode parecer perfeitamente numérico no nível superior e ainda falhar nesse teste se um de seus campos for ele mesmo um registro que enterra uma string algumas camadas abaixo, de modo que a verificação precisa seguir registros aninhados até o fim, em vez de parar na lista de campos mais externa. Auditar um código-base existente em busca desse padrão é mecânico, e não exaustivo: busque cada chamada FillChar cujo alvo seja uma variável de registro, depois verifique a lista de campos desse registro contra a lista de tipos gerenciados acima. A própria auditoria da v1.56.4 do PDFiumPas rodou exatamente essa busca por toda a biblioteca e encontrou essa exposição em uma unit; todo outro ponto de chamada de FillChar já estava limpando um registro puramente numérico, onde o FillChar era, e continua sendo, a ferramenta certa

O mesmo comportamento do compilador que torna um Result reutilizado perigoso aqui também conduz uma família relacionada de divergências entre Delphi e FPC em outro lugar deste código-base; um artigo complementar sobre armadilhas entre compiladores cobre um caso onde o FPC e o Delphi discordam sobre exatamente quando um temporário de resultado de registro é finalizado dentro de uma única expressão, um sintoma diferente do mesmo fato subjacente de que o Result de registro de uma função nem sempre é o armazenamento novo e privado que parece ser. O loop de anotação usado como exemplo corrente ao longo deste artigo também não é hipotético: é o mesmo percurso página por página que você escreveria ao construir um painel de revisão de anotações, que é exatamente a forma de código que transformou um FillChar de uma linha em um vazamento de memória lento, para começo de conversa

Nada disso exige trocar de biblioteca ou perseguir um bug no código compilado de outra pessoa: é uma propriedade da própria linguagem Object Pascal, uma com a qual todo desenvolvedor Delphi e FPC trabalha diariamente, e a correção é uma única chamada de função assim que você sabe o que procurar. As APIs de anotação, marcador e anotação de link descritas aqui vêm como parte do Componente PDFium para Delphi, C++Builder e Lazarus/FPC, ao lado do restante da superfície de leitura, renderização e anotação de PDF coberta em outro lugar neste blog