C# : comprendre comment un destructeur peut être appelé dans un constructeur

Le , par François DORIN, Responsable .NET & Magazine
Il y a quelque temps, au cours d'une discussion dans un de nos forums, je suis tombé sur un cas particulier qui abordait la possibilité au destructeur (ou finaliseur) d'être appelé durant le constructeur.

Si cela semble complètement absurde (position que je défendais d'ailleurs), un de nos membres à réussi à me sortir des références expliquant cela, et force est de constater... qu'il avait raison ! Donc une fois encore, je l'en remercie, et je propose de revenir un petit peu sur ce cas très particulier.


En théorie, c'est impossible...
En théorie, cela ne peut pas se produire.

En effet, si on regarde de plus près le fonctionnement d'un programme .NET, et plus particulièrement au niveau du CIL (Common Intermediate Language) généré, l'instanciation d'un objet se fait via l'instruction newobj.

Cette instruction appelle le constructeur de l'objet à instancier, puis place une référence de cet objet sur la pile. En théorie, il est donc impossible que l'objet soit collecté durant son constructeur, puisque juste après l'exécution du constructeur, une référence de l'objet est placée sur la pile.

...et pourtant, en pratique !
Pourtant, en pratique, cela peut se produire.

Voici un exemple de programme montrant cette situation :
Code C# : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System; 
using System.Threading; 
  
namespace TestConstructeur 
{ 
    public class Program 
    { 
        public Program() 
        { 
            GC.Collect(); 
            Thread.Sleep(100); 
            Console.WriteLine("Constructeur"); 
        } 
  
        ~Program() 
        { 
            Console.WriteLine("Kill !"); 
        } 
  
        public void SayHello() 
        { 
            Console.WriteLine("Hello World"); 
        } 
  
        public static void Main() 
        { 
            Program p = new Program(); 
            p.SayHello(); 
            Console.ReadLine(); 
        } 
  
    } 
}

Si on exécute ce code en mode release et sans dégogueur, on constate l'affichage suivant :
Kill !
Constructeur
Hello World
Donc oui, l'appel au destructeur a bien eu lieu alors que l'appel au constructeur n'était pas terminé !

Mais comment est-ce possible ?

Le JIT à la rescousse
L'explication tient dans un acronyme de 3 lettres : JIT. JIT, ou Just In Time correspond à une méthode très usitée pour les langages s'exécutant dans une machine virtuelle comme C# ou Java. Il s'agit de compiler à la volée le code managé en code natif.

Si nous revenons à notre programme d'exemple, le destructeur peut être exécuté durant le constructeur via les optimisations mises en oeuvre par le JIT.

En effet, si on regarde la méthode Main(), une instance de Program est créée, puis est ensuite utilisée via p.SayHello(). La méthode étant très simple, le JIT inline l'appel à la méthode SayHello, c'est-à-dire qu'au lieu de générer un appel à la méthode, il remplace directement l'appel par le code même de la fonction (c'est une optimisation classique permettant d'économiser quelques instructions dans les cas simples).

L'appel ayant été inliné (pardon pour les anglicismes), le compilateur JIT peut, dès lors, constater que la référence à l'objet Program n'est jamais utilisée. Et il peut donc optimiser encore plus en ne poussant pas sur la pile la référence créée (auquel cas, il faudrait alors dépiler cette référence). La référence n'étant alors référencée nulle part, le ramasse-miettes, s'il entre en action, peut tout à fait collecter l'objet et appeler son destructeur, et ceci, même si l'objet n'est pas fini d'être construit.

Bien évidemment, cela ne peut se produire que dans des cas très particuliers, comme celui ci-dessus. Si jamais la référence était utilisée à un moment ou un autre, jamais le ramasse-miettes n'aurait pu collecter l'objet.

Comment vérifier qu'il s'agit bien de cela ? Nous allons empêcher le compilateur JIT de réaliser cette opération d'inlinisation. Comment ? En fait, seules les méthodes "courtes" (c'est-à-dire ne comportant que quelques instructions) sont éligibles pour être inlinées.

Aussi, nous allons modifier cette méthode SayHello par :
Code C# : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  
  public void SayHello() 
        { 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
            Console.WriteLine("Hello World"); 
        }

Comme vous pouvez le constater, la méthode en elle-même est très similaire à la précédente. Au lieu d'afficher une seule fois "Hello World", on va l'afficher plusieurs fois.
Si on rééxecute le programme de tout à l'heure, alors le destructeur n'est plus appelé pendant le constructeur.

Ainsi, en empêchant le compilateur JIT d'inliner la méthode, il n'est plus en mesure de générer un code où le destructeur peut être appelé durant le constructeur.

L'honneur est sauf.

Conclusion
En règle général, il n'y a aucun risque à cela. Si le JIT le permet, c'est que c'est autorisé. Si votre code est 100% managé, il n'y a absolument aucun risque.

Le seul cas où cela pourrait éventuellement créer des soucis, c'est si votre code fait appel à des méthodes natives ayant des effets de bords. Dans un tel cas, il est possible que votre programme puisse ne pas fonctionner correctement dans de très rare cas (et des cas très difficiles à reproduire et à comprendre !).


Vous avez aimé cette actualité ? Alors partagez-la avec vos amis en cliquant sur les boutons ci-dessous :


 Poster un commentaire

Avatar de maitredede maitredede - Membre régulier https://www.developpez.com
le 23/10/2018 à 8:59
Bonjour,

Intéressant, détruire pendant qu'on construit

