Quand un constructeur Delphi lève une exception, la RTL appelle aussitôt le destructeur sur l'instance à moitié construite, donc chaque destructeur doit gérer des champs qui n'ont jamais été affectés. HotXLS applique cette règle à ses destructeurs, TXLSXRichText et TXLSXCells compris : un échec d'allocation dans ces constructeurs arrive dans votre code comme l'EOutOfMemory d'origine, pas comme une violation d'accès secondaire levée pendant le nettoyage
Cette classe de bug est vieille et facile à rater. Un service convertit un classeur de 300 MB sur un serveur chargé, la mémoire vient à manquer, et le log montre une violation d'accès dans un destructeur. Rien dans ce message ne dit « plus de mémoire », rien ne suggère que réessayer plus tard marcherait, et la stack trace pointe vers un destructeur qui semble parfaitement correct. L'exception d'origine a existé quelques microsecondes puis a été remplacée. Cet article déroule pourquoi ça arrive, comment HotXLS structure ses destructeurs pour l'éviter, et comment écrire vos propres classes pour qu'elles ne le fassent jamais
Que se passe-t-il quand un constructeur Delphi lève ?
L'objet est détruit pour vous avant que l'exception n'atteigne l'appelant, et votre destructeur tourne sur l'état que le constructeur a atteint, quel qu'il soit. La séquence est fixée par le compilateur et la RTL. NewInstance alloue l'instance et InitInstance la remplit de zéros, donc toute référence d'objet démarre à nil, tout entier à 0 et toute chaîne à vide. Le corps du constructeur tourne ensuite dans un gestionnaire d'exceptions implicite. Si quoi que ce soit dans ce corps lève, le code généré appelle le destructeur virtuel : BeforeDestruction tourne, puis votre Destroy, puis FreeInstance libère la mémoire, et enfin l'exception est relancée. AfterConstruction ne tourne jamais, et la variable à gauche de X := TFoo.Create n'est jamais affectée
Le destructeur voit donc un objet qui s'est arrêté à une ligne arbitraire de son constructeur. Les champs affectés avant l'échec contiennent de vrais objets. Les champs après lui sont encore nil. Un destructeur écrit en supposant que la construction s'est terminée va déréférencer l'un de ces champs nil et lever une violation d'accès de son propre fait
Cette seconde exception est le vrai dégât. Delphi n'enchaîne pas les exceptions levées pendant ce nettoyage : la violation d'accès remplace l'EOutOfMemory, l'exception d'origine est jetée, et l'appelant ne voit jamais que l'AV. Le diagnostic utile — « une allocation a échoué, réessayez avec plus de mémoire » — a disparu, et à sa place se trouve quelque chose qui ressemble à de la corruption mémoire
D'où vient la violation d'accès secondaire ?
Elle vient d'un destructeur qui suppose son constructeur terminé et touche un champ de liste qui est nil quand la toute première allocation interne échoue. Deux classes HotXLS montrent clairement la forme. Le TXLSXRichText.Create autonome comme la surcharge possédée par un commentaire TXLSXRichText.Create(AOwner: TXLSXComment) appellent inherited Create puis allouent FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) fait pareil avec FList := TList.Create avant de construire ses tables de chaînes packées et son stockage de cellules packé. Dans Lib/lxHandleX.pas, les deux destructeurs protègent ces champs :
procedure TXLSXRichText.ClearCore;
var
i: Integer;
begin
if FRuns=nil then Exit; // le constructeur peut s'être arrêté avant 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 est nil si TList.Create a échoué
// ... stockage packé, tables de chaînes et FList sont libérés avec Free
inherited Destroy;
end;
Le point d'échec est précis. En comptant les allocations tas pendant la construction, la première est l'instance elle-même ; si NewInstance échoue, aucun corps de constructeur ne tourne et aucun destructeur n'est appelé, donc ce cas est toujours propre. La deuxième allocation est la TList. Quand elle échoue, FRuns ou FList reste nil, la RTL appelle Destroy, et un Destroy non protégé lit Count à travers une référence nil, ce qui est exactement la violation d'accès secondaire. Les échecs ultérieurs dans TXLSXCells.Create sont inoffensifs, car une fois FList existant, Clear vérifie FPackedStorage <> nil avant d'utiliser le stockage packé
Ces classes comptent parce qu'elles sont créées en masse. Chaque feuille chargée construit une collection TXLSXCells, et chaque cellule ou commentaire avec des runs formatés construit un TXLSXRichText. Un grand classeur au texte riche dense en crée des milliers, donc sous pression mémoire l'allocation qui finit par échouer a une vraie chance d'atterrir sur l'une de ces lignes
Free est nil-safe, l'accès aux champs ne l'est pas
TObject.Free vérifie Self <> nil avant d'appeler Destroy, donc FRuns.Free sur un champ nil est un no-op. Lire FRuns.Count ne l'est pas : Count lit un champ à un offset du pointeur d'instance, et un appel virtuel tel que FRuns.Clear lit d'abord le pointeur de VMT dans l'instance. Les deux fautent sur nil. Cette asymétrie est tout le piège. Les appels de libération d'un destructeur typique sont déjà sûrs ; les boucles et appels de nettoyage placés avant eux sont là où logent les ennuis. Voici le schéma dans une petite classe à vous :
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 ici laisse FRuns = nil
FIndex := TStringList.Create; // ... et ici laisse FIndex = nil
end;
destructor TStyledText.Destroy; // MAUVAIS : suppose le constructeur terminé
var
I: Integer;
begin
for I := 0 to FRuns.Count - 1 do // violation d'accès quand FRuns = nil
TObject(FRuns[I]).Free;
FIndex.Free;
FRuns.Free;
inherited Destroy;
end;
Le destructeur corrigé protège chaque déréférencement et ne libère les champs que par des appels 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;
Les règles qui découlent de ça sont assez courtes pour s'appliquer en revue de code :
- Chaque usage d'un champ possédé dans un destructeur, autre que
FreeouFreeAndNil, exige un testnilouAssigned, y compris les appels vers des helpers commeClearouClearCorequi itèrent le champ - Libérez les objets possédés avec
FreeouFreeAndNil, jamais en appelantDestroydirectement, qui saute le testnil - Gardez
inherited Destroyen dernière instruction, pour que l'ancêtre démonte ses propres champs après que les vôtres ont disparu - Ne raisonnez pas depuis l'ordre des champs dans le constructeur ; le prochain mainteneur insérera une allocation au-dessus de la vôtre et déplacera en silence le point d'échec
Où inherited Create a sa place
Appelez inherited Create en premier, comme le font les deux constructeurs HotXLS. Les champs de l'ancêtre existent alors avant que votre code ne tourne, et l'image miroir du destructeur, inherited Destroy en dernier, se déroule dans l'ordre inverse. Si le constructeur ancêtre lève lui-même, votre destructeur tourne quand même avec tous vos champs à nil, ce qu'un destructeur nil-safe gère sans cas spéciaux. Les affectations explicites comme FOwner := nil dans un constructeur sont redondantes après InitInstance, mais inoffensives et parfois dignes d'être gardées comme documentation
Ne pas attraper et libérer dans le constructeur
Deux schémas bien intentionnés aggravent les choses. Le premier est d'envelopper le corps du constructeur dans try ... except Free; raise; end : la RTL va de toute façon appeler le destructeur, donc l'objet est détruit deux fois. Le second est d'avaler l'exception pour « continuer » : l'appelant reçoit alors une référence d'apparence valide vers un objet aux champs nil, et l'échec migre vers un appel de méthode sans rapport bien plus tard. Si un constructeur crée un temporaire qu'il ne garde pas, libérez ce temporaire dans un try ... finally local et laissez l'exception continuer vers le haut intacte
Comment tester les chemins d'échec d'un constructeur ?
Faites échouer exprès chaque point d'allocation d'un constructeur, un à la fois, et exigez EOutOfMemory à chaque fois, suivie d'une construction normale réussie. L'outil pour ça est un gestionnaire mémoire de remplacement dont GetMem, AllocMem et ReallocMem comptent les demandes de taille positive et renvoient nil à un ordinal choisi. Comptez d'abord les allocations que fait un constructeur sur votre build cible, puis exécutez un échec injecté par ordinal
Après chaque échec injecté, construisez à nouveau la même classe avec l'allocateur restauré et exercez-la : pour une classe de texte riche, ajoutez un run, posez son texte, et confirmez que le texte brut revient comme prévu. Les contrôles de cycle de vie normaux ont leur place à côté, comme sauvegarder un classeur avec des runs formatés et des commentaires vers un fichier et vers un flux, libérer le classeur source, puis rouvrir et comparer les deux copies
Les comptages d'allocations valent pour un compilateur et une plateforme ; un autre compilateur ou une autre RTL peut allouer différemment, donc mesurez sur le build que vous livrez. HotXLS applique la même règle nil-safe dans toute la bibliothèque, des collections de styles, graphiques et pivots aux tables de hachage, au writer zip et à l'exporteur PDF. Les tables de hachage méritent une note précise : une table qui change son masque avant une réallocation qui échoue ensuite laisse son destructeur arpenter la mémoire au-delà de l'ancienne table, donc le masque ne doit changer qu'après l'existence du nouveau stockage. Un cas se trouve dans la RTL elle-même : TInterfaceList faute dans son destructeur quand son allocation interne échoue. Rien de tout cela ne vaut rollback d'une opération arbitraire qui échoue à mi-chemin, et ce n'est pas une garantie générale de récupération hors mémoire
Qu'est-ce que ça change pour du code qui ouvre de grands classeurs ?
En pratique, un EOutOfMemory de ces chemins de construction est un signal que vous pouvez attraper et sur lequel agir, au lieu d'une violation d'accès à traiter comme un crash. TXLSXWorkbook.Create ne prend aucun paramètre, et TXLSXWorkbook.Open(const FileName: WideString): Integer renvoie 1 en succès et -1 en échec. Les exceptions levées pendant le chargement sont enregistrées dans la collection Diagnostics du classeur comme xlsDiagnosticUnhandledException puis relancées, ce qui est le comportement décrit dans l'article HotXLS sur les diagnostics structurés. Un wrapper de réessai ressemble à ceci :
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; // aucun argument de constructeur
if Book.Open(FileName) <> 1 then // 1 = succès, -1 = échec
raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
[string(FileName)]);
Result := Book;
Book := nil; // la possession passe à l'appelant
Exit;
finally
Book.Free; // nil-safe après un transfert
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // votre code : vider les caches, autres classeurs
end;
end;
end;
end;
Quelques détails de ce wrapper sont délibérés. Chaque tentative construit un TXLSXWorkbook neuf, car des destructeurs nil-safe rendent les échecs de construction propres mais ne rendent pas un Open à moitié fait reprenable, donc l'instance fautive est simplement libérée. Seul EOutOfMemory est retenté ; EWorkbookOpenFailed, les erreurs de mot de passe et tout le reste se propagent dès la première tentative. Un EAccessViolation ne doit jamais être retenté du tout, puisqu'il signale un défaut plutôt qu'une pénurie de ressources. Et le réessai n'a de sens que si quelque chose change entre les tentatives, voilà pourquoi le handler libère de la mémoire d'abord. Si vous avez besoin de la piste de diagnostic de la tentative fautive, lisez Book.Diagnostics avant que le bloc finally ne la libère
Retenter est une atténuation, pas un plan de capacité. Un processus 32 bits qui se heurte sans cesse à l'EOutOfMemory sur de gros fichiers manque en général d'espace d'adressage contigu plutôt que de mémoire physique, et la réponse durable est un build Win64. La même réflexion vaut côté écriture, où les sauvegardes crash-safe HotXLS avec fichiers temporaires par étapes empêchent une sauvegarde échouée d'endommager le fichier existant. Si plusieurs threads partagent un classeur, les leases de lecture et gardes d'écriture HotXLS décrivent comment l'accès à cette instance est coordonné
Une liste de contrôle pour vos propres classes Delphi
Les protections elles-mêmes font une ligne chacune, et le même audit prend des minutes sur votre propre code. Cherchez les destructeurs qui appellent une méthode Clear, Reset ou ClearCore avant de libérer des champs, puis vérifiez si cette méthode touche un champ que le constructeur n'a peut-être pas atteint. Cherchez des boucles sur des listes possédées non précédées d'un test nil. Cherchez des constructeurs qui attrapent des exceptions, et retirez à la fois l'avalaison et le Free manuel. Si vous voulez une preuve plutôt qu'une inspection, le gestionnaire mémoire à comptage décrit plus haut tient dans une page de code et transforme chaque allocation d'un constructeur en cas de test
Pour l'ensemble des fonctionnalités, les versions Delphi et C++Builder supportées et les licences, voir la page produit du composant Excel HotXLS pour Delphi