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 a cero en cada llamada. Esa variable Result oculta empieza a cero exactamente una vez, y nada la vuelve a poner a cero automáticamente entre llamadas, así que limpiarla al entrar es trabajo de la propia función. Hacedlo con FillChar(Result, SizeOf(Result), 0) y, desde la segunda llamada en adelante, la rutina sobrescribe una referencia viva de cadena o de array dinámico en lugar de liberarla, dejando huérfano cualquiera que sea el bloque del montón al que apuntaba esa referencia

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

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

FillChar filtra cadenas porque no tiene ni 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 precisamente lo que hace que FillChar sea rápido y de propósito general, porque nunca inspecciona el tipo de X ni se ramifica según lo que signifiquen los bytes subyacentes. Un campo UnicodeString o WideString dentro de un registro no son los caracteres en sí; es un puntero a un bloque del montón que lleva un recuento 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 igual que sobrescribiría un campo Integer o Double. El puntero desaparece, el recuento de referencias que debería haberse decrementado primero nunca se toca, y el bloque al que apuntaba queda reservado sin nada que ya lo referencie

Cómo rastrea el compilador las cadenas y los arrays 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 ámbito. Los tipos de cadena larga como AnsiString, UnicodeString y WideString califican, y también lo hacen los arrays dinámicos, las interfaces y los Variant, junto con cualquier registro o array de tamaño fijo que contenga uno de esos como campo. Para cada campo gestionado, el compilador emite discretamente la contabilidad que de otro modo sería tediosa y fácil de hacer mal a mano: incrementar un recuento de referencias en la asignación, decrementarlo cuando la variable contenedora se sobrescribe o sale de ámbito, y liberar el bloque subyacente en cuanto ese recuento llega a cero. Esa maquinaria es la razón por la que el código Pascal ordinario nunca reserva ni libera manualmente una string, y por la que asignar un array 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 a demanda, y son lo que el código de limpieza de un registro debería llamar en lugar de un relleno de memoria en bruto

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é empieza el fallo de memoria solo en la segunda llamada?

La primera llamada en un bucle siempre es inofensiva, que es precisamente lo que hace fácil pasar por alto este defecto en las pruebas. Una variable local de un tipo de registro gestionado empieza a cero, y nada la vuelve a poner a 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 recuento de referencias, y la llamada regresa con un aspecto totalmente correcto. La segunda llamada es distinta: la misma variable local ya contiene lo que fuera que la primera llamada escribió en ella, 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 principio de esa segunda llamada pone a cero un campo que ya no es nil, y todo lo que dependa aguas abajo de ese patrón de bytes está silenciosamente mal desde entonces. Una prueba que llame a la función una sola vez e inspeccione el resultado nunca verá el problema; solo un bucle, o cualquier vía de código que llame a la función repetidamente contra el mismo destino, lo expone

Un fallo de memoria 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 devuelven cada una un registro que contiene 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 array dinámico Points. Las tres empezaban con la misma forma mostrada abajo: limpiar Result con un FillChar en bruto, y luego rellenar los campos uno a uno a partir de los datos de página subyacentes. Recorrer cada anotación de una página una a una, la forma habitual 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 posterior a la primera; un PDF elaborado con un número inusualmente grande de anotaciones con texto podía hacer crecer la memoria de un proceso de larga duración durante todo el tiempo que ese proceso siguiera ejecutándose. La solución tocó una línea en cada función: sustituir FillChar(Result, SizeOf(Result), 0) por Result := Default(TPdfAnnotation) bastó, porque asignar Default a un registro gestionado ejecuta la secuencia ordinaria del compilador de liberar-y-luego-limpiar en lugar de un relleno de memoria en bruto

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 recibe su salida como var Data: TBookmark y solía limpiar Data al principio 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 se aplique al propio Result de una función se aplica igual de directamente a cualquier rutina auxiliar que lo reciba por referencia. Revisar solo las funciones que declaran literalmente un tipo de retorno de registro pasa por alto esta forma; la búsqueda también tiene que seguir 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 algo más barato, para un registro construido enteramente a partir de ordinales, campos de coma flotante, arrays de tamaño fijo de esos, u otros registros planos hechos de lo mismo, porque no hay nada en él que el compilador tenga que finalizar. El propio tipo de 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 recuento de referencias que liberar. La comprobación que separa los dos casos es sencilla de enunciar: ¿tiene algún campo del registro, a cualquier profundidad de anidamiento, el tipo string, AnsiString, WideString, un array dinámico, una interfaz o un Variant? Un registro puede parecer perfectamente numérico en el nivel superior y aun así fallar esa prueba si uno de sus campos es en sí mismo un registro que esconde una cadena unas cuantas 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 una base de código existente en busca de este patrón es mecánico más que exhaustivo: buscad cada llamada a FillChar cuyo objetivo sea una variable de registro, y luego comprobad la lista de campos de ese registro contra la lista de tipos gestionados de arriba. La propia auditoría de PDFiumPas para la v1.56.4 ejecutó exactamente esa búsqueda en toda la biblioteca y encontró esta exposición en una unidad; cualquier otro punto de llamada a FillChar ya estaba limpiando un registro numérico plano, donde FillChar era, y sigue siendo, la herramienta correcta

El mismo comportamiento del compilador que hace peligroso aquí un Result reutilizado también impulsa una familia relacionada de discrepancias entre Delphi y FPC en otra parte de esta base de código; un artículo complementario sobre trampas entre compiladores cubre un caso donde FPC y Delphi discrepan sobre exactamente cuándo se finaliza un temporal de resultado de registro dentro de una única 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 a página que escribiríais 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 un fallo de memoria lento en primer lugar

Nada de esto requiere cambiar de biblioteca ni perseguir un fallo en código compilado de otra persona: es una propiedad del propio lenguaje Object Pascal, una con la que trabaja a diario cada desarrollador Delphi y FPC, y la solución es una única llamada a función en cuanto sabéis qué buscar. Las API de anotaciones, marcadores y anotaciones de enlace descritas aquí forman 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 otra parte de este blog