Voir le flux RSS

François DORIN

[Actualité] C# : un destructeur appelé durant le constructeur

Noter ce billet
par , 21/10/2018 à 23h00 (1009 Affichages)
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.

Nom : logo-csharp.png
Affichages : 3788
Taille : 50,9 Ko

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 - Visualiser dans une fenêtre à part
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 - Visualiser dans une fenêtre à part
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 !).

Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Viadeo Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Twitter Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Google Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Facebook Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Digg Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Delicious Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog MySpace Envoyer le billet « C# : un destructeur appelé durant le constructeur » dans le blog Yahoo

Mis à jour 22/10/2018 à 18h07 par François DORIN

Catégories
DotNET , C#

Commentaires

  1. Avatar de maitredede
    • |
    • permalink
    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 - Visualiser dans une fenêtre à part
    [MethodImplAttribute(MethodImplOptions.NoInlining)]
    serait une possibilité si on tombe dans un cas similaire ?
  2. Avatar de Aurelien.Regat-Barrel
    • |
    • permalink
    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 ?
  3. Avatar de François DORIN
    • |
    • permalink
    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.
  4. Avatar de ekinoks60
    • |
    • permalink
    Hello,

    Merci beaucoup pour les explications !
  5. Avatar de no2303
    • |
    • permalink
    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 ?
  6. Avatar de François DORIN
    • |
    • permalink
    Citation Envoyé par no2303
    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é.
  7. Avatar de Aurelien.Regat-Barrel
    • |
    • permalink
    Citation Envoyé par François DORIN
    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 - Visualiser dans une fenêtre à part
    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.
    Mis à jour 25/10/2018 à 18h12 par Aurelien.Regat-Barrel
  8. Avatar de ryankarl65
    • |
    • permalink
    Wouahhh !!!

    Quel serait ? ou quel pourrait être l'utilité d'une telle pratique ?