Artículo técnico

Excepciones de constructores y destructores nil-safe

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

La clase de bug es vieja y fácil de perder. Un servicio convierte un libro de 300 MB en un servidor ocupado, la memoria se agota, y el log muestra una access violation dentro de un destructor. Nada en ese mensaje dice «sin memoria», nada sugiere que reintentar más tarde funcione, y el stack trace apunta a un destructor que parece perfectamente correcto. La excepción original existió unos microsegundos y luego fue sustituida. Este artículo recorre por qué ocurre eso, cómo estructura HotXLS sus destructores para evitarlo, y cómo escribir tus propias clases para que jamás lo hagan

¿Qué pasa cuando un constructor de Delphi lanza?

El objeto se destruye por ti antes de que la excepción llegue a quien llama, y tu 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 rellena con 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 tu Destroy, luego FreeInstance libera la memoria, y por último la excepción se relanza. AfterConstruction nunca corre, y la variable a la izquierda de X := TFoo.Create jamás 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 haya alcanzado

O sea que el destructor ve un objeto que se paró en una línea arbitraria de su constructor. Los campos asignados antes del fallo contienen objetos reales. Los posteriores siguen en nil. Un destructor escrito asumiendo que la construcción terminó desreferenciará uno de esos campos nil y lanzará una access violation propia

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

¿De dónde sale la access violation secundaria?

Sale de un destructor que asume que su constructor terminó y toca un campo lista que está nil cuando la primera 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 a 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 guardan esos campos:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // el constructor puede haberse parado 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 falló TList.Create
  // ... 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 falla NewInstance, no corre cuerpo de constructor ni se llama a destructor, así que ese caso siempre sale limpio. La segunda asignación es el TList. Cuando falla, FRuns o FList se queda en nil, la RTL llama a Destroy, y un Destroy sin guarda lee Count a través de una referencia nil, que es exactamente la access violation secundaria. Los fallos posteriores dentro de TXLSXCells.Create son inofensivos, porque una vez que FList existe, Clear comprueba FPackedStorage <> nil antes de usar el almacenamiento empaquetado

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

Estas clases importan porque se crean a granel. Cada hoja cargada construye una colección TXLSXCells, y cada celda o comentario con runs formateados construye un TXLSXRichText. Un libro grande con rich text abundante 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 comprueba 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 lee primero el puntero VMT de la instancia. Ambas 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 puestos antes de ellas son donde vive el problema. Aquí está el patrón en una clase pequeña tuya:

type
  TStyledText = class
  private
    FRuns: TList;
    FIndex: TStringList;
  public
    constructor Create;
    destructor Destroy; override;
  end;

constructor TStyledText.Create;
begin
  inherited Create;
  FRuns := TList.Create;          // un 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   // access violation cuando FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

El destructor corregido guarda cada desreferencia y libera los campos solo mediante 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 lo bastante cortas para aplicarlas en una revisión de código:

  • Todo uso de un campo propio en un destructor, salvo Free o FreeAndNil, necesita un check de nil o Assigned, incluidas las llamadas a helpers como Clear o ClearCore que iteran el campo
  • Libera los objetos propios con Free o FreeAndNil, nunca llamando a Destroy directamente, que se salta el test de nil
  • Deja inherited Destroy como última sentencia, para que el ancestro desmonte sus propios campos cuando los tuyos ya se han ido
  • No raciones desde el orden de campos en el constructor; el siguiente mantenedor insertará una asignación por encima de la tuya y moverá el punto de fallo en silencio

Dónde va el inherited Create

Llama a inherited Create primero, como hacen ambos constructores de HotXLS. Los campos del ancestro existen entonces antes de que corra tu código, y la imagen especular del destructor, inherited Destroy al final, desenvuelve en orden inverso. Si el constructor del ancestro lanza por sí mismo, tu destructor corre igualmente con todos tus 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 merecen quedarse como documentación

No cacen ni liberen dentro del constructor

