Artículo técnico

Constructores que fallan y destructores nil-safe en Delphi

Cuando un constructor de Delphi lanza una excepción, la RTL llama inmediatamente al destructor sobre la instancia a medio construir, así que cada destructor debe lidiar con campos que nunca se asignaron. HotXLS aplica esa regla a sus destructores, incluidos TXLSXRichText y TXLSXCells: un fallo de asignación dentro de esos constructores llega a su código como el EOutOfMemory original, no como una violación de acceso secundaria lanzada durante la limpieza

La clase de bug es vieja y fácil de pasar por alto. Un servicio convierte un workbook de 300 MB en un servidor ocupado, la memoria se acaba, y el log muestra una violación de acceso dentro de un destructor. Nada en ese mensaje dice "sin memoria", nada sugiere que reintentar más tarde funcionaría, y el stack trace apunta a un destructor que se ve perfectamente correcto. La excepción original existió por unos microsegundos y luego fue reemplazada. Este artículo recorre por qué pasa eso, cómo estructura HotXLS sus destructores para evitarlo, y cómo escribir sus propias clases para que nunca lo hagan

¿Qué pasa cuando un constructor de Delphi lanza?

El objeto se destruye por usted antes de que la excepción llegue a quien llama, y su destructor corre contra el estado que el constructor haya alcanzado. La secuencia la fijan el compilador y la RTL. NewInstance asigna la instancia y InitInstance la llena de ceros, así que cada referencia a objeto arranca como nil, cada entero como 0 y cada cadena como vacía. El cuerpo del constructor corre entonces dentro de un manejador de excepciones implícito. Si algo en ese cuerpo lanza, el código generado llama al destructor virtual: corre BeforeDestruction, luego su Destroy, luego FreeInstance libera la memoria, y finalmente la excepción se relanza. AfterConstruction nunca corre, y la variable a la izquierda de X := TFoo.Create nunca se asigna

Ciclo de vida de un objeto Delphi que muestra cómo la RTL llama a Destroy sobre una instancia HotXLS a medio construir cuando un constructor lanza, antes de que la excepción original llegue a quien llama
cuando un constructor de Delphi lanza, la RTL destruye primero la instancia a medio construir y solo entonces relanza la excepción original, así que Destroy corre contra el estado que la construcción alcanzó

Así que el destructor ve un objeto que se detuvo en una línea arbitraria de su constructor. Los campos asignados antes del fallo contienen objetos reales. Los posteriores siguen siendo nil. Un destructor escrito suponiendo que la construcción terminó desreferenciará uno de esos campos nil y lanzará una violación de acceso propia

Esa segunda excepción es el daño real. Delphi no encadena excepciones lanzadas durante esta limpieza: la violación de acceso reemplaza al EOutOfMemory, la excepción original se descarta, y quien llama solo ve la AV. El diagnóstico útil, "falló una asignación, reintente con más memoria", desaparece, y en su lugar queda algo que parece corrupción de memoria

¿De dónde viene la violación de acceso secundaria?

Viene de un destructor que asume que su constructor completó y toca un campo de lista que está nil cuando la primerísima asignación interna falla. Dos clases de HotXLS muestran la forma con claridad. Tanto el TXLSXRichText.Create independiente como la sobrecarga con dueño de comentario TXLSXRichText.Create(AOwner: TXLSXComment) llaman inherited Create y luego asignan FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) hace lo mismo con FList := TList.Create antes de construir sus tablas de cadenas empaquetadas y su almacenamiento de celdas empaquetado. En Lib/lxHandleX.pas los dos destructores protegen esos campos:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // el constructor puede haberse detenido antes de 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 es nil si TList.Create falló
  // ... el almacenamiento empaquetado, las tablas de cadenas y FList se liberan con Free
  inherited Destroy;
end;

El punto de fallo es preciso. Contando las asignaciones de heap durante la construcción, la primera es la propia instancia; si NewInstance falla, no corre ningún cuerpo de constructor y no se llama a ningún destructor, así que ese caso siempre queda limpio. La segunda asignación es el TList. Cuando falla, FRuns o FList queda nil, la RTL llama a Destroy, y un Destroy sin guarda lee Count a través de una referencia nil, que es exactamente la violación de acceso secundaria. Los fallos posteriores dentro de TXLSXCells.Create son inofensivos, porque una vez que FList existe, Clear verifica FPackedStorage <> nil antes de usar el almacenamiento empaquetado

