Singletons en C++

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

Pourquoi vouloir un singleton?

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 .

Assurer l'instanciation unique : quelques stratégies

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 des constructeurs privés. On comprendra pourquoi: en effet, si les constructeurs étaient publics, un sous-programme utilisateur pourrait instancier plusieurs fois la classe à l'aide de l'opérateur new.

Approche 0 – membre de classe et instanciation sur demande

Personnellement, j'utilise parfois get() plutôt que getInstance() pour nommer ma méthode d'accès au singleton. Cela dit, dans certains langages (en particulier les langages .NET), le mot get est réservé, ce qui rend la stratégie non portable à cette plateforme.

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 thread)
      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)?

Notez que j'ai nommé get() la méthode de classe donnant accès au singleton en tant que tel; j'aime bien cette écriture, concise et directe. Dans la littérature, les écritures getInstance() ou GetInstance() sont les plus fréquemment rencontrées. Notez aussi que get est un mot réservé en C# et ne serait donc pas adéquat dans ce cas.

Parenthèse – empêcher la copie

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.

Approche 1 – membre de classe et instanciation au lancement

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.

Approche 2 – membre de classe et instanciation statique

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?

Approche 3 – variable statique locale à une méthode

Cette variable est localisée au même endroit en mémoire que le sont les variables globales. Elle est initialisée au premier appel du sous-programme qui la déclare, et garde sa valeur (pour un objet: son état) d'un appel à l'autre (pour un objet : n'est pas détruite – ou reconstruite – avant la fin de l'exécution du programme).

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;
   }
   // ...
};

Risques d'accès concurrents

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.

Singletons et interdépendances

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 :

  • Que les singletons globaux devraient idéalement être indépendants les uns des autres, et
  • Que dans le cas où une dépendance existe entre au moins deux singletons globaux, il faut avoir recours à d'autres mécanismes pour les coordonner. On a recours dans ce cas à des mécanismes que contrôlera le programme

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.

#include "Incopiable.h"
class A : Incopiable {
   static A singleton;
   A ()
      { /* ... */ }
public:
   static A &get() noexcept
      { return singleton; }
   // ...
};
class B : Incopiable {
   static B singleton;
   B()
      { /* ... */ }
public:
   static B &get() noexcept
      { return singleton; }
   // ...
};
class C : Incopiable {
   static C singleton;
   C()
      { /* ... */ }
public:
   static C &get() noexcept
      { return singleton; }
   // ...
};
// dans le ou les fichier(s) source
A A::singleton;
B B::singleton;
C C::singleton;
// ...

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 :

  • Faire en sorte que les « singletons » soient en fait des classes qui ne peuvent être instanciées que par un tiers très précis, qui soit lui-même un singleton (dans l'exemple à droite, on parle du singleton Ges). Ceci se fait typiquement à l'aide de l'amitié (mot clé friend)
  • Faire en sorte que ce tiers instancie dans un ordre qui vous convient les « singletons » (ici, la méthode privée Ges::creer() fait ce travail à l'aide d'instanciation dynamique)
  • Faire en sorte que ce tiers détruise dans un ordre qui vous convient les « singletons » (ici, la méthode privée Ges::detruire() est utilisée à cette fin)
  • Offrir un service permettant d'obtenir les « singletons » désirés de manière homogène à partir du tiers spécialisé (ici, la méthode générique Ges::obtenir<T>() offre ce service)

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.

#include "Incopiable.h"
class SingX : Incopiable {
   friend class Ges;
   SingX() = default;
public:
   int f() const
      { return 3; }
};
class SingY : Incopiable {
   friend class Ges;
   SingY() = default;
public:
   int g() const
      { return 4; }
};
class Ges : Incopiable {
   SingX *pX;
   SingY *pY;
   void creer() {
      //
      // contrôler l'ordre de construction
      //
      pY = new SingY;
      try {
         pX = new SingX;
      } catch (...) {
         delete pY;
         throw;
      }
   }
   void detruire() noexcept {
      //
      // contrôler l'ordre de destruction
      //
      delete pX;
      delete pY;
   }
   Ges() {
      creer();
   }
public:
   static Ges& get() {
      static Ges sing;
      return sing;
   }
   template <class T>
      static T& obtenir();
   template <>
      static SingX& obtenir<SingX>()
         { return *(get().pX); }
   template <>
      static SingY& obtenir<SingY>()
         { return *(get().pY); }
   ~Ges() noexcept {
      detruire();
   }
};
template <class T>
   T &obtenir()
      { return Ges::get().obtenir<T>(); }
int main()
{
   int i = obtenir<SingX>().f();
}

Complément à l'approche 1 – automatiser la destruction du singleton

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é.

#include "Incopiable.h"
class X : Incopiable {
   class GestionnaireDeX {
      X *singleton;
   public:
      GestionnaireDeX()
         { singleton = new X; }
      ~GestionnaireDeX() noexcept
         { delete singleton; }
      X *get()
         { return singleton; }
   };
   static GestionnaireDeX gX;
   X(); // constructeur privé
public:
   // pour obtenir indirectement l'singleton
   static X *get() noexcept
      { return gX.get(); }
};
// instanciation du singleton (dans le .cpp)
X::GestionnaireDeX X::gX;

Exemple concret – un générateur de nombres aléatoires

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
#include "Incopiable.h"
#include <random>
class BornesInvalides {}; // pour piger()
// Nom savant, idée simple :)
class GenerateurStochastique : Incopiable {
   static GenerateurStochastique singleton;
   GenerateurStochastique() noexcept;
   std::mt19937 prng;
public:
   using value_type = unsigned int; // type public interne
   static GenerateurStochastique &get() noexcept
      { return singleton; }
   value_type piger(value_type b_min, value_type b_max) const;
};
#include "GenerateurStochastique.h"
#include <random>
using namespace std;

GenerateurStochastique GenerateurStochastique::singleton;

GenerateurStochastique::GenerateurStochastique() noexcept {
   random_device rd;
   prng = mt19937{rd()};
}
auto GenerateurStochastique::piger(value_type b_min, value_type b_max) const -> value_type {
   if (b_max < b_min) throw BornesInvalides{};
   uniform_int_distribution<> de{ b_min, b_max };
   return de(prng);
}

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, " "});
}