Dos patrones bienintencionados empeoran esto. El primero es envolver el cuerpo del constructor en try ... except Free; raise; end: la RTL va a llamar al destructor de todos modos, 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 muda a alguna llamada de método sin relación mucho después. Si un constructor crea un temporal que no conserva, libera ese temporal en un try ... finally local y deja que la excepción siga hacia arriba intacta

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

Falla cada punto de asignación de un constructor a propósito, uno cada vez, y exige EOutOfMemory siempre, seguido de una construcción normal con éxito. 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. Cuenta primero las asignaciones que hace un constructor en tu build objetivo, y luego ejecuta un fallo inyectado por ordinal

Tras cada fallo inyectado, construye la misma clase otra vez con el allocator restaurado y ejercítala: para una clase de rich text, añade un run, pon su texto, y confirma que el texto plano vuelve como se espera. Los checks normales de ciclo de vida van al lado, como guardar un libro con runs formateados y comentarios a un archivo y a un stream, liberar el libro fuente, y reabrir y comparar ambas copias

Los recuentos de asignaciones pertenecen a un compilador y plataforma; otro compilador o RTL puede asignar distinto, así que mide en el build que distribuyes. HotXLS aplica la misma regla nil-safe por toda la biblioteca, desde las colecciones de estilos, gráficas y pivotes 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 está 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 de falta de memoria

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

En la práctica, un EOutOfMemory de estas rutas de construcción es una señal que puedes cazar y sobre la que actuar, en vez de una access violation que tienes que tratar como crash. TXLSXWorkbook.Create no toma parámetros, y TXLSXWorkbook.Open(const FileName: WideString): Integer devuelve 1 en éxito y -1 en fallo. Las excepciones lanzadas durante la carga se anotan en la colección Diagnostics del libro como xlsDiagnosticUnhandledException y luego se relanzan, que es el comportamiento descrito en el artículo de diagnósticos estructurados 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;              // tu código: suelta 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 dejan limpios los fallos de construcción pero no hacen reanudable un Open a medio terminar, así que la instancia fallida sencillamente se libera. Solo se reintenta EOutOfMemory; EWorkbookOpenFailed, los errores de contraseña y cualquier otra cosa se propagan en el primer intento. Un EAccessViolation no debería reintentarse jamás, porque señala un defecto, 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 necesitas la pista de diagnóstico del intento fallido, lee Book.Diagnostics antes de que el bloque finally lo libere

Matriz de decisión de reintentos en Delphi con HotXLS: el EOutOfMemory se reintenta con un TXLSXWorkbook nuevo, los demás fallos se propagan al instante, una access violation jamás se reintenta
cada intento construye un libro nuevo porque un Open a medio terminar no es reanudable, y solo el EOutOfMemory gana jamás un segundo intento

Reintentar es una mitigación, no un plan de capacidad. Un proceso de 32 bits que se topa una y otra vez con EOutOfMemory en archivos grandes suele estar corto de espacio de direcciones contiguo más que de memoria física, y la respuesta duradera es un build Win64. El mismo razonamiento aplica en el lado de escritura, donde los guardados crash-safe de HotXLS con temp files escalonados evitan que un guardado fallido dañe el archivo existente. Si varios hilos comparten un libro, los read leases y write guards de HotXLS describen cómo se coordina el acceso a esa instancia

Una checklist para tus propias clases Delphi

Las guardas en sí son una línea cada una, y la misma auditoría lleva minutos sobre tu propio código. Busca destructores que llamen a un método Clear, Reset o ClearCore antes de liberar campos, y luego comprueba si ese método toca un campo al que el constructor quizá no llegó. Busca bucles sobre listas propias que no vayan precedidos de un check de nil. Busca constructores que cacen excepciones, y quita tanto el trago como el Free manual. Si quieres pruebas en vez de inspección, el memory manager de conteo descrito arriba cabe en una página de código y convierte cada asignación de un constructor en un caso de test

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