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
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
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
FreeoFreeAndNil, necesita una verificaciónniloAssigned, incluidas llamadas a helpers comoClearoClearCoreque iteran el campo - Libere objetos propios con
FreeoFreeAndNil, nunca llamando aDestroydirectamente, que se salta la prueba denil - Deje
inherited Destroycomo ú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
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