Les aléas de l'optimisation

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).

#ifndef CHARGEUR_SOCKETS_H
#define CHARGEUR_SOCKETS_H
#include "Incopiable.h"
class ChargeurSockets : Incopiable {
   static ChargeurSockets singleton;
   ChargeurSockets() noexcept;
public:
   ~ChargeurSockets();
};
#endif

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?

#include "ChargeurSockets.h"
#include <winsock2.h>
// Définition du singleton
ChargeurSockets ChargeurSockets::singleton;
// Code (simplifié, sans traitement d'erreur)
ChargeurSockets::ChargeurSockets() noexcept {
   WSADATA wsaData = { 0 };
   WSAStartup(MAKEWORD(2,2), &wsaData);
}
ChargeurSockets::~ChargeurSockets() noexcept
   { WSACleanup(); }

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()?).

#ifndef CHARGEUR_SOCKETS_H
#define CHARGEUR_SOCKETS_H
#include "Incopiable.h"
class ChargeurSockets : Incopiable {
   static ChargeurSockets singleton;
   ChargeurSockets() noexcept;
public:
   static ChargeurSockets &get() noexcept
      { return singleton; }
   ~ChargeurSockets() noexcept;
};
#endif

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.

#include "ChargeurSockets.h"
// Classe soigneusement choisie et dépendant des
// sockets, donc de la création du singleton
class SocketBase
{
   // ... bla bla ...
private:
   // pointeur sur une méthode retournant une référence à
   // un GestionnaireSockets et ne prenant aucun paramètre...
   // très exactement comme ChargeurSockets::get()
   static GestionnaireSockets& (*pPatch)();
   // ... bla bla ...
};

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.

#include "SocketBase.h"
GestionnaireSockets& (*SocketBase::pPatch)() =
   GestionnaireSockets::get;
// ...

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.

#include "ChargeurSockets.h"
// Classe soigneusement choisie et dépendant des
// sockets, donc de la création du singleton
class SocketBase {
   // ... bla bla ...
private:
   //
   // on prend l'adresse de ChargeurSockets::get(),
   // sans l'appeler
   //
   SocketBase() {
      auto patch = GestionnaireSockets::get;
      // ... bla bla ...
   }
   // ... bla bla ...
};

Variante sur l'implémentation du schéma de conception Singleton

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.

#include "Incopiable.h"
template <class S>
   struct Singleton : Incopiable {
      static S& get() {
         static S singleton;
         return singleton;
      }
   };
//
// Ceci est un exemple pour démontrer la technique
//
class Service : Singleton<Service> { // magie!
   // sans ceci, le parent ne peut instancier son enfant
   friend class Singleton<Service>;
   Service() : cur{} {
      // bla bla
   }
   ~Service() noexcept {
      // bla bla
   }
   int cur;
public:
   // vos services vont ici
   int prochain() noexcept {
      return ++cur;
   }
};
//
// Ta-daaa: ça marche!
//
#include <iostream>
int main() {
   using std::cout;
   auto &service = Singleton<Service>::get();
   for (int i = 0; i < 10; ++i)
      cout << service.prochain() << ' ';
} 

Où est le 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.

class Service : Singleton<Service> {
   friend class Singleton<Service>;
   Service();
   // ...
};

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.

La question de l'ordre de destruction des singletons

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
#ifndef X_H
#define X_H
//
// Fichier X.h
//
#include "Incopiable.h"
class X : Incopiable {
   X() = default;
public:
   static X &get() {
      static X singleton;
      return singleton;
   }
   ~X();
   int f();
};
#endif
#ifndef Y_H
#define Y_H
//
// Fichier Y.h
//
#include "Incopiable.h"
class Y : Incopiable {
   int val;
   Y();
public:
   static Y &get() {
      static Y singleton;
      return singleton;
   }
   ~Y();
   int f();
};
#endif
#ifndef Z_H
#define Z_H
//
// Fichier Z.h
//
#include "Incopiable.h"
class Z : Incopiable {
   int val;
   Z();
public:
   static Z &get() {
      static Z singleton;
      return singleton;
   }
   ~Z();
   int f();
};
#endif
Fichier X.cpp Fichier Y.cpp Fichier Z.cpp
//
// Fichier X.cpp
//
#include "X.h"
#include <iostream>
using namespace std;
X::~X() {
   // Voir la note, plus bas
   cout << "Destructeur de X"
        << endl;
}
int X::f()
   { return 3; }
//
// Fichier Y.cpp
//
#include "Y.h"
#include "X.h"
#include <iostream>
using namespace std;
Y::Y() : val(X::get().f()) {
}
Y::~Y() {
   // Voir la note, plus bas
   cout << "Destructeur de Y"
        << endl;
}
int Y::f()
   { return 2 * val; }
//
// Fichier Z.cpp
//
#include "Z.h"
#include "X.h"
#include <iostream>
using namespace std;
Z::Z() : val(X::get().f()) {
}
Z::~Z() {
   // Voir la note, plus bas
   cout << "Destructeur de Z"
        << endl;
}
int Z::f()
   { return -val; }

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.


Valid XHTML 1.0 Transitional

CSS Valide !