Article technique

Exceptions de constructeur Delphi et destructeurs nil-safe

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

Cycle de vie d'un objet Delphi montrant comment la RTL appelle Destroy sur une instance HotXLS à moitié construite quand un constructeur lève, avant que l'exception d'origine n'atteigne l'appelant
quand un constructeur Delphi lève, la RTL détruit d'abord l'instance à moitié construite et ne relance qu'ensuite l'exception d'origine, donc Destroy tourne sur l'état atteint par la construction, quel qu'il soit

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é

Carte de l'ordre d'allocation pour les constructeurs HotXLS en Delphi : un NewInstance échoué est propre, un TList.Create échoué laisse le champ nil, les échecs ultérieurs restent protégés
seule la deuxième allocation compte : l'échec de la TList laisse le champ nil, et un Destroy non protégé est ce qui transforme EOutOfMemory en violation d'accès

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 Free ou FreeAndNil, exige un test nil ou Assigned, y compris les appels vers des helpers comme Clear ou ClearCore qui itèrent le champ
  • Libérez les objets possédés avec Free ou FreeAndNil, jamais en appelant Destroy directement, qui saute le test nil
  • Gardez inherited Destroy en 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

Matrice de décision de réessai en Delphi avec HotXLS : EOutOfMemory est retenté avec un TXLSXWorkbook neuf, les autres échecs se propagent aussitôt, une violation d'accès n'est jamais retentée
chaque tentative construit un classeur neuf car un Open à moitié fait n'est pas reprenable, et seul EOutOfMemory gagne jamais une seconde tentative

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