par , 21/10/2018 à 22h00 (4313 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.
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 :
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 :
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 !).