Pour autant que je me rappelle, quand j'utilisais mono embarqué, l'instantiation de l'objet et l'appel au constructeur étaient deux opérations distinctes, le constructeur étant une "méthode comme une autre" pour l'appel. Du coup, ça ne me parait pas si surprenant que le constructeur et le destructeur puissent être appelées en même temps (même si c'est assez tordu...)

Du coup, quels seraient les effets de bord en terme d'allocation mémoire d'objets détruits pendant leur construction ?

Et aussi, pour l'inlining, est-ce que l'utilisation de MethodImplAttribute avec
Code : Sélectionner tout
[MethodImplAttribute(MethodImplOptions.NoInlining)]
serait une possibilité si on tombe dans un cas similaire ?
Avatar de Aurelien.Regat-Barrel Aurelien.Regat-Barrel - Expert éminent sénior https://www.developpez.com
le 23/10/2018 à 11:25
Au delà du simple inlining, je suppose que c'est aussi (voire surtout) une conséquence du fait que la méthode concernée est une méthode static déguisée ? Est-ce que cela marcherait de la même façon si au lieu d'afficher une string constante, elle affichait la valeur d'une variable membre de la classe ?
Avatar de François DORIN François DORIN - Responsable .NET & Magazine https://www.developpez.com
le 23/10/2018 à 13:20
Citation Envoyé par maitredede
Et aussi, pour l'inlining, est-ce que l'utilisation de MethodImplAttribute avec
[MethodImplAttribute(MethodImplOptions.NoInlining)]
serait une possibilité si on tombe dans un cas similaire ?
Sans doute oui. A vérifier cependant, car il me semble que cet attribut n'est qu'une indication, et que le compilateur n'est pas obligé de suivre la directive.

Citation Envoyé par Aurelien.Regat-Barrel
Au delà du simple inlining, je suppose que c'est aussi (voire surtout) une conséquence du fait que la méthode concernée est une méthode static déguisée ? Est-ce que cela marcherait de la même façon si au lieu d'afficher une string constante, elle affichait la valeur d'une variable membre de la classe ?
Effectivement, si la méthode était une "vraie" méthode, avec une variable membre, le problème ne se poserait pas, et le ramasse-miettes ne pourrait pas collecter la variable.
Avatar de ekinoks60 ekinoks60 - Membre à l'essai https://www.developpez.com
le 23/10/2018 à 14:20
Hello,

Merci beaucoup pour les explications !
Avatar de no2303 no2303 - Membre du Club https://www.developpez.com
le 23/10/2018 à 14:34
Le "comment" est effectivement intéressant, mais "pourquoi" faire ça ? Ça manque un peu à l'article. Est-ce que c'est juste parce que c'est possible et qu'il faut le mentionner "for the sake of completeness" ?

Je peux aussi couper mon steak avec une scie à métaux, mais dans quel cas est-ce préconisé, et qu'est-ce que ça va m'apporter ?
Avatar de François DORIN François DORIN - Responsable .NET & Magazine https://www.developpez.com
le 23/10/2018 à 15:39
Citation Envoyé par no2303 Voir le message
Le "comment" est effectivement intéressant, mais "pourquoi" faire ça ?
Le but n'est pas de pouvoir le faire, car ce n'est pas un choix qui est du ressort du développeur ou du concepteur.

Il s'agit de comprendre ce qui se passe quand ça ne se passe pas comme attendu. Le cas reste très rare, mais une classe managée qui serait un simple wrapper à du code natif pourrait se retrouver dans une telle situation, avec des surprises à la clé.
Avatar de Aurelien.Regat-Barrel Aurelien.Regat-Barrel - Expert éminent sénior https://www.developpez.com
le 25/10/2018 à 12:07
Citation Envoyé par François DORIN Voir le message
Le cas reste très rare, mais une classe managée qui serait un simple wrapper à du code natif pourrait se retrouver dans une telle situation, avec des surprises à la clé.
Cette partie là m'intéresse, et j'ai un peu de mal tout de même à voir quel problème concret pourrait apparaître dans la mesure où cela ne concerne PAS les méthodes qui référencent une variable d'instance. Car l'exemple donné revient à écrire :

Code : Sélectionner tout
1
2
3
Program p = new Program();
Program.SayHello();
Console.ReadLine();
et dans ce cas on comprend très bien que l'appel de SayHello() n'est pas lié à l'instance p. Jusque là tout va bien.

Ce qui est plus surprenant en revanche c'est l'appel du destructeur avant que l'appel du constructeur soit finalisé. Là aussi c'est à priori lié au fait que le destructeur ne fait rien sur l'instance elle même, donc bien que surprenant de prime abord cela fait sens et ne pose pas problème. Car je suppose que si le dtor référençait une variable initialisée par le ctor afin de la libérer, ce cas là ne se produirait pas. Et donc tout irait bien.

Pour aller dans ton sens, le cas hypothétique que je peux imaginer est celui où ctor et dtor ne font qu'appeler des fonctions statiques d'une lib afin de faire un init / shutdown. Donc là il pourrait y avoir potentiellement une inversion de l'ordre d'appel. Il faudrait (je pense) utiliser un ctor statiques dans ce cas. Y'a pas de dtor statique en C# je crois, mais on peut le simuler il me semble.
Avatar de ryankarl65 ryankarl65 - Membre habitué https://www.developpez.com
le 09/11/2018 à 15:57
Wouahhh !!!

Quel serait ? ou quel pourrait être l'utilité d'une telle pratique ?
Responsables bénévoles de la rubrique Microsoft DotNET : Hinault Romaric - François DORIN -