Artículo técnico

FillChar sobre un resultado de función filtra cadenas en Object Pascal

Una función Delphi o FPC que devuelve un registro no obtiene un Result nuevo y en cero en cada llamada. Esa variable Result oculta empieza en cero exactamente una vez, y nada la vuelve a poner en cero automáticamente entre llamadas, así que limpiarla al entrar es trabajo de la propia función. Haga esa limpieza con FillChar(Result, SizeOf(Result), 0) y, desde la segunda llamada en adelante, la rutina sobrescribe una referencia viva de cadena o arreglo dinámico en lugar de liberarla, dejando huérfano cualquiera que sea el bloque del heap al que apuntaba esa referencia

El escenario donde esto muerde es mundano. Un proceso por lotes abre una pila de PDF de terceros y recorre cada anotación en cada página, extrayendo el texto del comentario hacia un log de auditoría. Nada en ese bucle se ve peligroso: cada llamada es una simple función que devuelve un simple registro, ningún puntero a la vista, nada que se parezca a gestión manual de memoria en absoluto. El conteo de referencias dentro de un registro es una regla de contabilidad simple de Object Pascal, no una peculiaridad específica de ninguna biblioteca en particular, y cualquier código base Delphi o FPC que mezcle FillChar con tipos de registro que contengan cadenas o arreglos dinámicos está expuesto al mismo defecto

¿Por qué FillChar sobre el resultado de un registro filtra cadenas?

FillChar filtra cadenas porque no tiene idea de qué tipo de datos está sobrescribiendo. FillChar(X, Count, Value) funciona sobre cualquier variable en absoluto: toma un bloque sin tipo de Count bytes y estampa cada uno de ellos con Value, y ese es todo el contrato. Esto es exactamente lo que hace a FillChar rápido y de propósito general, porque nunca inspecciona el tipo de X y nunca bifurca según lo que signifiquen los bytes subyacentes. Un campo UnicodeString o WideString dentro de un registro no son los caracteres mismos; es un puntero a un bloque del heap que lleva un conteo de referencias por delante de los datos de carácter. FillChar ve un puñado de bytes que resultan contener un valor de puntero y los sobrescribe con cero exactamente como sobrescribiría un campo Integer o Double. El puntero desaparece, el conteo de referencias que debería haber decrementado primero nunca se toca, y el bloque al que apuntaba queda asignado sin nada que le siga haciendo referencia

Cómo rastrea el compilador las cadenas y arreglos dinámicos dentro de un registro

Object Pascal llama gestionado a un tipo cuando el compilador tiene que ejecutar código adicional para mantenerlo correcto a través de la asignación y la salida de alcance. Los tipos de cadena larga como AnsiString, UnicodeString, y WideString califican, y también lo hacen los arreglos dinámicos, las interfaces, y los Variant, junto con cualquier registro o arreglo de tamaño fijo que contenga uno de esos como campo. Para cada campo gestionado, el compilador silenciosamente emite la contabilidad que de otro modo sería tediosa y fácil de equivocar a mano: incrementar un conteo de referencias al asignar, decrementarlo cuando la variable que lo contiene se sobrescribe o sale de alcance, y liberar el bloque subyacente una vez que ese conteo llega a cero. Esa maquinaria es por qué el código Pascal ordinario nunca asigna o libera manualmente un string, y por qué asignar un arreglo dinámico a otro es una operación barata y segura en lugar de un bucle de copia manual. System.Default y Finalize son las dos formas documentadas de invocar esa misma lógica de liberación bajo demanda, y son lo que el código de limpieza de un registro debería llamar en lugar de un relleno de memoria crudo

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 qué la fuga solo empieza en la segunda llamada?

La primera llamada en un bucle siempre es inofensiva, que es exactamente lo que hace fácil pasar por alto este defecto en las pruebas. Una variable local de un tipo de registro gestionado empieza en cero, y nada la vuelve a poner en cero automáticamente entre una pasada de bucle y la siguiente, así que la primera vez que un bucle asigna el valor de retorno de una función a esa variable, su campo Description o ContentsText todavía es nil. FillChar sobrescribe nil con cero, lo que no cambia nada en lo que respecta al conteo de referencias, y la llamada retorna viéndose completamente correcta. La segunda llamada es distinta: la misma variable local ya contiene lo que sea que escribió en ella la primera llamada, y el Result de la nueva llamada se escribe directamente en ese mismo almacenamiento en lugar de en memoria nueva y vacía. FillChar al inicio de esa segunda llamada pone en cero un campo que ya no es nil, y todo lo que sigue a ese patrón de bytes queda silenciosamente equivocado a partir de entonces. Una prueba que llama a la función una vez e inspecciona el resultado nunca verá el problema; solo un bucle, o cualquier ruta de código que llame a la función repetidamente contra el mismo destino, lo expone

Una fuga real: anotaciones, marcadores, y registros de enlace