Mapa del orden de asignaciones para los constructores de HotXLS en Delphi: un NewInstance fallido queda limpio, un TList.Create fallido deja el campo en nil, los fallos posteriores quedan protegidos
solo la segunda asignación importa: el fallo del TList deja el campo en nil, y un Destroy sin guarda es lo que convierte el EOutOfMemory en una violación de acceso

Estas clases importan porque se crean en masa. Cada worksheet cargado construye una colección TXLSXCells, y cada celda o comentario con runs con formato construye un TXLSXRichText. Un workbook grande con rich text pesado crea miles de ellos, así que bajo presión de memoria la asignación que finalmente falla tiene una probabilidad real de caer en una de estas líneas

Free es nil-safe, el acceso a campos no

TObject.Free verifica Self <> nil antes de llamar a Destroy, así que FRuns.Free sobre un campo nil es un no-op. Leer FRuns.Count no lo es: Count lee un campo en un offset desde el puntero de instancia, y una llamada virtual como FRuns.Clear primero lee el puntero VMT de la instancia. Ambos fallan con nil. Esa asimetría es toda la trampa. Las llamadas de liberación en un destructor típico ya son seguras; los bucles y las llamadas de limpieza puestas antes de ellas son donde vive el problema. Aquí está el patrón en una clase pequeña propia:

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 aquí deja FRuns = nil
  FIndex := TStringList.Create;   // ... y aquí deja FIndex = nil
end;

destructor TStyledText.Destroy;   // MAL: asume que el constructor terminó
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // violación de acceso cuando FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

El destructor corregido protege cada desreferencia y libera los campos solo por llamadas 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;

Las reglas que se desprenden de esto son cortas como para aplicarlas en una revisión de código:

  • Todo uso de un campo propio en un destructor, aparte de Free o FreeAndNil, necesita una verificación nil o Assigned, incluidas llamadas a helpers como Clear o ClearCore que iteran el campo
  • Libere objetos propios con Free o FreeAndNil, nunca llamando a Destroy directamente, que se salta la prueba de nil
  • Deje inherited Destroy como última sentencia, para que el ancestro desmonte sus propios campos cuando los suyos ya se fueron
  • No razone desde el orden de campos en el constructor; el siguiente mantenedor insertará una asignación encima de la suya y moverá silenciosamente el punto de fallo

Dónde pertenece el inherited Create

Llame inherited Create primero, como hacen ambos constructores de HotXLS. Los campos del ancestro existen entonces antes de que corra su código, y la imagen espejo del destructor, inherited Destroy al final, se desenvuelve en orden inverso. Si el constructor del ancestro mismo lanza, su destructor igual corre con todos sus campos en nil, lo que un destructor nil-safe maneja sin casos especiales. Las asignaciones explícitas como FOwner := nil en un constructor son redundantes tras InitInstance, pero inofensivas y a veces valen la pena como documentación

No capture ni libere dentro del constructor

Dos patrones bien intencionados empeoran esto. El primero es envolver el cuerpo del constructor en try ... except Free; raise; end: la RTL ya va a llamar al destructor, así que el objeto se destruye dos veces. El segundo es tragarse la excepción para "seguir adelante": quien llama recibe entonces una referencia de aspecto válido a un objeto con campos nil, y el fallo se mueve a alguna llamada de método no relacionada mucho después. Si un constructor crea un temporal que no conserva, libere ese temporal en un try ... finally local y deje que la excepción siga hacia arriba intacta

¿Cómo prueba las rutas de fallo del constructor?

Falle a propósito cada punto de asignación de un constructor, uno a la vez, y exija EOutOfMemory cada vez, seguido de una construcción normal exitosa. La herramienta para eso es un memory manager de reemplazo cuyo GetMem, AllocMem y ReallocMem cuentan las peticiones de tamaño positivo y devuelven nil en un ordinal elegido. Cuente primero las asignaciones que hace un constructor en su build objetivo, y luego ejecute un fallo inyectado por ordinal

Tras cada fallo inyectado, construya de nuevo la misma clase con el allocator restablecido y ejércitela: para una clase de rich text, agregue un run, ponga su texto, y confirme que el texto plano vuelve como se espera. Las verificaciones normales de ciclo de vida van al lado, como guardar un workbook con runs con formato y comentarios a un archivo y a un stream, liberar el workbook fuente, y reabrir y comparar ambas copias

