Il peut arriver qu'on ait besoin de classes à instanciation unique.
Par instanciation unique, on entend ici une classe qu'il est impossible d'instancier plus d'une fois dans un programme donné.
Aussi étrange que ce concept puisse apparaître, il est relativement fréquent qu'on y ait recours (ou qu'on rencontre une situation pour laquelle ce serait une approche viable) dans des projets de la vie courante.C'est même un schéma de conception (un Design Pattern) répandu.
On nomme singleton une classe qui ne peut être instanciée qu'une seule fois pour un programme donné, quel qu'il soit. Vous trouverez plus d'information à ce propos sur xxxxx
Bien que les singletons représentent une famille de classes particulière – il ne s'agit pas là de l'approche orientée objet la plus typique ou la plus souvent rencontrée – il existe plusieurs raisons envisageables pour désirer avoir recours à une classe de ce genre.
Pensons par exemple à :
Une fois la nécessité occasionnelle de singletons acceptée, reste à savoir comment implanter ce concept. Heureusement, c'est là quelque chose de relativement simple. Nous verrons ici comment procéder, de plusieurs manières différentes. Un risque potentiel de l'emploi de singletons – les accès concurrents – sera mentionné au passage, mais sa solution réelle sera escamotée ici .
Plusieurs stratégies sont possibles pour empêcher une classe d'être instanciée plus d'une fois dans un programme, mais toutes ces stratégies ont en commun un certain nombre d'éléments, parmi lesquels on trouve :
Vous remarquerez que chacune de ces stratégies a en commun d'utiliser un constructeur privé. On comprendra pourquoi: en effet, si le constructeur était public, le code client pourrait instancier plusieurs fois la classe.
Avec cette approche, l'singleton sera allouée dynamiquement et son adresse sera affectée à un attribut de classe, que nous avec nommé ici singleton – parce qu'il s'agit de l'unique singleton qui sera jamais construite de cette classe dans tout programme – et qui est initialisé à zéro, prenant ici le sens de pointeur nul.
L'instanciation est faite lors d'un appel à une méthode (ici get()), ce qui fait que l'objet ne sera créé que si au moins une demande d'accès y est faite. Seul le premier appel à cette méthode résultera en une instanciation; les appels subséquents réutiliseront tous l'instance initialement construite.
Notez que chaque appel à get() nécessite l'évaluation d'une condition, mais que, malgré tout, on vérifie à chaque fois si l'instanciation a été faite ou non. Si un programme sait qu'il devra instancier le singleton, le temps utilisé à valider que l'instanciation ait eu lieu (ou non) est une perte sèche, et on privilégiera d'autres stratégies.
class X {
// pointeur vers le singleton
static X *singleton;
X(); // constructeur privé
public:
// pour obtenir l'singleton
static X *get() {
//
// instanciation unique garantie, du moins si
// le programme n'a qu'un seul fil d'exécution;
// si votre programme comprend plusieurs fils
// d'exécution, ce qui suit est insuffisant
//
if (!singleton)
singleton = new X;
return singleton;
}
// ...
};
// initialisation du pointeur de singleton à zéro
// (insérer dans le .cpp)
X *X::singleton = nullptr;
Pourquoi faut-il absolument que get() soit une méthode de classe (une méthode static)?
Un vrai singleton ne peut être dupliqué, et doit donc être implémenté de manière à ce que toute tentative de le copier soit illégale. Pour arriver à ce niveau de protection et de confiance, il importe d'empêcher les deux opérations permettant la copie d'un objet et qui ont une implémentation par défaut si on les néglige, soit l'opérateur d'affectation (opérateur =) et le constructeur par copie.
class X {
static X *singleton; // pointeur vers le singleton
X(); // constructeur privé
public:
X(const X&) = delete;
X& operator=(const X&) = delete;
static X *get() { // pour obtenir le singleton
if (!singleton) // voir plus haut
singleton = new X;
return singleton;
}
// ...
};
// initialisation du pointeur de singleton à zéro
// (insérer dans le .cpp)
X *X::singleton = nullptr;
Pour un autre truc amusant, voir cet article sur l'idiome des objets incopiables. à partir d'ici, je présumerai que vous avez lu et compris cet article et j'utiliserai la classe Incopiable dans le but d'alléger le code.
Cette approche ressemble à la précédente, à ceci près que l'instanciation du singleton se fait dès le lancement du programme (à l'initialisation du membre de classe singleton).
Avec cette solution, chaque appel à get() sera plus rapide que dans l'approche 0, aucun test n'étant requis sur le pointeur vers le singleton, mais singleton sera allouée qu'on s'en serve ou non.
#include "Incopiable.h"
class X : Incopiable {
static X *singleton; // pointeur vers le singleton
X(); // constructeur privé
public:
static X *get() noexcept { // pour obtenir le singleton
return singleton;
}
// ...
};
// initialisation du pointeur de singleton à zéro
// (insérer dans le .cpp)
X *X::singleton = new X;
Le défaut de cette stratégie est que l'objet ainsi créé (le singleton) ne sera jamais finalisé, au sens où son destructeur ne sera jamais appelé – un objet créé avec new doit être détruit avec delete).
La mémoire associée au singleton sera libérée, bien entendu, à la mort du programme. Cela dit, dans le cas où des opérations spécifiques seraient faites lors d'une invocation du destructeur (fermeture d'un lien de communication, d'une connexion à une BD, libération d'une DLL chargée manuellement, etc.) alors celles-ci ne seront pas réalisées.
Cette stratégie est donc à déconseiller, du moins si on la prend telle quelle.
Il y a des solutions à ce problème : entre autres, une solution reposant sur une technique exploitant des classes internes (voir plus loin) et le recours à un pointeur intelligent, ce qui serait l'idéal ici.
Cette approche est identique à la précédente, sauf pour le fait que le singleton n'est pas alloué dynamiquement et que, conséquemment, on prendra soin de donner accès à son adresse seulement (pas question d'en créer une copie par accident, après tout!). Cette stratégie s'applique aussi en retournant une référence à l'instance en question.
#include "Incopiable.h"
class X : Incopiable {
static X singleton; // déclaration du singleton
X(); // constructeur privé
public:
static X *get() noexcept { // pour obtenir le singleton
return &singleton;
}
// ...
};
// définition du singleton
// (insérer dans le .cpp)
X X::singleton;
Mieux encore, sur le plan de l'utilisation : retourner une référence au singleton.
#include "Incopiable.h"
class X : Incopiable {
static X singleton; // déclaration du singleton
X(); // constructeur privé
public:
static X &get() noexcept { // pour obtenir le singleton
return singleton;
}
// ...
};
// définition du singleton
// (insérer dans le .cpp)
X X::singleton;
On peut retourner l'adresse du singleton, ou une référence vers celui-ci, mais pourquoi ne peut-on pas retourner une instance du singleton par valeur?
Notez qu'il est essentiel ici que le destructeur soit public, sinon le compilateur ne pourra pas finaliser le singleton à la fin de l'exécution du programme donc le programme ne compilera pas.
Dans cette approche, le singleton n'existera réellement que dans la méthode que y donne accès. La variable qu'est le singleton sera localisée au même endroit que les variables globales du programme mais ne sera visible que de l'intérieur du sous-programme où elle est déclarée.
Le singleton singleton est instancié lors du premier appel à get(), comme dans l'approche 0 proposée plus haut.
L'adresse rendue disponible reste valide tout au cours de l'exécution du programme parce que la variable est statique (au sens du langage C). Il y a un léger coût en performance ici lors de chaque invocation de la méthode get() (le code machine doit sauter par-dessus le code d'initialisation lors des appels suivant le tout premier, ce qui introduit un très léger ralentissement en pratique). Remarquez que la clause noexcept apposée à X::get() dans certaines approches ne peut être utilisée ici (sauf bien sûr si X::X() est aussi noexcept) du fait que le premier appel à X::get(), contrairement aux appels subséquents, construira le singleton.
#include "Incopiable.h"
class X : Incopiable {
X(); // constructeur privé
public:
static X &get() { // pour obtenir le singleton
// Instanciation au premier appel seulement
static X singleton;
return singleton;
}
// ...
};
Ici, le destructeur n'a pas à être public, car la construction du singleton se fait à même l'une de ses propres méthodes.
Les programmes qui font affaire avec un singleton sont souvent multiprogrammés, par exemple à l'aide de fils d'exécution concurrents (de threads).
Sans entrer dans les détails, tout processus – tout programme en cours d'exécution – est composé d'un ou de plusieurs fils d'exécution – d'un ou de plusieurs threads. Un même processus peut donc être fait de plusieurs threads distincts, qui s'exécutent de manière concurrente.
Lorsqu'un programme muni d'un singleton est fait de plusieurs threads, il existe un risque bien réel que deux d'entre eux tentent d'accéder au singleton pendant une même tranche de temps. Si cela se produit, un risque d'accès concurrent aux données du singleton peut se produire – et un accès concurrent peut mener à une violation de partage si deux des accès tentés sont des accès en écriture, donc qui tentent de modifier une même donnée.
Les violations de partage peuvent faire planter un programme, et sont un problème de la vie courante dans les systèmes multiprogrammés. Mêler threads et singletons demande une saine dose de prudence.
Certaines stratégies de singletons créent le singleton lors de la première demande faite pour ses services; d'autres créent le singleton avant même le démarrage du thread principal d'un programme (de la fonction main()).
Dans un cas où plus d'un singleton se trouve créé avant même le démarrage du programme, comme par exemple dans le cas proposé à droite. Dans quel ordre seront créés A::singleton, B::singleton et C::singleton? Dans l'ordre selon lequel ils sont déclarés? Dans l'ordre inverse de leur déclaration? Dans l'ordre selon lequel ils sont définis? Dans l'ordre inverse de leur définition? En ordre alphabétique? Autre chose complètement? Il est difficile de prédire l'ordre de construction des variables globales; il est imprudent (on pourrait d'ailleurs sans gêne utiliser le mot dangereux) d'écrire du code qui dépende de l'ordre de ces constructions. Pourtant, l'une des utilités potentielles des singletons est d'automatiser certaines initialisations et certains monceaux de code de terminaison ou de nettoyage. Visiblement, un problème conceptuel (qui devient vite un problème technique) survient du moment qu'un singleton global dépend de la construction d'un autre. Que devrait-on en déduire? Voici :
Souvent, quand une situation de dépendance survient, la solution est d'éliminer complètement les singletons et de créer un gestionnaire de démarrage, lui-même singleton, qui sera responsable de créer les objets qui auraient pu être des singletons globaux, dans un ordre respectant leurs contraintes de dépendance, et qui les détruira en ordre inverse. Dans un cas de dépendance entre singletons, il est préférable d'avoir recours aux stratégies 0 et 3 qu'aux stratégies 1 et 2, du fait que la sollicitation d'un singleton crée, dans chaque cas, ceux dont il a besoin. En retour, la stratégie 0 coûte plus cher en temps d'exécution que les alternatives, ce qui est un irritant important dans certains projets. Notez que, toute stratégie confondue, une dépendance circulaire (singleton A dépend de singleton B et singleton B dépend de singleton A) est une faute de design qui tuera le code. Si vous rencontrez un tel cas, alors il faut repenser cette partie de votre système. |
|
Les dépendances entre singletons sont aussi dommageables à la construction qu'à la destruction. On oublie souvent ce détail. La prudence est de mise.
Il est possible (contrairement à ce que plusieurs pensent) de contrôler la création de singletons construits de manière statique avant l'exécution du programme. Le code pour y arriver est subtil et repose fortement sur des stratégies de programmation générique. Une solution moins élégante mais plus simple se déclinerait comme suit :
Sans être très élégante ni très facile à transporter d'un projet à l'autre, cette approche a le mérite d'être simple à implémenter. Si vous souhaitez une infrastructure plus riche pour contrôler l'ordre de construction et de destruction de vos singletons, alors il vous faudra investir un peu plus d'efforts au préalable... Si la question du contrôle de l'ordre de destruction des singletons vous échappe, voir ceci. |
|
L'approche 1, on s'en souviendra, avait le défaut de créer un objet (le singleton) dynamiquement (avec new) sans garantir sa destruction correcte (avec delete). On se souviendra aussi que l'approche 1 est plus rapide à l'usage que l'approche 0, du fait que la méthode get() en est simplifiée. Il arrive qu'on veuille vraiment utiliser new pour créer un objet, car cet opérateur peut être surchargé et servir entre autres à placer l'objet ainsi créé à un endroit spécifique en mémoire – par exemple sur un morceau de matériel précis. Une solution capable d'assurer à la fois l'emploi de new pour créer le singleton, la bonne construction du singleton et sa bonne destruction repose sur l'utilisation d'une classe interne, ou imbriquée. Le singleton y est indirect. On déclare une classe interne et privée dans le singleton (ici, la classe est nommée X::GestionnaireDeX). Toute singleton de cette classe possède un attribut de type X*, créé dynamiquement dans son constructeur et détruit dynamiquement dans son destructeur. La paire constructeur/ destructeur de cette classe interne chapeaute ainsi la mécanique de construction et de destruction dynamique du singleton. Dans la classe X, l'attribut de classe devient un X::GestionnaireDeX, nommé X::gX. La méthode d'accès au singleton, X::get(), devient un relais indirect vers la méthode équivalente de X::gX. Ce faisant, X::gX est construit et détruit par le compilateur, et on utilise sa mécanique symétrique de construction/ destruction pour automatiser l'application aussi symétrique de new et de delete sur le singleton véritablement désiré. |
|
Imaginons qu'on veuille faciliter l'accès à des nombres pseudo-aléatoires, de manière à ce que le générateur soit correctement initialisé dans chaque programme y ayant recours et de manière à ce qu'obtenir un nombre pigé pseudo-aléatoirement entre deux bornes soit un service clairement défini. Ce type d'opération est relativement fréquent, et on peut se demander s'il n'y aurait pas lieu de l'implémenter proprement par des objets.
Le fait qu'un générateur de nombres pseudo-aléatoires doive être initialisé une fois par programme seulement est un indice qu'un singleton pourrait y trouver une application intéressante.
Le code irait comme suit.
GenerateurStochastique.h | GenerateurStochastique.cpp |
---|---|
|
|
Un exemple d'utilisation en serait celui-ci, qui tire puis affiche une combinaison de la 6/49 qui sera triée et sans redondance.
#include "GenerateurStochastique.h"
#include <vector>
#include <algorithm>
#include <iterator>
#include <iostream>
int main() {
using namespace std;
//
// Pour alléger l'écriture
//
using value_type = GenerateurStochastique::value_type;
const value_type
NB_CHIFFRES = 6,
BORNE_MIN = 1,
BORNE_MAX = 49;
vector<value_type> combinaison;
while(combinaison.size() < NB_CHIFFRES) {
auto nombre = GenerateurStochastique::get().piger(BORNE_MIN, BORNE_MAX);
if (find(begin(combinaison), end(combinaison), nombre) == end(combinaison))
combinaison.push_back(nombre);
}
sort(begin(combinaison), end(combinaison));
cout << "Tirage de la " << NB_CHIFFRES << "/" << BORNE_MAX << ": ";
copy(begin(combinaison), end(combinaison), ostream_iterator<value_type>{cout, " "});
}
Un compilateur C++ de qualité tend à procéder à plusieurs optimisations agressives. Parmi ces optimisations, on peut en trouver certaines qui entrent directement en conflit avec la vocation de certaines applications du schéma de conception singleton.
Pensons par exemple à un singleton ayant pour vocation d'exister, sans plus, et dont on exploite le constructeur pour réaliser des opérations d'initialisation et le destructeur pour réaliser des opérations de nettoyage. Le cas en exemple à droite est une illustration d'un tel singleton: sous Win32, le moteur de sockets doit être chargé et déchargé explicitement (fonctions globales WSAStartup() et WSACleanup(), respectivement) et il peut être agréable pour du code exploitant les sockets d'automatiser cette mécanique en l'implantant à travers un singleton, garantissant ainsi le chargement a priori des sockets et leur déchargement éventuel, quoiqu'il advienne. On remarquera que ce code ne demande pas même qu'on accède au singleton une fois celui-ci construit (pas de méthode de classe get() qui soit réellement nécessaire). |
|
Cela dit, il est possible que cette implémentation pose problème. Imaginons que ChargeurSockets soit intégré à une bibliothèque à liens statiques (.lib ou.a, selon les plateformes) offrant un certain nombre de services exploitant des sockets, pour automatiser le chargement et le déchargement des sockets de manière transparente au code client. Il est possible (probable, même!) que le compilateur et son optimiseur fassent le constat que, puisque personne n'accède à ChargeurSockets::singleton, alors cet objet est superflu et peut être éliminé du code compilé de la bibliothèque. Évidemment, cela va clairement à l'encontre de notre démarche. Comment contrer cet irritant? |
|
Plusieurs trucs sont possibles, mais tous reviennent au même: il faut forcer le compilateur à générer le code, sans toutefois entraîner de coût à l'exécution. La stratégie la plus simple pour y arriver va comme suit. Tout d'abord, exposer une méthode de classe dans le singleton dont on désire forcer l'existence (pourquoi pas un simple get()?). |
|
Ensuite, faire en sorte qu'en un point qui sera utilisé (par exemple dans une classe dont feront nécessairement usage les éléments de code client dépendant de la construction du singleton) on déclare un pointeur conforme à la méthode de classe du singleton en question. |
|
Enfin, utiliser ce pointeur pour obtenir un pointeur sur la méthode du singleton. Le compilateur ne peut plus, à partir de ce moment, savoir si la méthode vers laquelle on tient un pointeur sera invoquée ou non (elle ne le sera pas, évidemment, mais c'est un secret!), et se voit donc forcé de générer le code du singleton. |
|
Ceci n'est toutefois pas à toute épreuve. Une solution plus efficace est de déclarer un pointeur sur la méthode de classe du singleton en tant que variable locale à une méthode destinée à être appelée (un constructeur d'une classe dépendant de l'existence du singleton) et d'y affecter l'adresse de la méthode de classe en question. Cette stratégie est très efficace et son coût (avant toute optimisation) est celui d'une affectation d'un entier et d'une variable locale de la taille de l'adresse d'un sous-programme. |
|
Ce qui suit provient en partie de stratégies employées par l'équipe de Dominik Bauset, Julien Gilli, Jean-Philippe Lamarre et Gregory Serafino de la cohorte 03 du Diplôme de développement du jeu vidéo offert à l'Université de Sherbrooke. Une partie de l'explication des subtilités de la manoeuvre provient d'échanges avec Florian Boeuf-Terru, de la cohorte 06 du Diplôme de développement du jeu vidéo. Pour comprendre cette approche, il est préférable de s'être familiarisé au préalable avec l'idiome CRTP.
Imaginons que l'approche 03 ait été choisie pour implémenter un singleton mais qu'on souhaite faire en sorte de réduire la quantité de code redondant à écrire pour chaque singleton. L'idée, donc, est de réduire le code de service du singleton à sa plus simple expression, idéalement un constructeur privé, un destructeur privé et des méthodes d'singleton.
Pour les besoins de la cause, imaginons que le service souhaité soit un simple fournisseur de nombres entiers séquentiels, représenté à droite par la classe Service. Avec cette approche, la classe Singleton est un descendant générique de Incopiable. Tout descendant de Singleton est donc aussi Incopiable jusqu'à preuve du contraire. La généricité sur une classe S est utilisée opour définir une méthode de classe dans Singleton<S> qui instanciera et offrira une référence sur le singleton en question. Il faut donc que Singleton<S> soit la seule classe autorisée à instancier S – hormis S elle-même qui, si elle veut être un singleton, ne commettra pas un tel impair. Le service destiné à être un singleton sera, donc, la classe Service (nom peu significatif; dans vos programmes, utilisez un nom convenant au service offert) qui dérivera de Singleton<Service> (recours à l'idiome CRTP), rendant ainsi l'enfant Incopiable au passage. Pour rencontrer notre exigence de simplicité, elle ne définira que son propre code sans se soucier de la mécanique d'instanciation du singleton. Sa seule responsabilité sera, en fait, de définir que Singleton<Service>, son parent, est son amie (pour permettre au parent d'instancier l'enfant même si le constructeur et le destructeur de ce dernier sont tous deux privés). Vous trouverez, à la fin du code offert en exemple, une démonstration que le tout fonctionne normalement. L'écriture très explicite Singleton<Service>::get() est moins irritante qu'il n'y paraît du fait que le client, invoquant get(), devait de toute manière être conscient qu'il transigeait avec un singleton. |
|
Il y a une subtilité dans le choix des termes avec une approche un peu hors-normes comme la variante ci-dessus, du moins si l'on souhaite l'expliquer en utilisant un vocabulaire proche des usages plus classiques en POO.
Par CRTP, on peut choisir comme parent une application d'une classe générique sur le nom de notre propre classe. Ici, au fond, en exprimant le schéma de conception sous la forme d'une application de cet idiome, on qualifie en fait la classe Service de singleton; le parent devient une annotation, un service, plus qu'une classe au sens traditionnel; il est sans doute préférable, pour fins de compréhension, de le présenter ainsi plutôt que de prendre la manoeuvre sous un regard classique, qui serait plus proche de l'héritage public. Comme c'est souvent le cas avec l'héritage privé, qui est un élément d'implémentation plutôt que d'interface, le design ici est d'injecter dans le système des opérations sur Service (ici, un système d'instanciation unique, suivant l'approche de Scott Meyers, et une qualification d'« incopiabilité »). Singleton<Service> n'est pas un parent au sens usuel du terme; il l'est, bien sûr, mais il n'offre aucun état – outre l'état d'être incopiable – à son enfant et il ne contribue pas à l'interface de Service puisque l'héritage est privé, donc seul Service connaît leur relation. |
|
D'un regard technique, on peut constater que Singleton<Service>::get() est en fait une fonction globale, qualifiée par le type de ce qui doit être fabriqué. En dérivant Service de Singleton<Service>, on a surtout enrichi l'espace global d'une fonctionnalité spécialisée pour instancier Service dans le respect du schéma de conception.
Une autre raison de prendre cette manoeuvre comme une annotation plutôt que de la considérer comme étant une application de l'héritage est que le nom du parent (pris en tant que parent) est trompeur. Un singleton, selon l'acception stricte du concept, ne peut pas avoir d'enfants : en POO, en effet, un enfant est aussi en partie son parent, alors le singleton-comme-parent ne serait plus un singleton. Par contre, ici, le singleton est Service, pas Singleton<Service> (c'est là la subtilité de la manoeuvre). C'est pourquoi j'estime sage de considérer cette application de l'héritage privé et de l'idiome CRTP comme une annotation intelligente de l'enfant; cela me semble être une interprétation plus juste de cette variante, malgré les apparences.
Ce qui suit a été rédigé de prime abord pour répondre aux questions du sympathique Jérôme Rampon, en lien avec l'impossibilité a priori d'établir à même le code source l'ordre de destruction de variables globales (dont les singletons) lorsque celles-ci sont disséminées dans divers fichiers sources.
Allons-y d'un exemple. Soit les classes suivantes :
Fichier X.h | Fichier Y.h | Fichier Z.h |
---|---|---|
|
|
|
Fichier X.cpp | Fichier Y.cpp | Fichier Z.cpp |
|
|
|
Ici, présumant le code découpé en six fichiers tel qu'indiqué ci-dessus, on sait que :
Le compilateur compile chaque .cpp séparément, alors que les conditions propres à l'ordre d'instanciation ou de destruction des variables globales sont des considérations transversales, qui dépendent de plusieurs fichiers sources distincts. Voilà la racine du problème.