Avertissement : ce texte a besoin d'une mise à jour, les trois langages principaux dont il y est question ayant traversé des mises à jour importantes depuis qu'il a été publié. Lisez-le donc de manière éveillée.
En attendant, Andrzej Krzemieński a écrit de très bons textes sur le sujet, que je vous invite à lire sur :
Un de mes anciens étudiants, Joël Collin, m'a fait parvenir une missive fort intéressante sur le comportement de la collecte d'ordures sous .NET, signalant qu'un exemple suspect en C# ci-dessous posait problème. Des ajustements ont été apportés en conséquence, et l'essentiel des remarques de Joël ont été annexées un peu plus bas dans ce document. Merci Joël!
Un autre de mes étudiants, John Serri, m'a mis sur la piste d'une stratégie intéressante pour appliquer (de manière limitée) l'idiome RAII (commun en C++) en C#, contournant en partie le caractère irritant de la collecte automatique d'ordures pour qui veut offrir des garanties de libération de ressources sans écrire du code la prenant en charge explicitement. La technique repose sur le mot clé using de C# et est expliquée brièvement un peu plus bas. Certaines des critiques faites ici au modèle de C# peuvent être palliées par un emploi de ce mot-clé, alors je vous invite à y jeter un coup d'oeil.
Joël Collin m'a récemment envoyé des remarques quant au coût en performance relié à l'emploi de using de cette manière. Il existe aussi un WikiBook portant sur le sujet du contrôle de la durée de vie des objets en C#.
Thierry Babin Ruel, un autre de mes brillants anciens étudiants (je suis privilégié!) m'a fait parvernir un certain nombre de liens, en particulier celui-ci qui détaille pourquoi, du moins du point de vue de l'évolution du modèle proposé par la plateforme .NET et dans un contexte de support historique des fonctionnalités de Visual Basic, la finalisation déterministe s'est révélée un problème difficile pour l'équipe de développement de cette infrastructure (le texte propose aussi l'application susmentionnée du mot clé using). Comme dans bien des cas, la vraie vie force le gens à faire des choix pragmatiques; les impératifs esthétiques et techniques de l'équipe de Visual Basic et de celle de la plateforme .NET sont les leurs et leurs choix reflètent ce qu'ils estimaient être important. Ce sont des choix raisonnables même si ce ne sont pas toujours les miens. L'essentiel du discours portant sur la performance dans ce texte présume que tous les objets doivent être alloués dynamiquement, ce qui est un choix technique de la plateforme .NET (et de Java) mais n'est absolument pas une nécessité philosophique ou technique – c'est un choix, que ces gens doivent maintenant assumer. N'oubliez pas votre regard critique à la maison. Ce que le texte démontre est que pour un système reposant strictement sur des objets alloués dynamiquement et pris en charge, l'approche de .NET fait en sorte de réduire la charge (l'Overhead) face à un système de comptage de références... mais la présomption selon laquelle on pourrait vouloir partager tous les objets d'un système est un a priori fort discutable.
C++/ CLI, un dialecte de C++ dans le monde pris en charge, supporte une distinction entre finaliseurs et destructeurs, analogue à celle entre l'interface IDisposable et le destructeur de C#. À titre historique, vous pouvez aussi consulter cet article du CodeProject des stratégies pour implémenter l'idiome RAII dans C++ .NET (un dialecte antérieur à C++/ CLI) mêlant code pris en charge et code standard.
En 2011, Stephen Cleary a publié dans le Code Project (où il faut toujours lire les articles avec un grain de sel, la qualité variant beaucoup) un texte critique mais constructif sur l'approche qu'applique .NET avec l'interface IDisposable, détaillant des pratiques pour encadrer les déficiences de ce modèle.
Chaque langage a ses particularités, ses avantages et ses inconvénients. Souvent, les désavantages des uns sont les avantages des autres. Le présent document en atteste.
Il existe une vague de langages de programmation relativement récents (en particulier les langages de script comme Python et les langages interprétés comme Java et les divers langages .NET) qui ont en commun un certain nombre de particularités très à la mode:
Notez que la surabondance de conversions explicites de types risque de diminuer maintenant que Java supporte les Generics, et que .NET, particulièrement C#, fait de même.
Les Generics ont une ressemblance de surface aux templates de C++. En Java, une classe générique réalise les conversions explicites de types à l'insu du programme alors qu'en C#, une classe générique est compilée au besoin et entreposé en antémémoire au besoin.
Ce document se propose de discuter brièvement des avantages et des inconvénients de cette stratégie, et de montrer quelques techniques de programmation exploitant la symétrie constructeur/ destructeur pour montrer que, dans bien des cas, reposer sur une collecte automatique d'ordures seule est une stratégie menant à du code plus sujet aux erreurs.
Un objet est une entité active et pleinement responsable de sa propre intégrité. L'encapsulation, renforcée dans la plupart des langages de programmation OO par des qualificateurs de contrôle d'accès (private, public, protected) et par des techniques permettant à un objet de contrôler toute tentative de lecture ou d'écriture d'un de ses attributs en forçant pour ce faire le passage par ses méthodes, permet à un objet de garantir que, dans la mesure où il est a priori dans un état stable, alors il le restera pour la totalité de son existence.
La présomption sur laquelle repose le contrat selon lequel l'objet doit se porter garant de sa propre intégrité est que l'objet sera en contrôle de son état dès le moment où il commencera à exister – dès sa construction.
C'est en partie pourquoi la plupart (tous? Probablement) des langages OO permettent aux objets d'exposer des constructeurs.
Habituellement, un constructeur initialisera au minimum les attributs de l'objet à des valeurs initiales convenables selon les circonstances.
|
|
La mécanique de la copie doit être prise en charge par les objets eux-mêmes dans bien des cas, puisque ce que signifie copier n'est pas toujours clair dans le cas des objets – si un attribut d'un objet est une référence à ou un pointeur vers un autre objet, doit-on copier la référence (le pointeur) ou le référé (le pointé)? Si un objet est une copie d'un autre et si tous deux examinent le même fichier, lequel des deux objets sera responsable de fermer ce fichier?
Un constructeur peut aussi servir à obtenir des ressources pour assister l'objet dans son travail. Évidemment, on parle d'obtenir de la mémoire allouée dynamiquement (souvent avec l'opérateur new), mais aussi d'ouvrir des liens de communication, des connexions à des bases de données, de lancer des threads, d'ouvrir des fichiers, etc.
Le rôle du destructeur sera de faire en sorte, dans les limites de ses possibilités, que le système soit dans un état aussi stable après la fin de la vie de l'objet qu'il ne l'était avant sa construction. Si l'objet a ouvert des fichiers (à la construction ou ailleurs), alors le destructeur devrait s'assurer que ces fichiers soient fermés. Si l'objet a lancé des threads, alors le destructeur devrait s'assurer que ces threads soient arrêtés, ou du moins faire en sorte que la destruction de l'objet ne mène pas au fonctionnement pour une durée indéterminée de ces threads.
Les constructeurs et les destructeurs ne sont pas de simples méthodes d'initialisation et de nettoyage. Ce sont des automatismes, des morceaux de la mécanique des langages OO qui assistent les programmes dans leur quête d'intégrité et de simplicité.
Pour bien des éléments de la mécanique d'un langage OO, on pourrait exposer des méthodes d'initialisation et de nettoyage appelées explicitement dans les programmes utilisateurs. Cela alourdirait bien sûr la syntaxe et l'utilisation du langage, mais ne constituerait pas un problème philosophique profond (à part peut-être dans le cas de la mécanique de copie des objets). Qu'une telle approche soit possible ne signifie pas qu'il s'agisse d'une approche OO, toutefois. Faire dépendre l'intégrité effective des objets de l'action des sous-programmes les utilisant déchargerait les objets de la responsabilité de leur propre intégrité, et éliminerait la possibilité pour un objet d'assurer sa propre intégrité du début à la fin de sa vie. |
|
La dualité constructeur/ destructeur est un fondement de l'approche OO, et fait partie des prérequis d'une encapsulation véritable.
C'est à partir de ce constat du caractère fondamental et essentiel de la dualité constructeur/ destructeur que se construira l'argumentaire de ce document : un vrai langage OO doit, pour supporter pleinement l'encapsulation, assurer à la fois la bonne construction et la bonne destruction des objets. La plupart des langages s'en sortent bien avec la construction; les langages à la mode s'en sortent beaucoup moins bien avec la destruction.
En Java, par exemple, la méthode protégée finalize(), qui existe au niveau de la classe mère Object, peut être rédigée et sera parfois appelée lors de la collecte d'ordures. Pour voir à quel point la mécanique est déficiente, vous pouvez essayer le programme proposé à droite, on y crée un tableau de références à des Z, puis on affecte null au tableau. Ce faisant, force est d'admettre que plus personne ne réfère au tableau, et que (le tableau étant le seul objet à référer à des Z) plus personne ne réfère aux Z nouvellement créés. Si on laisse la méthode main() se compléter et si on omet l'appel à System.gc() tout en bas, alors la méthode finalize() des Z n'est jamais appelée. De deux choses l'une : ou Java ne se rend jamais compte que plus personne ne réfère aux Z, ou il choisit de ne pas appeler finalize() pour ses propres raisons (économie de temps?). |
|
En insérant un appel explicite à System.gc(), ce qu'on ne devrait pas avoir à faire – si on utilise une collecte automatique d'ordures, après tout, on la veut vraiment automatique – alors un seul des Z verra sa méthode finalize() appelée.
Quelle est l'utilité d'un destructeur qui ne sera pas nécessairement appelé? Et quelle est l'utilité réelle d'une collecte automatique d'ordures si elle n'est pas véritablement faite?
Si la finalisation ne signifiait que libérer des ressources du langage en tant que tel, alors cette omission ne serait pas plus dommageable qu'il ne le faut. Après tout, une collecte automatique d'ordures ramassera éventuellement (en théorie) les objets laissés flottants en mémoire, alors la dualité new/ delete fréquente en C++ dans les paires constructeur/ destructeur ne constitue par un besoin criant en Java ou en C#.
Il faut être honnête : la collecte d'ordures de Java est faite au besoin, l'idée de besoin étant ici liée au constat fait par la JVM que la mémoire se fait rare. Dans la mesure du possible, Java cherche à procéder rapidement et efficacement; s'il peut éviter entièrement de collecter les ordures, alors le moteur de Java le fera.
Que Java ne puisse pas garantir un support complet de l'encapsulation jette une forme de discrédit sur ses prétentions de pureté OO. Un objet en Java peut garantir son intégrité personnelle, mais pas l'intégrité systémique là où il est utilisé. Il n'en a pas les moyens, n'étant pas nécessairement informé de son propre décès.
Pour un cas simple comme celui proposé ci-dessus, l'implémentation de C# est plus stricte du point de vue du support de l'encapsulation que celle de Java.
En effet, le CLR de .NET appelle systématiquement les destructeurs des objets d'un programme C# (VB.NET reçoit aussi le même niveau de support du CLR), ce qui peut se démontrer avec le petit programme donné en exemple à droite. Il est dangereux de négliger l'importance du nettoyage des programmes OO lorsqu'on a droit à un moteur de collecte automatique d'ordures. En effet, la plupart des programmes OO sérieux permettent une forme d'interopérabilité avec des outils existant hors du carré de sable, donc hors de la machine virtuelle dans laquelle s'exécute la collecte automatique d'ordures. Que ce soit dans une approche client/ serveur (CS) où les programmes interagissent presque directement les uns avec les autres, ou que ce soit parce que le programme OO avec collecte automatique d'ordures interagit avec un produit qui n'est pas un objet du système (fichier, BD, matériel), l'interopérabilité est une réalité des systèmes informatiques. |
|
On peut croire qu'enrober ces ressources extérieures dans des objets pour les intégrer au système soit une solution au problème de la libération des ressources, mais il se trouve que, si le destructeur n'est pas appelé de manière systématique pour ces objets, alors le problème demeure entier.
Un système OO sérieux a besoin de la dualité constructeur/ destructeur pour implémenter pleinement l'encapsulation, et pour se réaliser en tant que modèle OO.
Les moteurs de collecte automatique d'ordures ont leur raison d'être, et ne doivent pas être négligés. Toutefois, ils ne sont pas une solution parfaite et suffisante au problème de la bonne gestion de systèmes OO.
L'une des raisons pour lesquelles on apprécie les moteurs de collecte automatique d'ordures (surtout s'ils assurent l'appel éventuel du destructeur de chaque objet) est qu'ils permettent d'automatiser une solution à un problème qui, sans être complexe outre mesure, est souvent mieux pris en charge par le langage de programmation que par les programmeurs: le problème des objets partageables.
Ce problème est exacerbé par le fait que, dans les langages comme C# et Java qui reposent exclusivement sur l'allocation dynamique de mémoire pour la création d'objets, tous les objets sans exception y sont manipulés de manière indirecte, à travers des références.
Ceci mène parfois à des situations surprenantes pour les gens peu familiers avec le modèle. Le programme Java donné en exemple à droite en est une illustration claire : un progammeur imprudent pourrait penser que la répétitive par laquelle on initialise Tableau copie des instances de Z, alors qu'elle ne copie que des références. En fin du compte, il n'y a qu'un seul Z dans tout le programme! La situation est banale dans certains cas. Certains objets, comme les instances de String, sont immuables et n'offrent aucun moyen de les modifier une fois construits; il importe donc peu qu'ils soient dupliqués ou non. |
|
Dans le cas de la classe Z, par contre, le résultat obtenu peut différer des attentes pour qui n'offre pas une compréhension suffisamment fine de la mécanique de manipulation indirecte des objets.
Le programme en exemple affichera dix fois le chiffre 9, plutôt que les chiffres de 0 à 9 inclusivement, dans l'ordre.
En C++, ce genre de manoeuvre est souvent plus clair dans l'esprit des gens puisque créer automatiquement des objets et les copier est une opération très différente de créer dynamiquement des objets puis les copier. Les manipulations indirectes et directes d'objets n'y sont pas visuellement semblables.
Présumant la classe Z toute simple proposée à droite, exposant :
|
|
...l'allocation automatique d'objets en C++ ne demande pas qu'on fasse appel à l'opérateur new. Le compilateur est responsable de solliciter les constructeurs requis, et est (conséquemment) responsable d'appeler lui-même les destructeurs lorsque la portée des objets se termine. |
|
Toujours en C++, l'utilisation de new présume un usage indirect des objets créés, et diffère visiblement de l'usage d'objets créés de manière automatique (la signature des pointeurs est apparente). Ce faisant, qui dit new dit pointeurs et dit obligation de détruire éventuellement des objets créés manuellement. En C++, utiliser new signifie se rendre responsable de la mémoire correspondante. Notez que le code à droite n'est pas sécuritaire et laissera fuir des ressources si une exception est levée n'importe où après le premier appel à l'opérateur new. C'est un exemple à ne pas suivre. |
|
En C++, un programme comme celui à droite porte donc visiblement la marque des accès indirect, et est plus lourd à rédiger qu'un programme semblable exploitant la construction et la destruction automatique d'objets. Y avoir recours demande un effort conscient, et il est probable qu'un programmeur le rédigeant soit conscient du fait qu'il n'y ait qu'un seul objet réellement créé et des conséquences de cette réalité. |
|
Par contre, en C++, un programme comme celui à droite est non seulement mal écrit (réf. : code en caractères gras) et très mauvais mais aussi très, très dangereux. Puisque tous les tableau[i] pointent au même objet, la destruction du premier invalide toute opération faite sur tous les autres. Dans un langage à collecte automatique d'ordures, de telles erreurs ne surviendront pas, car la destruction sera assurée (en théorie) par le moteur et aucun objet ne sera détruit tant et aussi longtemps qu'au moins une référence mènera dans sa direction. |
|
Évidemment, cela pose des questions importantes, comme celle du programme C# ci-dessous.
Une partie de ce qui suit a été ajusté suite à des remarques d'un de mes anciens étudiants, Joël Collin, qui a eu la gentillesse de faire une lecture rigoureuse de ce document et de m'indiquer que certains des éléments présentés y étaient, à l'origine, inexacts, et dont les commentaires détaillés sont reproduits plus bas de manière à s'intégrer à ce document. Je vous invite fortement à consulter ses remarques qui sont fort instructives.
Ce programme semble fonctionner mais, en fait, il ne fonctionne pas vraiment; il tend toutefois à ne planter qu'après une longue période d'exécution, ce qui est une mauvaise nouvelle (ça rend l'erreur difficile à détecter). Dès que le destructeur d'un élément du tableau t est appelé, cet objet devrait être considéré détruit, or les destructeurs sont tous appelés et offrent souvent un message cohérent à l'intérieur de leurs destructeurs respectifs. Ce phénomène est une illusion – la mémoire qu'occupait un objet qui a été détruit peut, bêtement, contenir des vestiges de ce qu'elle contenait auparavant. C# traite la fin d'un processus différemment du reste de son exécution. S'assurer explicitement que le tableau t ne soit plus utilisé dans un programme puis appeler System.GC.Collect() (équivalent .NET du System.gc() de Java) ne collectera pas les éléments du tableau comme s'ils faisaient partie des ordures du programme, ce qui se comprend (ils se réfèrent les uns les autres, donc sont tous référencés au moins une fois). Par contre, la fin du programme enverra un signal à tous les objets selon lequel il sera temps de mourir, et il semble que tous les destructeurs restants soient d'abord appelés puis que tous les objets soient effectivement détruits. Pervers, mais efficace. |
|
Le même problème surviendra si un objet se réfère à lui-même (si une instance de Z a elle-même pour voisin) :
Z z = new Z(24);
z.setVoisin(z);
J'avais écrit initialement que z vivait jusqu'à la fin du programme, mais la nuance (à la prochaine collecte...) requise a été suggérée par le très vif Thierry Babin Ruel.
Encore une fois, le moteur de collecte d'ordures de C# tranche le noeud gordien de manière brutale mais efficace. L'objet z sera éventuellement détruit, mais seulement à la prochaine collecte d'ordures, qu'il reste ou non une référence (une autoréférence!) dans sa direction.
Les langages récents intègrent à peu près tous un modèle de multiprogrammation. Cela implique qu'il est fréquent dans ces langages que des programmes créent des objets qui seront utilisés de manière concurrente par plusieurs séquences de code.
Quand un objet alloué dynamiquement est partagé par plusieurs threads, la question naturelle à poser est lequel des threads sera responsable de détruire cet objet? Et la réponse ne peut pas toujours clairement être celui qui a créé l'objet, puisque les threads meurent dans un ordre qui leur est propre et qu'il est possible que le thread ayant créé un objet ne soit pas le dernier thread à utiliser l'objet en question.
En soi, la question de l'euthanasie d'un objet est subjective. Il faut, pour savoir vraiment quand détruire un objet, compter en tout temps le nombre de références vers cet objet, et ne le détruire que lorsque le nombre de références est nul.
Compter les références vers chaque objet est une tâche dont un moteur OO s'acquittera en général très bien, mais seulement dans l'optique où ce moteur connaît les tenants et aboutissants de chaque objet – donc dans la mesure où tous les objets utilisés, sans exception, font partie intégrante du langage. C'est là un problème que Java et .NET ont tous deux rencontré, et pour lequel il n'existe aucune solution parfaite. Les solutions possibles varient plus en esthétisme qu'en qualité.
Évidemment, quand les objets en interaction dans un système proviennent de plusieurs machines virtuelles, sur une seule ou plusieurs plates-formes et rédigés dans un seul ou plusieurs langages de programmation, il devient difficile (et pour le moment impossible) de compter sur une véritable collecte automatique d'ordures efficace et répartie. Un exemple expliquant comment on pourrait implémenter manuellement une telle mécanique sera exposée dans Partageabilité collaborative en l'absence de collecte automatisée d'ordures, plus bas, mais une telle stratégie demandera l'usage d'un langage permettant à un programme de gérer lui-même le moment de la destruction de ses objets.
Il y a plus d'avantages de que d'inconvénients à la construction/ destruction automatique, surtout dans la mesure où la destruction automatique se fait dès que l'objet alloué automatiquement quitte la portée dans laquelle il a été déclaré. Pour que Java et C# puissent exploiter les techniques qui suivent, il leur faudrait, au choix :
Que permet l'exploitation de la symétrie construction/ destruction automatique? Énormément de techniques de programmation très simples, très sécuritaires et très efficaces. En voici quelques exemples.
Examinez les programmes Java et C++ ci-dessous. Les deux procèdent à une copie d'un fichier texte nommé "In.txt" dans un aute fichier texte nommé "Out.txt".
Version Java | Version C++ |
---|---|
|
|
Le programme C++ pourrait être plus court encore (voir ceci pour des exemples), mais cette version nous suffira pour les fins de l'explication. Mettons de côté la différence de complexité entre les deux implémentations, dûe à beaucoup de facteurs mais surtout à la manière de gérer les exceptions des deux langages (obligation en Java, option en C++, et fait que la lecture en C++ retourne une référence sur le flux duquel elle fut faite, et que ce flux peut être testé comme un booléen – faux lors d'une erreur en lecture).
Le point qu'il faudrait remarquer ici est que dans la version Java, la seule manière de garantir la bonne fermeture des flux d'entrée/ sortie sur les fichiers est d'appeler explicitement la méthode close() de chacun. Ici, ces appels sont insérés dans une clause finally pour que nous soyons assurés qu'ils auront lieu, peu importe qu'une exception soit levée ou non dans le bloc try.
Le fait que C++ appelle implicitement les destructeurs des objets créés automatiquement lorsque ceux-ci atteignent la fin de leur portée permet de responsabiliser chaque flux quant à sa propre fermeture, ce qui permet un support beaucoup plus complet de l'encapsulation.
Avantage non négligeable : les destructeurs d'une variable automatique sont appelés peu importe la circonstance selon laquelle la fin de la portée est atteinte: sortie brusque (return, exit(), throw) ou naturelle (atteinte de la fin du bloc), peu importe. Ainsi, le concept de finally dans la gestion des exceptions est essentiellement superflu en C++.
Imaginons un programme ayant besoin de charger une ressources externe au démarrage et de décharger cette ressource à la fermeture (une bibliothèque à liens dynamiques, une connexion à une BD, un autre programme, peu importe).
Version Java | Version C++ |
---|---|
|
|
Ici, le code Java est plutôt fragile : le programme principal doit commencer son exécution par le chargement explicite de la ressource et doit s'assurer par lui-même du déchargement de la ressource, alors qu'en C++, le déchargement se produira automatiquement de par la destruction du Chargeur, quoiqu'il advienne..
Il existe un patron fort intéressant pour automatiser et solidifier des opérations a priori délicates et pouvant laisser un système dans un état instable. Imaginons par exemple un bloc de code dans une méthode pour lequel une synchronisation est requise. Pour simplifier la démonstration, nous procéderons à la saisie de mesures de temps d'exécution pour une méthode.
Version Java | Version C++ |
---|---|
|
|
| |
|
Ici encore, le code Java semble plus court (c'est une illusion dûe aux commentaires, car il est beaucoup plus long!), mais il est de loin plus fragile et plus complexe à gérer. Deux appels à la méthode fin() d'une même instance de Mesure peut corrompre le journal; une erreur manuelle dans le code (oublier de fermer le journal, oublier d'appeler manuellement fin() lors d'une exception, etc.) peut entraîner une perte d'information.
En C++, l'existence même d'une instance automatique de Mesure garantit non seulement l'intégrité de l'objet, mais aussi la qualité de la mesure en tant que telle. Il est impossible d'appeler manuellement et incorrectement la méthode d'une Mesure servant à inscrire la mesure au journal, cette méthode étant le destructeur de l'objet.
Les systèmes répartis constituent un autre animal informatique qui se prête mal à une collecte automatique d'ordures. Les références vers un objet donné pouvant venir de plusieurs threads/ de plusieurs processus/ de plusieurs ordinateurs distincts, les moteurs de collecte automatique d'ordures tendent à offrir peu ou pas de garanties pour des objets jouant un rôle de serveur pour des objets clients pouvant être répartis un peu partout et ne se trouvant pas sous la gouverne de la machine virtuelle du serveur.
Le modèle COM, vieillissant mais encore utilisé, exige de tous les composants (disons tous les objets, pour simplifier, même si en réalité la chose est plus complexe) du modèle qu'ils respectent une interface spécifique (IUnknown) et donc qu'ils en exposent toutes les méthodes. Parmi celles-ci, ont trouve une paire de méthodes pour permettre le compte des références sur un objet donné, soit une méthode, AddRef(), pour informer un objet qu'il vient de se gagner un client (qu'un nouveau pointeur mène maintenant vers lui) et une autre, Release(), pour informer l'objet qu'un de ses clients vient de le quitter.
L'objet étant le seul à pouvoir compter ses propres clients, il doit aussi être tenu responsable de sa propre destruction lorsque le dernier de ses clients se sera déconnecté. Ce modèle se prête donc mal à une collaboration avec un moteur de collecte automatique d'ordures.
Ce formalisme fonctionne parfaitement dans la mesure où la connexion est stable entre le serveur et ses clients (ou dans la mesure où le moteur d'interopérabilité situé entre le serveur et ses clients génère par lui-même le bon nombre d'appels à Release() sur le serveur si sa connexion avec un client donné est brisée), et dans la mesure où les clients gèrent rigoureusement les appels aux deux méthodes en question.
Les modèles de .NET et de Java ont une granularité plus grossière, moins précise, mais sont aussi moins lourds à supporter pour le moteur d'interopérabilité.
Chaque composant dans un système réparti se fait offrir un bail d'une certaine durée. Chaque appel à une méthode de l'objet réinitialisera le bail, ce qui fait qu'un objet sollicité fréquemment demeurera en mémoire. Une fois que le bail d'un objet aura expiré, cet objet sera sujet à être ramassé par le moteur de collecte automatique d'ordures. Au besoin, on pourra attribuer un mécène à chaque objet, soit un objet dont le rôle sera d'en autoriser (ou non) la collecte.
Dans ce modèle, l'objet n'a pas de contrôle sur sa propre durée de vie, et ne restera en mémoire que s'il est utilisé régulièrement (ou si l'on y force). Il se prête donc bien aux collectes automatiques d'ordures.
La possibilité de partager des objets est un des aspects de l'informatique contemporaine qui rend les mécaniques d'utilisation indirecte (via pointeurs ou références) et les collectes automatiques d'ordures très alléchantes.
En effet, le problème des objets partagés est que tout client d'un tel objet (tout objet ayant un pointeur dans sa direction) est susceptible, en tout temps, d'être le dernier client. En même temps, il est pratiquement impossible (du moins si on veut rédiger du code propre et respectueux du principe d'encapsulation) de demander au dernier client d'un objet donné de vérifier qu'il en est le dernier puis, si c'est le cas, de détruire l'objet partagé. De plus, en situation multiprogrammée, il faudrait que la paire d'opérations {si je suis le dernier client, alors détruire l'objet}soit atomique, pour éviter qu'un nouveau client ne s'ajoute entre le moment du test et celui de la destruction.
Dans un modèle sans collecte automatique d'ordures, le moment où le dernier client cesse de pointer vers un objet donné est celui où cet objet est irrémédiablement perdu. Si plus personne ne mène vers lui, alors il ne seras jamais détruit. Les moteurs de collecte automatique d'ordures, eux, tiennent un compte se voulant rigoureux du nombre de clients de chaque objet, et procèdent régulièrement à une phase de nettoyage des objets vers lesquels plus personne ne mène – ce qui rend ceci possible est que le moteur de collecte d'ordures est le seul client de chaque objet pour lequel la comptabilité du nombre de références d'entre pas en considération.
L'encapsulation suggère que, si aucun moteur ne prend en charge automatiquement la destruction des objets orphelins, il faut que les objets sur le point de devenir orphelins soient responsables de leur propre destruction. Conséquemment, ces objets doivent être informés de chaque ajout d'un pointeur et de chaque retrait d'un pointeur dans leur direction.
On peut y arriver simplement par héritage (idéalement par héritage multiple), comme le montre l'exemple suivant.
Exemple, objets partageables, C++ | |
---|---|
J'ai utilisé les threads de Win32, ce qui explique l'utilisation de <windows.h>. Cela ne change rien au propos. Cela dit, quand j'aurai une minute, je migrerai cet exemple vers les threads standards de C++ 11. |
|
La classe Partageable exprime le concept selon lequel chacune de ses instances sait compter les références menant vers elle et sait s'autodétruire lorsque la dernière référence vers elle est relâchée. J'ai utilisé les noms de méthodes du modèle COM, mais le nom importe moins que le concept ici. Le compteur interne de références à un Partageable est initialisé à 1 plutôt qu'à zéro puisqu'un Partageable nouvellement créé est nécessairement utilisé par au moins un client (celui qui a utilisé new pour l'obtenir). |
|
Chacun des threads génériques utilisant notre Partageable de démonstration aura cette particularité de s'exécuter pour un temps qui se veut aléatoire. En effet, cinquante fois par seconde, il se demandera s'il y a lieu de se terminer. Une fois terminé, il relâchera le Partageable reçu en paramètre. Voir le foncteur CreerThread pour comprendre pourquoi il y a un Release() mais pas de AddRef() dans le thread. Pour associer ce thread à quelque chose de plus concret qu'une espèce de roulette russe virtuelle, imaginez que le Partageable soit plutôt un dérivé de Partageable et que la boucle appelle une de ses méthodes plutôt que de faire un vulgaire appel à Sleep(). |
|
Le foncteur CreerThread est utilisé ici par la fonction std::generate() (voir main() ci-dessous). Son constructeur prend note d'un Partageable et son opérateur() crée lors de chaque appel un nouveau thread générique auquel sera passé. dans chaque cas, le même pointeur de Partageable. Notez en particulier la gestion des constructeurs et des destructeurs de la classe CreerThread. Il est important de surcharger le constructeur par copie du fait qu'une négligence en ce sens entraînerait un débalancement du nombre de AddRef() et de Release() appliqués au Partageable. Notez aussi l'appel à AddRef() fait sur le Partageable avant le lancement du thread générique. Cette étrange stratégie est en place pour éviter ce qu'on nomme des effets de course (Race Conditions) : s'il fallait que les threads ne démarrent pas avant que CreerThread ait fait son dernier Release() et que main() en ait fait autant, alors le Partageable mourrait avant que les threads en s'en soient servis. La fonction FermerThread() est en quelque sorte l'inverse du foncteur CreerThread, et est utilisée par un std::for_each() dans main(). |
|
Ici, la classe Truc est Partageable. Dans un programme réel, ses instances auraient des méthodes utiles au bon fonctionnement des threads qui les utiliseront. Idéalement, Truc serait une classe qui combine par héritage multiple un concept utile pour les différents threads susceptibles de s'en servir le concept de Partageable. Puisque Partageable contient des attributs, il est préférable pour implémenter ceci d'avoir recours à de l'héritage multiple (cela sauve de la réécriture redondante de code). |
|
Le programme principal est simple :
|
|
Et si une fonction doit partager un Partageable pour la durée de son exécution, et doit absolument le libérer une fois complétée l'exécution de cette fonction, peu importe comment celle-ci se complète? La dualité constructeur/ destructeur des variables automatiques peut encore une fois résoudre ce problème, de la manière la plus propre et la plus efficace qui soit. Examinez l'exemple de code à droite, qui présente une version très allégée de la classe std::unique_ptr, où apparaît la classe Autorelacheur. Son constructeur saisit un Partageable et son destructeur le libère. En pratique, préférez les outils standards comme std::unique_ptr aux outils maison, de grâce! |
|
Ainsi, une fonction (comme f()) ayant besoin d'un Partageable pour son exécution peut déclarer localement une instance de Autorelacheur (qui doit être nommée, comme dans le cas de ar ici).
Ceci permet une automatisation simple, rapide et efficace de la mécanique symétrique d'obtention et de relâchement d'un Partageable.
Dans les bibliothèques standard de C++, fichier <memory>, on trouve la classe unique_ptr, raffinement C++ 11 de auto_ptr dont chaque instance assure la libération (à travers delete) de l'objet vers lequel il pointe. La classe auto_ptr avait une particularité agaçante, soit celle de ne pas permettre à deux auto_ptr distincts de pointer sur un même objet, du moins pas à travers une copie ou à travers une affectation. Ceci impose des contraintes d'expressivité aux programmeurs, qui sont entre autres privés du passage de paramètre par valeur lorsqu'ils ont recours à ce type. |
|
Pour le partage des pointés, il y a mieux encore, soit la classe shared_ptr. De manière générale, les pointeurs intelligents sont préférables à une approche intrusive comme celle utilisée ci-dessus, qui repose sur l'héritage et force ainsi l'insertion d'une dépendance supplémentaire envers un parent.
En pratique, en C++, on préfère de loin l'approche non-intrusive passant par des pointeurs intelligents à une approche intrusive qui impose un parent tel que Partageable. Les approches non-intrusives sont plus flexibles, s'appliquent à une plus vaste gamme de types (incluant des types primitifs), et réduisent par conséquent le couplage dans le code.
Petite remarque : le destructeur d'un objet ne détruit pas cet objet, peu importe le langage ou la présence (ou non) de collecte d'ordures automatique, mais contient en fait le code à appliquer lorsque cet objet cesse d'être utilisable, souvent dans le but d'effacer les traces de l'objet détruit dans le système (remettre au système les ressources sollicitées par l'objet pendant sa vie utile). À la fin d'un destructeur, la mémoire consacrée à l'objet ne peut être utilisée de manière sécuritaire, mais elle doit l'être pendant le destructeur (sinon celui-ci perd son sens : à quoi bon agir après la fin de l'objet si même les références sur des ressources externes sont a priori invalidées?)
Joël utilise aussi le terme autogéré, mais je ne l'ai pas utilisé ici parce que je ne suis pas d'accord avec le sens sous-entendu. Auto signifie « par soi-même » et les moteurs de collecte d'ordures sont précisément l'inverse d'une autogestion : par définition, on s'en remet à un tiers pour gérer à la place de l'objet ou du programme!
Pris en charge par un tiers (le moteur qu'est le CLR de .NET ou la JVM de Java) me semble de loin plus exact. Le terme pris en charge n'est pas négatif à mes yeux, et a le double avantage d'être véridique et honnête.
Ce qui suit provient en majeure partie d'un courriel envoyé par Joël Collin, qui me faisait remarquer que le code C# présenté à l'époque comme fonctionnel mais suspect était en fait défectueux, mais ne plantait qu'après une longue période d'utilisation pour des raisons circonstancielles. Joël utiliseait à l'époque C# de manière professionnelle et ses remarques sont tout à fait pertinentes.
Je me suis permis des annotations (à droite, dans des encadrés) et quelques retouches linguistiques, mais son propos reste intact pour l'essentiel.
Si le sujet de la collecte automatique d'ordures sous .NET vous intéresse, envisagez lire les articles Using GC Efficiently – Part 1, Using GC Efficiently – Part 2, Using GC Efficiently – Part 3 et Using GC Efficiently – Part 4. Dans la même lignée, envisagez la lecture de How to write friendlier code for the Garbage Collector and to gain performance boost et de cet article sur la finalisation. Certains de ces articles ont été suggérés par Joël (mais je ne me souviens plus lesquels). Plus récemment, cet article discute en détail de IDisposable en C# et couvre des variantes selon les versions des infrastructures .NET (où les règles changent souvent, alors les développeurs .NET doivent être alertes et pas trop attachés à leur code).
Il serait facile d'offrir une longue liste d'articles sur l'utilisation efficace d'une collecte automatique d'ordures sous Java comme sous .NET mais ces quelques articles vous donneront un point de départ raisonnable.
Si vous devez retenir des règles globales pour l'utilisation d'un système à collecte automatique d'ordures, je suggère les suivantes :
Choisissez vos outils avec soin et utilisez-les avec intelligence.
Tout d'abord, les clarifications :
Ces clarifications expliquent ta chance. En effet, je suis surpris que tu n'aies jamais eu d'exceptions. Si tu augmentais un peu la valeur de ta constante Z (pour les besoins de la cause à 1000000) et que tu exécutais de nouveau ton programme, tu verrais qu'en réalité, tous tes finalisateurs ne sont pas appelés. J'ai tenté l'expérience sur mon poste et le processus s'est terminé à la ligne Mort de 994140, voisin de 994141 après avoir finalisé moins de 6000 objets. Pire encore, si tu étais vraiment malchanceux, la classe statique Console pourrait s'être fait collecter au passage et Console.WriteLine te lancerait une exception.
Maintenant, quelle est la solution? En fait, devant ce manque flagrant de déterminisme, Microsoft a offert à la communauté un patron de disposition des objets pris en charge. On voudra en effet parfois libérer des quantités importantes de mémoire, bien que prises en charge, quand bon nous semble. On ne sait jamais, un objet pourrait contrôler une ressource matérielle lorsqu'aucun client n'en veut, on voudrait qu'il se ferme immédiatement (sans attendre la collecte) [oui, c'est trop précis pour ne pas être un cas vécu ].
Le patron de disposition est relativement simple. On implémente l'interface IDisposable qui offre une méthode dont la signature est la suivante: void Dispose().
De plus, on ajoutera une méthode protégée ayant la signature suivante: protected void Dispose(bool disposing). À l'intérieur de la méthode Dispose(), on fera un appel à Dispose(true). À l'intérieur du finalisateur, on fera Dispose(false).
On force donc les clients à appeler Dispose() explicitement. Pas d'appel automatique comme C++ fait avec son destructeur.
Cela dit, la question Pourquoi cela n'est-il pas automatisable? n'est toujours pas résolue. L'offre d'un destructeur (qu'on le nomme finaliseur ne change rien; le destructeur de C++ ne détruit pas l'objet non plus) sur lequel il ne suffit pas de compter ne marque-t-elle pas une faute fondamentale de design?
Si je suis bien le raisonnement, l'appel à Dispose(true) est valable parce qu'il est fait manuellement, avant la perte de la dernière référence sur l'objet détenteur de la méthode, et donc avant que le moteur de collecte d'ordures n'ait pu opérer. L'appel à Dispose(false) ne peut oeuvrer sur quelque attribut que ce soit (et ne peut donc véritablement être utile) puisqu'il pourrait être invoqué dans le finalisateur et ne pourrait donc prétendre que les références détenues par l'objet en cours de finalisation demeurent valides. Les références à des entités non prises en charge seules ont-elles droit à un traitement distinct dans un objet .NET? Les remarques ici portent à le croire (si ce sont les seules références pouvant être utilisées en toute sécurité dans Dispose(false), donc dans le finalisateur, alors elles ont droit à un traitement spécial; si elles ont le même traitement que les autres, alors le patron Dispose() est inutile car les références prises en charhge pourraient être accédées en sécurité sans lui).
Image que j'en tire (et c'est intéressant) : que les objets pris en charge soient invalidés avant l'invocation du finalisateur est nécessaire dans la philosophie .NET pour assurer la bonne finalisation d'un programme pris en charge; ceci permet au moteur de ramasser même les références circulaires (elles sont invalidés avant finalisation) puis de les finaliser. En permettant à une référence dans un objet pris en charge de signaler sa propre finalisation à un objet non pris en charge avant de décéder complétement, on donne une toute dernière chance aux objets non pris en charge de terminer leur propre relation avec l'objet pris en charge. Il n'est donc pas possible à un objet non pris en charge (hormis peut-être par des mécanismes spéciaux comme des mécènes – Sponsors – de maintenir artificiellement en vie une référence prise en charge une fois que celle-ci a entrepris son propre processus de finalisation et de disposition des ressources.
L'implémentation de protected void Dispose(bool disposing) aura donc cette allure :
protected void Dispose(bool disposing)
{
if (disposing)
{
// On fait le ménage de nos objets pris en charge
}
}
Tout de suite, je suis certain que tu te dis : Ok, mais à quoi cela sert-il de mettre un appel à Dispose(false) dans le finalisateur?
À ça je répondrai : À quoi le finalisateur sert-il? En effet, on implémente un finalisateur seulement quand on désire se débarasser d'objets non pris en charge (une interface COM à laquelle nous avons fait un AddRef(), par exemple, et qui va rester en vie toujours si on ne fait pas de Release()).
Si nous avons effectivement des références à des objets qui ne sont pas pris en charge, nous nous en débarrasserons comme ceci (à droite). Ainsi, quand on appellera explicitement Z.Dispose(), tant les objets pris en charge que ceux qui ne le sont pas seront nettoyés. Si par contre nous n'appelons pas explicitement Z.Dispose(), le finalisateur s'occupera d'appeler la méthode Dispose(false), ce qui aura pour effet de nettoyer seulement les objets qui ne sont pas pris en charge. |
|
Bon, encore un commentaire? Laisse-moi deviner... C'est con ton truc, on va faire nous-même le ménage de notre objet et ensuite on va se refaire nettoyer. Bonne remarque. J'allais justement en parler! Pour éviter que le finalisateur soit appelé après que nous ayons procédé au nettoyage de façon déterministe, il faut appeler System.GC.SuppressFinalize(this) à la fin de notre méthode Dispose(bool diposing). Ceci aura pour effet d'informer le moteur de collecte d'ordures qu'il n'y a rien à finaliser. Côté performance, ça vaut la peine de le faire.
Si on n'a pas d'objets non pris en charge dans la classe, on peut se permettre de ne pas implémenter le finalisateur du tout. Dans ce cas, on peut quand même garder la structure du patron Dispose().
Si tout est géré dans la classe et si les objets n'occupent pas beaucoup d'espace, mieux vaut laisser le moteur de collecte d'ordures faire le boulot. Il est ajusté pour faire le travail dans les pires conditions et le fait probablement mieux que quiconque. Aussi, il y a deux règles fondamentales : la première est que nous n'appelons jamais explicitement GC.Collect() et la deuxième est que nous respectons rigoureusement la première règle. De toute manière, les appels à GC.Collect() ne garantissent en rien la collecte d'un objet.
Revenons donc à ton exemple. J'ai pris soin de le modifier pour qu'il respecte les règles du jeu. Normalement, dans cet exemple, le patron de disposition ne sert à rien. Quand il aura besoin d'espace, le moteur de collecte d'ordures enlèvera tes 10 entiers et ça finira là. Évidemment, je comprends qu'il s'agit d'un exemple pédagogique alors considérons l'appel à Console.WriteLine() comme un appel critique qui doit absolument être fait (pour pimenter le tout, on aurait pu ajouter des références à des flux qui ont ouvert un fichier mais ne l'ont pas encore fermé – mais il est déjà tard et je travaille demain).
Alors quelles conclusions devrait-on tirer? Premièrement, que ce n'est pas parce qu'il y a un moteur de collecte d'ordures qu'on peut se permettre de gaspiller la mémoire. La mémoire est une ressource qu'on devrait bien gérer dans tout bon programme. Un moteur de collecte d'ordures est habituellement très gourmand: il allouera jusqu'à saturation et allouera par grosses tranches.
On peut aussi dire que le moteur de collecte d'ordures de .NET est plutôt efficace quand on l'utilise de la bonne manière. Le patron de disposition est excellent pour ajouter du déterminisme et on ne devrait se servir du finalisateur que dans des cas. Avec un bon design, on contrôle la création et la disposition des objets en tout temps.
La remarque de Joël est très pertinente, et ce pour tout moteur de prise en charge (VB6, Java, les langages .NET, etc.). Les impératifs commerciaux qui guident (légitimement!) le développement de ces produits visent à faciliter au maximum les tâches jugées normales, typiques. Ce sont de bons outils pour la majorité des tâches; ils n'ont pas la prétention d'être bons dans tous les cas, mais bien d'être bons dans la majorité des cas pour la majorité des gens. C'est tout à fait défendable.
On peut aussi dire qu'avec .NET, tout est facile... jusqu'à temps que ça devienne difficile. Alors, les enquêtes prennent des proportions gargantuesques (comme ce message d'ailleurs, que je souhaite instructif).
À l'endroit où je travaille, nous développons depuis deux ans un logiciel en C# (qui contient beaucoup classes et fait beaucoup d'interopérabilité avec COM et d'autres ressources non prises en charge – fichiers, objets GDI ou autres) et nous n'avons pas un seul finalisateur qui entre en jeu. Ils sont implémentés, certes, dans certaines classes spécifiques – pas plus de cinq ou six – mais ils ne contiennent que des assertions qui n'ont jamais été rejointes. Étonnamment, nous n'avons ni fuite de mémoire, ni fuite de ressources.
Le code proposé par Joël pour remplacer le code en exemple ci-dessus suit :
namespace z
{
class Z : IDisposable
{
private Z voisin_;
private int indice_;
public void setVoisin(Z voisin)
{
voisin_ = voisin;
}
private void setIndice(int n)
{
indice_ = n;
}
public int getIndice()
{
return indice_;
}
public Z(int n)
{
setIndice(n);
}
~Z()
{
this.Dispose(false);
}
#region IDisposable Members
public void Dispose()
{
this.Dispose(true);
}
#endregion
protected void Dispose(bool disposing)
{
if (disposing)
{
System.Console.WriteLine("Mort de {0}, voisin de {1}", getIndice(), voisin_.getIndice());
}
GC.SuppressFinalize(this);
}
}
public class Test
{
public static void Main()
{
const int NB_Z = 1000000;
Z[] t = new Z[NB_Z];
for (int i = 0; i < t.Length; i++)
t[i] = new Z(i);
for (int i = 0; i < t.Length; i++)
t[i].setVoisin(t[(i + 1) % t.Length]);
for (int i = 0; i < t.Length; i++)
t[i].Dispose();
}
}
}
Un moteur de collecte automatique d'ordures a de bons côtés mais est aussi très limitatif pour ce qui est de l'implémentation de l'encapsulation.
En effet, le moment de la destruction des objets devenant imprévisible (ou devant être pris en charge manuellement), il faut souvent réaliser explicitement des opérations (comme fermer un fichier ou un socket) qui devraient, si l'encapsulation était pleine et entière, être faites de manière automatique et implicite par les objets qui les représentent. Cela force le code client d'un objet à faire manuellement des opérations qui devraient être du ressort de cet objet.
Exemple avec C# | Exemple avec C++ |
---|---|
|
|
Ce type de problème est exacerbé lorsque le code se complique (dans les méthodes où il y a plus d'un point de sortie, à cause de traitement d'erreur ou de levées d'exceptions), du fait que les appels explicites à la libération des ressources se multiplient rapidement.
Le langage C# offre toutefois un moyen de pallier ce problème, en permettant d'utiliser le mot clé using autrement que dans le cas où un espace nommé doit être accédé implicitement. Un bloc peut être préfixé par using(expr) où expr est une expression dont le type doit être au moins IDisposable. Le langage prend alors en charge l'appel implicite à la méthode Dispose de l'objet décrit par expr à la fin du bloc.
Comme c'est souvent le cas avec C#, on s'en sort par un ajout au langage plutôt que par nos propres moyens, mais au moins il y a une porte de sortie pour rédiger du code un peu moins fragile.
namespace Z
{
public class Test
{
private void AccèsFichier()
{
using(StreamWriter sw = File.CreateText(path))
{
sw.WriteLine("Hello");
sw.WriteLine("And");
sw.WriteLine("Welcome");
} // l'appel à sw.Dispose() est implicite,
// qu'il y ait ou non levée d'exception
}
[STAThread]
static void Main(string [] args)
{
Test pTest = new Test();
pTest.AccèsFichier();
}
}
}
Un exemple de code C# profitant de ce mot clé pour générer une trace simple (message avant une tâche, message après cette tâche) suit. Le code C++ de même fonctionnalité est proposé à titre comparatif.
Exemple avec C# | Exemple avec C++ |
---|---|
|
|
Java a suivi la même voie et, depuis la version 7 de ce langage, les blocs Try-With sont supportés. Là où C# utilise IDisposable et Dispose() pour gérer la libération des ressources, Java utilise Closeable et close(). Pour le reste, c'est blanc-bonnet, bonnet blanc.
Merci à Joël Collin pour ces remarques. Ce qui suit est un extrait d'un message de Joël à moi-même; j'ai formaté le tout puisqu'il s'agit d'extraits d'un message portant sur plusieurs sujets, et j'espère ne pas en avoir transformé le sens.
C'est utile d'insérer des opérations dans un bloc using si tu crains que les développeurs qui travaillent avec toi oublient d'appeler Dispose (remarque de Patrice : l'idée est aussi de simplifier les cas où de multiples points de sortie – exceptions incluses, y compris par des méthodes qui y sont appelées – alourdiraient une méthode au point de rendre difficile son entretien; l'incompétence est un facteur, mais le problème est plus profond que ça). Mais il faut aussi se demander combien ça coûte.
En effet, using ne prend qu'un type qui implémente IDisposable. C'est très bien, mais comment fait-il pour gérer les problèmes qui peuvent arriver dans le bloc using? Par exemple, on pourrait faire comme proposé à droite... |
|
... mais à l'exécution, le code pourrait s'exécuter sur un disque C: qui est plein et une exception serait levée. Sans trop de surprise, le moteur va appeler s.Dispose() même si il y a une exception (le contraire serait troublant). Comment fait-il? Lors de la compilation, il va tout simplement remplacer le bloc using par un bloc try/ finally .
Tu sais qu'il y a un surdébit (Overhead?) important lorsqu'on utilise ces machins. Avec .NET, par exemple, toutes les méthodes qui utilisent le mot-clé throw dans leur définition ne sont pas inlinées... Pour s'en convaincre, regardons un petit bout de programme anodin. La classe MaClasse a une propriété en lecture seulement MaChaine. Pour une raison éducative, on veut s'assurer que la variable privée chaine_ soit vide quand on Dispose() l'instance de MaClasse.
using System;
namespace MonNamespace
{
static class Program
{
[STAThread]
static void Main()
{
using (MaClasse maClasse = new MaClasse("maChaine!"))
{
Console.WriteLine(maClasse.MaChaine);
}
}
private class MaClasse : IDisposable
{
private string chaine_ = string.Empty;
public MaClasse(string maChaine)
{
chaine_ = maChaine;
}
public string MaChaine
{
get
{
return chaine_;
}
}
public void Dispose()
{
chaine_ = string.Empty;
}
}
}
}
On compile et on utilise ildasm (remarque de Patrice : très utile, ça. Je m'en sers souvent, surtout pour régler des problèmes d'interopérabilité entre .NET et le monde natif. C'est un bel outil, simple et bien fait) qui vient avec le .NET Framework pour voir de quoi a l'air le code en langage intermédiaire (ayant compilé en Debug, c'était rempli de nop à cause de certains commentaires; j'ai pris la liberté de les enlever) :
.method private hidebysig static void Main() cil managed
{
.entrypoint
.custom instance void [mscorlib]System.STAThreadAttribute::.ctor() = ( 01
00 00 00 )
// Code size 46 (0x2e)
.maxstack 2
.locals init ([0] class MonNamespace.Program/MaClasse maClasse,
[1] bool CS$4$0000)
IL_0001: ldstr "maChaine!"
IL_0006: newobj instance void
MonNamespace.Program/MaClasse::.ctor(string)
IL_000b: stloc.0
.try
{
IL_000d: ldloc.0
IL_000e: callvirt instance string
MonNamespace.Program/MaClasse::get_MaChaine()
IL_0013: call void [mscorlib]System.Console::WriteLine(string)
IL_001a: leave.s IL_002c
} // end .try
finally
{
IL_001c: ldloc.0
IL_001d: ldnull
IL_001e: ceq
IL_0020: stloc.1
IL_0021: ldloc.1
IL_0022: brtrue.s IL_002b
IL_0024: ldloc.0
IL_0025: callvirt instance void
[mscorlib]System.IDisposable::Dispose()
IL_002b: endfinally
} // end handler
IL_002d: ret
} // end of method Program::Main
Ceci étant dit, il y a un coût aux blocs using, mais c'est super pratique quand même. On s'en sert surtout avec les objets GDI+ (qui ont des handles bruts) mais aussi dans une tonne de cas dont je te donne deux exemples :
Si tu veux en savoir plus, je ne saurais trop te référer à l'unique référence valable pour tout ce qui touche à .NET: le livre Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries, de Brad Abrams et Krzysztof Cwalina, deux des architectes principaux derrière le .NET Framework, avec des commentaires de monuments comme Anders Hejlsberg (le gars derrière Pascal – remarque de Patrice : ça, en fait, c'est Niklaus Wirth, mais Hejlsberg a contribué à certaines versions de Turbo Pascal – Delphi et C#). Ils expliquent quoi faire et quoi ne pas faire quand on fait des bibliothèques publiques avec .NET et expliquent leur choix (et avouent leurs torts!). Fascinant livre.
Je l'ai lu; si vous voulez savoir ce que j'en ai pensé, voir ../../Liens/Suggestions-lecture.html#framework_design_guidelines