Los conteos de asignación pertenecen a un compilador y plataforma; otro compilador o RTL puede asignar distinto, así que mida en el build que distribuye. HotXLS aplica la misma regla nil-safe en toda la biblioteca, desde colecciones de estilos, gráficos y tablas dinámicas hasta las tablas hash, el zip writer y el exportador PDF. Las tablas hash merecen una nota específica: una tabla que cambia su máscara antes de una reasignación que luego falla deja a su destructor caminando memoria más allá de la tabla vieja, así que la máscara debe cambiar solo cuando el nuevo almacenamiento ya existe. Un caso vive en la propia RTL: TInterfaceList falla en su destructor cuando su asignación interna falla. Nada de esto equivale a rollback de una operación arbitraria que falla a mitad, y no es una garantía general de recuperación ante falta de memoria

¿Qué cambia esto para código que abre workbooks grandes?

En la práctica, el EOutOfMemory de estas rutas de construcción es una señal que puede capturar y atender, en vez de una violación de acceso que tiene que tratar como crash. TXLSXWorkbook.Create no recibe parámetros, y TXLSXWorkbook.Open(const FileName: WideString): Integer devuelve 1 en éxito y -1 en fallo. Las excepciones lanzadas durante la carga se registran en la colección Diagnostics del workbook como xlsDiagnosticUnhandledException y luego se relanzan, que es el comportamiento descrito en el artículo de diagnóstico estructurado de HotXLS. Un wrapper de reintento se ve así:

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;          // sin argumentos de constructor
        if Book.Open(FileName) <> 1 then        // 1 = éxito, -1 = fallo
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // la propiedad pasa a quien llama
        Exit;
      finally
        Book.Free;                             // nil-safe tras una entrega
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // su código: suelte cachés, otros libros
      end;
    end;
  end;
end;

Algunos detalles de ese wrapper son deliberados. Cada intento construye un TXLSXWorkbook nuevo, porque los destructores nil-safe vuelven limpios los fallos de construcción pero no hacen reanudable un Open a medio terminar, así que la instancia fallida simplemente se libera. Solo EOutOfMemory se reintenta; EWorkbookOpenFailed, los errores de contraseña y cualquier otra cosa se propagan en el primer intento. Un EAccessViolation nunca debería reintentarse, porque señala un defecto y no una escasez de recursos. Y el reintento solo tiene sentido si algo cambia entre intentos, que es por lo que el manejador libera memoria primero. Si necesita el rastro de diagnóstico del intento fallido, lea Book.Diagnostics antes de que el bloque finally lo libere

Matriz de decisión de reintentos en Delphi con HotXLS: EOutOfMemory se reintenta con un TXLSXWorkbook nuevo, los demás fallos se propagan de inmediato, una violación de acceso nunca se reintenta
cada intento construye un workbook nuevo porque un Open a medio terminar no es reanudable, y solo EOutOfMemory se gana un segundo intento

Reintentar es una mitigación, no un plan de capacidad. Un proceso de 32 bits que se estrella una y otra vez contra EOutOfMemory con archivos grandes suele tener poco espacio de direcciones contiguo y no poca memoria física, y la respuesta durable es un build Win64. El mismo razonamiento aplica del lado de escritura, donde los guardados crash-safe de HotXLS con archivos temporales por etapas evitan que un guardado fallido dañe el archivo existente. Si varios hilos comparten un workbook, los read leases y write guards de HotXLS describen cómo se coordina el acceso a esa instancia

Una lista de verificación para sus propias clases Delphi

Las guardas mismas son una línea cada una, y la misma auditoría toma minutos sobre su propio código. Busque destructores que llamen a un método Clear, Reset o ClearCore antes de liberar campos, y luego verifique si ese método toca un campo que el constructor quizá no alcanzó. Busque bucles sobre listas propias que no estén precedidos de una verificación nil. Busque constructores que capturan excepciones, y elimine tanto el tragarse la excepción como el Free manual. Si quiere prueba en vez de inspección, el memory manager contador descrito arriba cabe en una página de código y convierte cada asignación de un constructor en un caso de prueba

Para el conjunto completo de funciones, las versiones soportadas de Delphi y C++Builder y el licenciamiento, vea la página del producto del componente Excel HotXLS para Delphi