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
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
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
FreeoFreeAndNil, necesita un check deniloAssigned, incluidas las llamadas a helpers comoClearoClearCoreque iteran el campo - Libera los objetos propios con
FreeoFreeAndNil, nunca llamando aDestroydirectamente, que se salta el test denil - Deja
inherited Destroycomo ú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
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