PDFiumPas incluía exactamente este defecto antes de la versión 1.56.4, en tres funciones que cada una devuelve un registro con al menos un campo gestionado: el lector de anotaciones a nivel de página devuelve un TPdfAnnotation que lleva las cadenas ContentsText y AuthorText, el lector de marcadores devuelve un TBookmark que lleva una cadena Title, y el lector de anotaciones de enlace devuelve un TLinkAnnotation que lleva una cadena ActionPath y un arreglo dinámico Points. Las tres empezaban con la misma forma mostrada abajo: limpiar Result con un FillChar crudo, y luego llenar los campos uno a uno a partir de los datos subyacentes de la página. Recorrer cada anotación de una página de una en una, la forma ordinaria de construir una lista de auditoría o un panel de revisión, llamaba al lector de anotaciones en un bucle y filtraba el texto de la anotación anterior en cada pasada después de la primera; un PDF creado con una cantidad inusualmente grande de anotaciones con texto podía hacer crecer la memoria de un proceso de larga duración mientras ese proceso siguiera ejecutándose. La corrección tocó una línea en cada función: reemplazar FillChar(Result, SizeOf(Result), 0) con Result := Default(TPdfAnnotation) fue suficiente, porque asignar Default a un registro gestionado ejecuta la secuencia ordinaria de liberar-luego-limpiar del compilador en lugar de un relleno de memoria crudo

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;

El mismo peligro detrás de un parámetro var

El lector de marcadores muestra una versión más sutil del mismo problema, porque el registro que se limpia con FillChar no es el propio Result de la función sino un parámetro var una llamada más abajo. SetBookmarkData toma su salida como var Data: TBookmark y solía limpiar Data al inicio de su cuerpo con FillChar; GetBookmark, la función pública que realmente devuelve un TBookmark, llama a SetBookmarkData y pasa su propio Result directamente como ese argumento var. Un parámetro var se pasa por referencia, así que Data dentro de SetBookmarkData y Result dentro de GetBookmark son el mismo almacenamiento bajo dos nombres, y cualquier riesgo de alias que aplique al propio Result de una función aplica igual de directamente a cualquier rutina auxiliar que lo reciba por referencia. Revisar solo las funciones que literalmente declaran un tipo de retorno de registro pasa por alto esta forma; la búsqueda tiene que seguir también cada parámetro var y out al que se reenvíe un Result

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;

¿Cuándo sigue siendo correcto usar FillChar?

FillChar sigue siendo correcto, y a menudo un poco más barato, para un registro construido enteramente a partir de ordinales, campos de punto flotante, arreglos de tamaño fijo de esos, u otros registros simples hechos de lo mismo, porque no hay nada en él para que el compilador finalice. El propio tipo rectángulo de PDFiumPas es exactamente ese caso: TPdfRectangle contiene cuatro campos Double y nada más, y limpiar uno con FillChar no libera nada porque no hay nada con conteo de referencias que liberar. La comprobación que separa los dos casos es simple de plantear: ¿algún campo del registro, a cualquier profundidad de anidamiento, tiene tipo string, AnsiString, WideString, un arreglo dinámico, una interfaz, o un Variant? Un registro puede verse perfectamente numérico en el nivel superior y aun así fallar esa prueba si uno de sus campos es a su vez un registro que entierra una cadena unas capas más abajo, así que la comprobación tiene que seguir los registros anidados hasta el final en lugar de detenerse en la lista de campos más externa. Auditar un código base existente en busca de este patrón es mecánico en lugar de exhaustivo: buscar cada llamada a FillChar cuyo destino sea una variable de registro, y luego comprobar la lista de campos de ese registro contra la lista de tipos gestionados de arriba. La propia auditoría de v1.56.4 de PDFiumPas ejecutó exactamente esa búsqueda a través de toda la biblioteca y encontró esta exposición en una unidad; cada otro punto de llamada a FillChar ya estaba limpiando un registro numérico simple, donde FillChar era, y sigue siendo, la herramienta correcta

El mismo comportamiento del compilador que hace peligroso un Result reutilizado aquí también impulsa una familia relacionada de desacuerdos entre Delphi y FPC en otra parte de este código base; un artículo complementario sobre trampas entre compiladores cubre un caso donde FPC y Delphi no están de acuerdo sobre exactamente cuándo se finaliza un temporal de resultado de registro dentro de una sola expresión, un síntoma distinto del mismo hecho subyacente de que el Result de registro de una función no siempre es el almacenamiento nuevo y privado que parece ser. El bucle de anotaciones usado como ejemplo recurrente a lo largo de este artículo tampoco es hipotético: es el mismo recorrido página por página que se escribiría al construir un panel de revisión de anotaciones, que es exactamente la forma de código que convirtió un FillChar de una línea en una fuga de memoria lenta en primer lugar

Nada de esto requiere cambiar de biblioteca ni perseguir un bug en el código compilado de alguien más: es una propiedad del propio lenguaje Object Pascal, una con la que trabaja a diario cada desarrollador Delphi y FPC, y la corrección es una sola llamada de función una vez que se sabe qué buscar. Las API de anotaciones, marcadores, y anotaciones de enlace descritas aquí se incluyen como parte del componente PDFium para Delphi, C++Builder, y Lazarus/FPC, junto con el resto de la superficie de lectura, renderizado, y anotación de PDF cubierta en otras partes de este blog