Code de grande personne – Garantir l'ordre de création/ destruction des singletons statiques en C++

Cet article présume que la lectrice ou le lecteur a, au préalable, lu et compris les articles sur les singletons (ou sur le schéma de conception lui-même, de manière générale), sur les listes de types, les templates variadiques et l'enchaînement de classes parents. Comprendre l'idiome de classe incopiable est aussi un avantage à la compréhension de certains éléments du code, mais devrait sembler plus simple pour la majorité des lectrices et des lecteurs.

Cela dit, nous montrerons ici comment résoudre efficacement et strictement à l'intérieur du langage C++ lui-même (donc sans magouilles malpropres) un problème de design fondamental. La version proposée ici dépend de la présence d'une compilateur C++ 11, bien qu'il soit possible de le modifier pour s'en tirer, de manière un peu plus laborieuse, avec un compilateur plus ancien.

Un merci spécial à Sébastien Lévy, qui a repéré une manoeuvre non-portable et m'a permis d'améliorer le tout.

Les singletons ne sont pas une panacée mais constituent un schéma de conception utile pour résoudre une gamme de problèmes spécifique, soit tous les cas il est nécessaire de garantir qu'un processus ne contienne qu'une seule instance d'une classe donnée.

Un problème de fond

Il est possible de découper les singletons en deux grandes familles :

Le problème des singletons statiques est que chaque singleton est, en fait, une variable globale, et qu'il n'est pas possible a priori de garantir l'ordre de construction ou l'ordre de destruction des variables globales d'un projet lorsqu'elles sont disséminées dans plusieurs fichiers binaires.

Cela signifie qu'il n'est pas possible, du moins de l'avis de plusieurs, de concevoir (de manière générale) des singletons statiques dépendant d'autres singletons statiques. Ainsi, il est impossible de prévoir un design dans lequel un singleton A utilise les services d'un singleton B si on n'arrive pas à garantir que B sera construit avant A et que B sera détruit après A.

Ce problème limite l'utilité des singletons statiques. On n'a qu'à penser à un singleton chargeant d'une base de données des paramètres propres au fonctionnement d'un programme: si nous n'arrivons pas à garantir que ce singleton sera créé avant ceux qui utilisent ces paramètres, et si nous n'arrivons pas à garantir que ce singleton sera détruit après ceux qui en dépendent, alors nous devons envisager une stratégie plus manuelle, moins élégante que celle exploitant la dualité constructeur/ destructeur du singleton pour automatiser le chargement des paramètres et leur entreposage en fin de parcours.

Plusieurs experts, en particulier Andrei Alexandrescu dans son excellent volume Modern C++ Design, prétendent qu'assurer un ordre entre la création et la destruction de singletons statiques est un problème insoluble en C++. à partir de ce constat, ils concentrent leurs efforts sur les singletons dynamiques, ce qui (nous le verrons) est une erreur, au sens où cela introduit des difficultés qu'il faut contourner à l'aide de stratégies problématiques et au sens où cela ne permet pas de remplacer les singletons statiques aux endroits et dans les situations où ils sont utiles.

Nous utiliserons d'ailleurs certaines idées mises de l'avant par Alexandrescu pour résoudre de manière générale le problème de l'ordonnancement de singletons interdépendants.

Notez que toutes les stratégies exposées ici, quelles qu'elles soient, ne règlent pas le problème qui résulterait d'une dépendance cyclique entre singletons (A dépend de B et B dépend de A). Dans un tel cas, on a un problème de design et il faut réexaminer le système dans son ensemble.

Les singletons dynamiques – en quoi ils aident et en quoi ils nuisent

Le problème des singletons dynamiques est double.

Leur accès est plus lent que celui sur un singleton statique.

Passant par une indirection sur un pointeur, le patron typique est de vérifier si le pointeur est nul, de créer le singleton au besoin, et de retourner le pointeur non nul résultant.

#ifndef GENERATEUR_SEQUENCE_H
#define GENERATEUR_SEQUENCE_H
#include <memory>
class GenerateurSequence {
   int prochain_ = 0;
   static std::unique_ptr<GenerateurSequence> singleton;
   GenerateurSequence() = default;
public:
   GenerateurSequence(const GenerateurSequence &) = delete;
   GenerateurSequence& operator=(const GenerateurSequence &) = delete;
   static std::unique_ptr<GenerateurSequence> &get() {
      if (!singleton) // condition de course!
         singleton = std::unique_ptr<GenerateurSequence>{new GenerateurSequence};
      return singleton;
   }
   int prochain() noexcept {
      return prochain_++;
   }
};
#endif
// GenerateurSequence.cpp
#include "GenerateurSequence.h"
#include <memory>
using namespace std;
unique_ptr<GenerateurSequence> GenerateurSequence::singleton;

Dans un contexte multiprogrammé, la synchronisation devient nécessaire et ralentit l'accès au singletons (au moins l'accès au moment de la construction initiale). Notez qu'il faut aussi, dans un tel cas, synchroniser la méthode prochain(), probablement en faisant de prochain_ un std::atomic<int> sinon chaque ++ devient une forme de comportement indéfini et le programme perd son sens.

Peu importe la stratégie, un test pour valider que le pointeur soit non nul est toujours nécesaire, ce qui (contrairement à ce que suggère Alexandrescu) n'est pas négligeable dans les systèmes soumis à des contraintes telles qu'un singleton statique est souhaitable.

Il faut donc retenir que, du point de vue de la performance à l'exécution, le passage d'un modèle statique à un modèle dynamique pose problème pour la majorité des cas pertinents

#ifndef GENERATEUR_SEQUENCE_H
#define GENERATEUR_SEQUENCE_H
#include <mutex>
#include <atomic>
class GenerateurSequence {
   static std::mutex verrou;
   std::atomic<int> prochain_ { 0 };
   static std::unique_ptr<GenerateurSequence> singleton;
   GenerateurSequence() = default;
public:
   GenerateurSequence(const GenerateurSequence &) = delete;
   GenerateurSequence& operator=(const GenerateurSequence &) = delete;
   static GenerateurSequence *get() {
      if (!singleton) {
         std::lock_guard<std::mutex> av{verrou};
         if (!singleton)
            singleton = std::unique_ptr<GenerateurSequence>{new GenerateurSequence};
      }
      return singleton;
   }
   int prochain() noexcept {
      return prochain_++;
   }
};
#endif
// GenerateurSequence.cpp
#include <memory>
using namespace std;
unique_ptr<GenerateurSequence> GenerateurSequence::singleton;

Utiliser des singletons dynamiques règle le problème de l'ordre de construction des singletons : un singleton est alors créé lors de sa première utilisation, donc qu'un singleton dépende d'un autre entraînera nécessairement une séquence de création dans le bon ordre. Cependant, les singletons créés ainsi ne seront pas détruits correctement, au sens où leur destructeur ne sera pas invoqué du tout ou ne sera pas invoqué selon un ordre prévisible.

Il y a une solution ici : créer un singleton statique qui tienne à jour une pile des singletons dynamiques à détruire, et faire en sorte que les singletons dynamiques y soient insérés de manière à permettre leur destruction en ordre inverse de leur création, même dans un contexte multiprogrammé. Cette solution demande toutefois aux singletons dynamiques de se plier à des règles particulières et est difficile à implémenter correctement.

Les solutions moins propres reposent sur la fonction atexit() de C et de C++ et sur des systèmes d'inscription à un registre, analogue mécanique d'un singleton statique chargé d'assurer le bon ordonnancement de la destruction des singletons.

Parenthèse sur les singletons en tant que variables statiques à une méthode

L'approche 3, illustrée à droite, se situe à cheval entre ces deux catégories du fait qu'elle crée chaque singleton seulement lors de sa première utilisation, mais garantit la destruction éventuelle de chaques singleton sans que cela n'implique de technique de programmation particulière. Depuis C++ 11, l'initialisation des variables static locales à une fonction est garantie Thread-Safe, ce qui fait de cette approche une solution raisonnable au problème de l'ordonnancement à la construction.

Le problème de cette approche est qu'elle résout seulement à moitié le problème dont nous discutons ici (le problème de l'ordre de construction des singletons) et rend impossible toute tentative propre et élégante de solution à l'autre moitié du problème, celle portant sur l'ordre de destruction des singletons, qui demeure indéterminé.

#ifndef GENERATEUR_SEQUENCE_H
#define GENERATEUR_SEQUENCE_H
#include <atomic>
class GenerateurSequence {
   std::atomic<int> prochain_{ 0 };
   GenerateurSequence() = default;
public:
   GenerateurSequence(const GenerateurSequence &) = delete;
   GenerateurSequence& operator=(const GenerateurSequence &) = delete;
   static GenerateurSequence &get() {
      static GenerateurSequence singleton;
      return singleton;
   }
   int prochain() noexcept {
      return prochain_++;
   }
};
#endif

Hypothèses derrière notre statégie

Nous proposerons ici une stratégie garantissant un ordre de construction entre singletons lorsque certains d'entre eux dépendent d'autres singletons. Nous présumerons qu'une vision applicative existe qui permette de décider, d'une manière externe aux singletons eux-mêmes, les dépendances entre les singletons, donc que cette responsabilité soit systémique, un point de design général pour un programme donné.

Notre stratégie réglera à la compilation le problème de l'ordre de création des singletons statiques. Elle réglera aussi à la compilation le probleme de l'ordre de destruction des singletons statiques. Elle réglera à l'exécution le problème du caractère unique des singletons, mais nous expliquerons plus loin pourquoi ce problème ne peut être réglé à la compilation; il peut être réglé lors de l'édition des liens, mais je suis certain que ma solution est perfectible.

Démarche

Je démontrerai d'abord la stratégie de manière générale, avec des singletons nommés SingletonA et SingletonB. J'expliquerai comment adapter le schéma de conception traditionnel du singleton pour appliquer notre stratégie et je montrerai en quoi la proposition faite ici n'est rien d'autre qu'une application du schéma de conception traditionnel et ne corrompt pas ce schéma.

Je montrerai comment il est possible de garantir que SingletonA soit construit avant SingletonB et que SingletonA soit détruit après SingletonB. Je montrerai aussi comment changer cet ordre sans modifier de manière adverse les singletons SingletonA et SingletonB eux-mêmes. Enfin, je montrerai comment étendre la stratégie à un nombre arbitrairement grand de singletons (de types différents, par définition).

Je montrerai ensuite comment faire en sorte que la contrainte d'unicité du singletons soit respectée dans la mesure où le schéma de conception tel que nous l'implémentons ici est respecté. J'expliquerai pourquoi il devient difficile (peut-être même impossible) de valider cette contrainte à la compilation, pourquoi j'estime en retour possible d'envisager un stratagème validant la contrainte d'unicité à l'édition des liens, et pourquoi je considère acceptable (bien qu'irritant d'un point de vue esthétique) de valider la contrainte à l'exécution comme je le fais ici.

Je proposerai alors un exemple plus concret (bien que simple) de singletons entre lesquels il existe une dépendance pour situer la stratégie dans un cadre plus concret.

Enfin, je discuterai de la stratégie générale utilisée pour résoudre le problème et d'avenues pour l'appliquer à d'autres problèmes. Je montrerai dans quelle mesure la stratégie est sécuritaire (ou, du moins, n'est pas moins sécuritaire que les approches reposant sur des singletons dynamiques) dans un contexte multiprogrammé.

Puisque j'avais une solution opérationnelle avec C++ 03 et puisque la solution que j'ai avec C++ 11 est plus jolie, les deux seront présentées côte à côte. Si votre compilateur est à jour, préférez (de loin!) la version C++ 11.

Rappel du problème

Il y a des raisons de souhaiter des singletons statiques. Cependant, il peut arriver qu'il existe un lien de dépendance entre plusieurs singletons, ce qui suggère qu'il faille être en mesure de forcer entre eux un ordre de création et un ordre de destruction, l'un étant généralement l'inverse de l'autre.

Parmi les cas typiques de singletons dont d'autres singletons peuvent dépendre, on trouve :

Dans tous ces cas, utilisés avec soin, le recours à un singleton pour automatiser les a priori et les a posteriori d'un programme évite de répéter (de manière erronnée) certaines opérations, ce qui réduit les risques d'accidents. Par exemple, on réduit ainsi le risque de double déchargement d'un module de support ou le risque d'oubli de procéder à certaines initialisations.

Notez qu'il est possible de réduire le prix à payer pour ce test par certaines techniques d'optimisation (analogues au __unlikely de gcc), mais que le problème de l'ordre de destruction des singletons dynamiques demeure une tare.

Le problème demeure d'ordonnancer les opérations à effectuer a priori et a posteriori. La solution traditionnelle passant par des singletons dynamiques est incomplète, ne couvrant pas de manière élégante l'ordonnancement de la destruction des singletons, et lente pour les applications qui auront fréquemment recours au service d'accès à un singleton, ajoutant chaque fois un test supplémentaire (ce qui, en particulier, réduit la capacité du processeur de garder plein son pipeline d'instructions).

Une piste de solution

Le problème pourrait être solutionné en fusionnant tous les singletons statiques de manière à ce qu'ils soient tous des attributs d'une même classe, elle-même un singleton statique. Procédant ainsi, l'application du schéma de conception singleton se déplacerait des singletons individuels à leur conteneur, qui pourrait avoir droit à ce privilège par amitié.

ConteneurSingletons.h
#ifndef CONTENEUR_SINGLETONS_H
#define CONTENEUR_SINGLETONS_H
class SingletonA {
   SingletonA() {
      // ...
   }
public:
   SingletonA(const SingletonA&) = delete;
   SingletonA& operator=(const SingletonA&) = delete;
   //
   // services du singleton (...)
   //
   friend class ConteneurSingletons;
};
//
// Idem pour SingletonB (omis par économie)
//
class ConteneurSingletons {
   SingletonA singA {};
   SingletonB singB {};
   ConteneurSingletons() = default;
public:
   ConteneurSingletons(const ConteneurSingletons&);
   ConteneurSingletons& operator=(const ConteneurSingletons&);
   static ConteneurSingletons &get() {
      static ConteneurSingletons singleton;
      return singleton;
   }
   SingletonA& getA() const {
      return singA;
   }
   SingletonB& getB() const {
      return singB;
   }
};
#endif

Cette stratégie va comme suit :

Cette stratégie fonctionnerait parce que le standard de C++ définit l'ordre de construction et l'ordre de destruction des attributs d'un objet. Cependant, cet ordre est contre-intuitif pour bien des gens puisqu'il correspond à l'ordre de déclaration des attributs, sujet à être différent de l'ordre de préconstruction des attributs : exprimé autrement, ici, singA est construit avant singB puis détruit après singB simplement parce que dans ConteneurSingletons, singA est déclaré avant singB. C'est une approche quelque peu fragile; règle générale, on s'abstiendra de faire reposer du code sur des considérations obscures comme celle-ci, à moins bien sûr qu'il n'y ait pas d'autres options (et, dans ce cas, on prendra soin de documenter la manoeuvre). Le coût associé à l'entretien du code obscur est souvent si élevé que le retour sur l'investissement est, disons-le à la blague, négatif.

Pour contrôler l'ordre de création et de destruction des singletons de facto que sont singA et singB, on pourrait les créer et les supprimer manuellement, avec new et delete, mais cela entraînerait son propre lot d'irritants. Nous préférerions éviter d'échanger une série d'ennuis pour une autre.

En plus du fait que cette façon de faire repose sur un aspect obscur du standard, elle a comme défaut d'être intrusive et d'exiger des ajustements fréquents au code, deux irritants du point de vue de son entretien :

Enfin, comme la plupart des stratégies manuelles, celle-ci alourdit beaucoup la tâche de développement, augmente la probabilité d'erreurs humaines et est difficile à généraliser autrement que par simple discipline humaine. Ajouter à cela le facteur obscur de la règle indiquant l'ordre de l'inialisation des attributs montre que nous ne pouvons nous arrêter ici et prétendre proposer une stratégie générale.

Il y a de l'espoir

Tout n'est pas sombre, cela dit :

Notez, à titre de rappel, que les stratégies de programmation générique (incluant celles décrites ou utilisées ici) sont souvent économiques en temps d'exécution et en espace mémoire, mais tendent à coûter plus cher en temps de compilation. Il n'y a (apparemment) rien de gratuit.

Les clés de la généralisation dans un problème comme celui-ci nous sont données par les travaux de pionniers de la programmation générique comme Andrei Alexandrescu (listes de types) et messieurs Barton et Nackman (pour le truc du même nom).

Nous ne retiendrons, pour notre argumentaire, que les idées essentielles des concepts sur lesquels nous bâtirons notre stratégie. Vous serez invité(e) à (re)lire les articles spécifiques à chacune de ces idées pour avoir plus de détails à son sujet.

Implémentation concrète

Nous présenterons notre proposition de solution à partir d'un exemple concret. Supposons que nous souhaitions que certaines classes ayant pour vocation de générer des identifiants de manière à ce que ceux-ci puissent être recyclés soient des singletons. Notre raison pour ce choix tiendra à certains facteurs :

  • Chacun des générateurs devra assurer l'unicité des identifiants qu'il met en circulation, sans toutefois devoir se porter garant des identifiants mis en circulation par les autres générateurs
  • Chacun aura sa propre stratégie pour générer des identifiants uniques, avec ses propres forces et ses propres faiblesses
  • Certains des générateurs dépendront d'une source d'entropie, symbolisée (ce qui est discutable, mais l'exemple se veut académique, sans plus) par un autre singleton (d'où la dépendance à la construction)

Le GenerateurSequentiel<T> offrira un service prochain() qui retournera un T distinct à chaque appel, allant du minimum au maximum d'un intervalle en croissant de manière monotone.

#ifndef GENERATEURSEQUENTIEL_H
#define GENERATEURSEQUENTIEL_H
#include "Intervalle.h"
#include "Sequence.h"
#include "SingletonWrapper.h"
template <class T, class Interv = Intervalle<T>>
   class GenerateurSequentiel : Sequence<T> {
   public:
      using value_type = typename Sequence<T>::value_type;
      GenerateurSequentiel(const GenerateurSequentiel&) = delete;
      GenerateurSequentiel& operator=(const GenerateurSequentiel&) = delete;
   private:
      GenerateurSequentiel(const Interv &bornes = {})
         : Sequence<T>(bornes.minval, bornes.maxval)
      {
      }
      friend struct SingletonWrapper<GenerateurSequentiel<T, Interv>>;
   public:
      value_type prochain() {
         auto valeur = curval();
         prochaine_valeur();
         return valeur;
      }
   };
#endif

Pour notre implémentation, les types Intervalle<T> et Sequence<T> seront les suivants (classes somme toute banales).

Fichier Intervalle.h Fichier Sequence.h
#ifndef INTERVALLE_H
#define INTERVALLE_H
#include <limits>
class BornesInvalides {};
class ValeurIncoherente {};
template <class T>
   struct Intervalle {
      using value_type = T;
      const value_type minval = std::numeric_limits<value_type>::min();
      const value_type maxval = std::numeric_limits<value_type>::max();
      constexpr bool est_inclus(const value_type &val) const {
         return minval <= val && val <= maxval;
      }
      Intervalle() = default;
      constexpr Intervalle(const value_type &minval)
         : minval{ minval }, maxval{ std::numeric_limits<value_type>::max() }
      {
      }
      constexpr Intervalle(const value_type &minval, const value_type &maxval)
         : minval{ minval },
           maxval{ (minval > maxval)? throw BornesInvalides{} : maxval } 
      {
      }
   };
#endif
#ifndef SEQUENCE_H
#define SEQUENCE_H
#include "Intervalle.h"
template <class T>
   class Sequence {
   public:
      using value_type = T;
   private:
      Intervalle intvl;
      value_type cur;
   public:
      Sequence(const value_type &minval, const value_type &maxval)
         : intvl{ minval, maxval }, cur{ minval }
      {
      }
      value_type curval() const {
         return cur;
      }
      value_type prochaine_valeur() {
         const auto valeur = curval();
         cur = valeur == intvl.maxval ? intvl.minval : cur + 1;
         return valeur;
      }
   };
#endif

Un autre générateur sera GenerateurUnique. Son implémentation aura les particularités suivantes :

  • Il tiendra à jour un vecteur des identifiants qu'il aura déjà distribué
  • Lors de la génération d'un nouvel identifiant, il pigera un identifiant au hasard, à travers les services d'un autre singletons (d'où la dépendance susmentionnée), et recommencera tant que l'identifiant pigé sera l'un de ceux déjà livrés. Notez que cet algorithme est terriblement inefficace, son temps d'exécution et sa consommation de mémoire vive s'accroissant tous deux avec le nombre d'identifiants distribués, et pourrait mener à une répétitive infinie (voyez-vous pourquoi?)
  • Si on lui remet un identifiant qui n'est pas effectivement en circulation grâce à lui, alors il lèvera une exception
  • Enfin, il compresse le vecteur dans lequel il entrepose les identifiants distribués à chaque fois qu'on lui en remet un seul, ce qui est une autre approche particulièrement inefficace

Malgré ses défauts manifestes, ce singleton est opérationnel (pour l'essentiel) et montre qu'un singleton peut avoir recours aux services d'un autre.

#ifndef GENERATEUR_UNIQUE_H
#define GENERATEUR_UNIQUE_H
#include "Intervalle.h"
#include "SingletonWrapper.h"
#include <vector>
class GenerateurUnique {
public:
   using value_type = int;
   GenerateurUnique(const GenerateurUnique&) = delete;
   GenerateurUnique& operator=(const GenerateurUnique&) = delete;
private:
   std::vector<value_type> distribue;
   GenerateurUnique() = default;
   bool contient(value_type) const;
   friend struct SingletonWrapper<GenerateurUnique>;
public:
   value_type prochain();
   void remettre(value_type);
};
#endif
#include "GenerateurUnique.h"
#include "GenerateurStochastique.h"
#include "SingletonDefinition.h"
#include <algorithm>
using namespace std;
bool GenerateurUnique::contient(value_type val) const {
   return find(begin(distribue), end(distribue), val) != end(distribue);
}
auto GenerateurUnique::prochain() -> value_type {
   auto &gen = singletons::get().get_singleton<GenerateurStochastique>();
   auto val = gen.prochain();
   while (contient(val)) // Boucle: O(???)
      val = gen.prochain();
   distribue.push_back(val);
   return val;
}
void GenerateurUnique::remettre(value_type val) {
   if (!contient(val)) throw ValeurIncoherente{};
   remove(begin(distribue), end(distribue), val);
   distribue.shrink_to_fit();
}

Le GenerateurRecycleur conservera une file des identifiants qui lui ont été remis et redistribuera ceux-ci si possible. Dans le cas où aucun identifiant déjà distribué ne lui aura été remis, il générera de nouveaux identifiants de manière croissante et monotone.

Notez que la méthode prochain() n'est pas Exception-Safe, mais que ce ne sera pas une préoccupation pour cet exemple.

#ifndef GENERATEUR_RECYCLEUR_H
#define GENERATEUR_RECYCLEUR_H
#include "Intervalle.h"
#include "Sequence.h"
#include "SingletonContextFwd.h"
#include <deque>
#include <limits>
#include <algorithm>
template <class T>
   class GenerateurRecycleur : Sequence<T> {
   public:
      using value_type = typename Sequence<T>::value_type;
      GenerateurRecycleur(const GenerateurRecycleur&) = delete;
      GenerateurRecycleur& operator=(const GenerateurRecycleur&) = delete;
   private:
      std::deque<value_type> recyclable;
      GenerateurRecycleur() : Sequence<T>(T{}, std::numeric_limits<T>::max()) {
      }
      bool contient(value_type val) const {
         using namespace std;
         return find(begin(recyclable), end(recyclable), val) != end(recyclable);
      }
      friend struct SingletonWrapper<GenerateurRecycleur<T>>;
   public:
      value_type prochain() {
         if (recyclable.empty()) return prochaine_valeur();
         auto val = recyclable.front();
         recyclable.pop_front();
         return val;
      }
      void remettre(value_type val) {
         if (contient(val)) throw ValeurIncoherente{};
         recyclable.push_back(val);
      }
   };
#endif

Enfin, le singleton GenerateurStochastique centralise (choix discutable) la génération de nombres pseudoaléatoires. J'ai appliqué une stratégie reposant sur une distribution uniforme des valeurs, tout en prenant soin de synchroniser les accès au Mersenne Twister (attribut prng) logé dans le singleton.

Notez que cet exemple utilise une distribution crée à chaque appel de la méthode prochain(), ce qui réduit la qualité des nombres générés (une distribution, en pratique, devrait persister car elle peut entreposer des états). Je vous laisse raffiner le tout puisque ce n'est pas le coeur de notre propos.

#ifndef GENERATEURSTOCHASTIQUE_H
#define GENERATEURSTOCHASTIQUE_H
#include "Intervalle.h"
#include "SingletonContextFwd.h"
#include <random>
#include <mutex>
class GenerateurStochastique {
public:
   using value_type = int;
private:
   mutable std::mutex m;
   mutable std::mt19937 prng;
   GenerateurStochastique();
   friend struct SingletonWrapper<GenerateurStochastique>;
public:
   // bornes inclusives
   value_type prochain(const Intervalle<value_type>& = {}) const;
};
#endif
#include "GenerateurStochastique.h"
#include <random>
#include <mutex>
#include <type_traits>
using namespace std;
GenerateurStochastique::GenerateurStochastique() : prng{ random_device{}() }{
}
template <class T>
   struct distribution_type_trait {
      using type = conditional_t<
         is_integral<T>::value, uniform_int_distribution<T>, uniform_real_distribution<T>
      >;
   };
auto GenerateurStochastique::prochain(const Intervalle<value_type> &bornes) const -> value_type {
   distribution_type_trait<value_type>::type de{ bornes.minval, bornes.maxval };
   lock_guard<mutex> _ { m };
   return de(prng);
}

Le recours à une liste de types

Fichier type_list.h C++ 03 C++ 11

Notre premier outil pour aborder la généralisation d'une stratégie par conteneur est la liste de types. En effet, nous cherchons à mettre en place un mécanisme général par lequel il sera possible de grouper des instances uniques d'un nombre arbitrairement grand de types et d'accéder à chacune de ces instances de manière immédiate, sans que cela n'implique le moindre coût en temps d'exécution ou en espace.

L'idée de liste de types est simplement une abstraction générale du concept de liste, où une liste a une tête (d'un certain type) et une queue (d'un autre type). Généralement, la queue est elle-même une liste ou encore une représentation du concept de vide, ce qui résulte en une définition pleinement récursive du concept de liste appliqué à des types sur une base abstraite. En C++, l'expression naturelle d'un concept aussi abstrait repose sur la métaprogrammation à l'aide de templates.

Dans le code à droite, la version C++ 03 construit explicitement une liste à partir d'un type T (tête) et d'un type Q (queue), ce derniers étant soit une type_list, soit Vide (un type bidon servant de marqueur de fin). La version C++ 11 repose sur une liste variadique de types, ce qui est plus efficace et plus élégant, comme le montreront les cas d'utilisation que nous rencontrerons plus bas.

#ifndef TYPE_LIST_H
#define TYPE_LIST_H
class Vide {};
template <class T, class U>
   struct type_list;
#ifndef TYPE_LIST_H
#define TYPE_LIST_H
template <class ... >
   struct type_list;
#endif

Nous utiliserons une liste de types pour lister les types de singletons prospectifs. L'ordre des types dans la liste correspondra à l'ordre de construction des singletons et à l'ordre inverse de leur destruction. Ce mécanisme a beaucoup d'avantages. En particulier :

Implémenter un singleton par délégation envers un ami

Nos singletons prospectifs délégueront la tâche d'implémenter le schéma de conception singleton à un tiers. Je nommerai ce tiers SingletonWrapper, qui sera une classe (en fait, un struct) générique.

Fichier SingletonWrapper.h C++ 03 C++ 11

L'idée de SingletonWrapper<T> est de simplifier le code client. Un singleton prospectif de type T selon notre stratégie aura comme mandat de déclarer le struct SingletonWrapper<T> comme étant son (seul!) ami. La classe T se spécifiera aussi Incopiable tout en offrant un constructeur par défaut privé, pour éviter que d'autres ne puissent l'instancier et brisent par le fait-même notre design.

Étant seul ami de la classe T, un SingletonWrapper<T> pourra seul construire un T. Remarquez la stratégie stratifiée que nous sommes en train de mettre en place ici :

  • La classe T n'est pas un singleton, mais elle est telle que seule une instance de SingletonWrapper<T> pourra l'instancier
  • Une instance de la classe SingletonWrapper<T> n'est pas non plus un singleton mais offre une interface unifiée pour connaître le type T, mieux connu sous le nom de type interne type à travers une définition de type interne public, et offre une interface unifiée (méthode d'instance get()) pour accéder à l'instance de T qu'elle enrobe
  • On ne peut empêcher statiquement de créer plusieurs SingletonWrapper<T> dans plusieurs contextes distincts, alors nous avons introduit un mécanisme pour lever une exception si quelqu'un fait le choix de tricher en contournant le système de cete manière
  • Le problème de l'unicité d'une instance de T vient d'être déplacé de la classe T à la classe SingletonWrapper<T>. Laissons-la en suspens pour le moment car nous y reviendrons plus loin
  • Nous venons donc de standardiser et de formaliser la démarche menant à la conception d'un singleton de type T, tout en définissant un vocabulaire qui nous permettra de traiter d'un singleton d'un type quelconque sans nous préoccuper du type en tant que tel.
#ifndef SINGLETON_WRAPPER_H
#define SINGLETON_WRAPPER_H
template <class>
   class NonRespectSingleton {};
template <class T>
   struct SingletonWrapper {
      static int ninst;
      using type = T;
   private:
      type singleton;
   public:
      SingletonWrapper() {
         if (ninst++) throw NonRespectSingleton<T>();
      }
      type& get() {
         return singleton;
      }
   };
template <class T>
   int SingletonWrapper<T>::ninst = {};
#endif
#ifndef SINGLETON_WRAPPER_H
#define SINGLETON_WRAPPER_H
#include <atomic>
template <class>
   class NonRespectSingleton {};
template <class T>
   struct SingletonWrapper {
      static std::atomic<int> ninst;
      using type = T;
   private:
      type singleton;
   public:
      SingletonWrapper() {
         if (ninst++) throw NonRespectSingleton<T>{};
      }
      type& get() {
         return singleton;
      }
   };
template <class T>
   std::atomic<int> SingletonWrapper<T>::ninst = {};
#endif

Concept utilitaire – le conteneur de singletons

L'étape suivante est de mettre en place une structure par laquelle, à partir une liste de types, on arrivera à assurer la construction et la destruction dans l'ordre souhaité des singletons. La structure que je vous propose mêle liste de types et enchaînement de classes parents dans une hiérarchie de classes génériques par héritage simple où les liens de parenté respectent l'ordre selon lequel apparaissent les types de singletons dans une liste de types donnée.

Fichier SingletonContext.h C++ 03 C++ 11

La première étape de la définition du côté C++ 03 est de dire qu'il existe une classe ConteneurSingleton qui sera générique à partir d'un type quelconque, mais de ne pas donner de définition pour ce type. Le résultat de cette définition est de permettre la spécialisation de l'idée générique de ConteneurSingleton selon des critères plus précis. L'étape suivante est de spécifier l'existence d'un cas particulier de type générique ConteneurSingleton pour le type Vide, notre marqueur de fin de liste, pour arrêter la construction récursive de la hiérarchie de parents. Remarquez l'implémentation d'une méthode d'instance get() qui retourne une référence sur une instance bidon de la classe Vide : cette implémentation n'est proposée sous cette forme que par souci d'homogénéité et la classe Vide n'a d'autre rôle réel que d'exister (on pourrait réécrire la hiérarchie pour éliminer complètement ce cas, mais il s'agit d'un exercice amusant que je vous laisse affronter avec un couteau entre les dents – vous, pas moi!).

Du côté C++ 11, n'ayant pas besoin d'un type Vide, le cas par défaut pour un type T donné est implémenté a priori.

Le cas intéressant est le troisième, qui exprime le cas général d'une instance de ConteneurSingleton construite à partir d'une liste de types faite d'une tête et d'une queue. Remarquez sa structure :

  • Elle définit le type interne et public singleton_type comme correspondant au type de la tête de la liste de types
  • Elle spécifie que son parent est la classe ConteneurSingleton<Q>, ce qui a pour impact de générer une hiérarchie récursive d'incarnations de ConteneurSingleton pour chaque type de la liste de types, du premier au dernier
  • Elle déclare un attribut d'instance du types SingletonWrapper<singleton_type>, ce qui permet d'enrober la seule instance du type corespondant à singleton_type

Ensuite, elle déclare deux versions de la méthode d'instance générique get(). Chacune retourne une référence sur le type pour lequel elle est invoquée, ce qui nous éviter d'écrire une méthode par type de singleton (un gain net sur le plan de l'entretien du code) :

  • Une version est spécifiquement applicable au type singleton_type, indiquant une prise en charge locale de l'accès au singleton de ce type (ce qui est merveilleux puisque le singleton de ce type est enrobé dans l'attribut singleton_ de type SingletonWrapper<singleton_type>), alors que
  • L'autre version est générique sur un type quelconque et se limite à déléguer vers le parent immédiat l'inférence de la bonne méthode à invoquer pour le type sollicité

Cette délégation est récursive (le parent délègue à son parent qui fait de même et ainsi de suite) et a plusieurs grandes qualités. En particulier, elle fonctionnera toujours une fois le programme compilé et son coût, en temps et en espace, est nul.

#ifndef SINGLETON_CONTEXT_H
#define SINGLETON_CONTEXT_H
#include "type_list.h"
#include "SingletonWrapper.h"
template <class>
   class ConteneurSingleton;
template <>
   class ConteneurSingleton<Vide> {
   public:
      Vide& get() {
         static Vide v;
         return v;
      }
   };
template <class T, class Q>
   class ConteneurSingleton<type_list<T, Q>>
      : public ConteneurSingleton<Q>
   {
   public:
      using singleton_type = T;
   private:
      SingletonWrapper<singleton_type> singleton_;
      template <class T>
         T& RecursiveParentGet() {
            return ConteneurSingleton<Q>::get<T>();
         }
   public:
      template <class T>
         T& get() {
            return RecursiveParentGet<T>();
         }
      template <>
         singleton_type& get<singleton_type>() {
            return singleton_.get();
         }
   };
template <class TList>
   class SingletonContext
      : ConteneurSingleton<TList>
   {
      static SingletonContext ctx;
      SingletonContext() = default;
   public:
      SingletonContext(const SingletonContext&) = delete;
      SingletonContext& operator=(const SingletonContext&) = delete;
      static SingletonContext& get() {
         return ctx;
      }
      template <class T>
         T & get_singleton() {
            return ConteneurSingleton<TList>::template get<T>();
         }
   };
template <class TList>
   SingletonContext<TList> SingletonContext<TList>::ctx;
#endif
#ifndef SINGLETON_CONTEXT_H
#define SINGLETON_CONTEXT_H
#include "SingletonWrapper.h"
#include "type_list.h"
#include <type_traits>
template <class T>
   class ConteneurSingleton {
   public:
      T& get() {
         static T singleton;
         return singleton;
      }
   };
template <class T, class ... Q>
   class ConteneurSingleton<type_list<T, Q...>>
      : public ConteneurSingleton<type_list<Q...>>
   {
   public:
      using singleton_type = T;
   private:
      SingletonWrapper<singleton_type> singleton_;
      template <class U>
         U& ParentGet() {
            return ConteneurSingleton<type_list<Q...>>::template get<U>();
         }
      template <class T>
         struct searcher {};
      class Found {};
      class NotFound {};
      template <class U>
         U& search_for(searcher<U>, Found) {
            return singleton_.get();
         };
      template <class U>
         U& search_for(searcher<U>, NotFound) {
            return ParentGet<U>();
         };
   public:
      template <class U>
         U& get() {
            return search_for(searcher<U>{}, std::conditional_t<
               std::is_same<T, U>::value, Found, NotFound
            >{});
         }
   };
template <class TList>
   class SingletonContext : ConteneurSingleton<TList> {
      static SingletonContext ctx;
      SingletonContext() = default;
   public:
      SingletonContext(const SingletonContext&) = delete;
      SingletonContext& operator=(const SingletonContext&) = delete;
      static SingletonContext& get() {
         return ctx;
      }
      template <class T>
         T & get_singleton() {
            return ConteneurSingleton<TList>::template get<T>();
         }
   };
template <class TList>
   SingletonContext<TList> SingletonContext<TList>::ctx;
#endif

Ces propriétés miracles ne sont pas accidentelles. Une invocation pour un type supporté mènera, par une séquence d'invocation de méthodes d'une seule ligne toutes pleinement visibles du compilateur et, par conséquent, toutes pleinement susceptibles de se voir appliquer du Method Inlining. En retour, une invocation pour un type qui n'apparaît pas dans la hiérarchie des diverses incarnations de ConteneurSingleton entraînera une erreur à la compilation.

Notez que nous n'avons toujours pas atteint le seuil visé, soit celui d'une structure telle qu'elle nous permet de garantir l'ordre de construction et de destruction d'une liste arbitraire de singletons statiques. Cependant, nous approchons du but :

La classe ConteneurSingleton est un outil mais n'est pas destinée à être utilisée directement par le code client.

Enfin, la liste ordonnancée de singletons

Fichier SingletonDefinition.h C++ 03 C++ 11

La classe qui servira d'interface effective pour le code client sera la classe singletons, qui correspondra à un SingletonContext<TL> construit sur la base d'une liste de types TL où les types de singletons sont placés dans l'ordre de construction souhaité, donc du singleton ayant le moins de dépendance envers ses pairs à celui en ayant le plus.

J'ai utilisé un seul type singletons ici, mais en pratique il pourrait y avoir plusieurs structures semblables dans un même programme; conséquemment, le code à droite est un exemple à adapter à vos besoins, pas un fichier à inclure naïvement dans vos projets.

Le rôle de singletons est à la fois de forcer, de par la construction de sa structure hiérarchique, la génération dans l'ordre des singletons, et d'être un singleton statique au sens classique du design.

Remarquez que l'accès à un singleton d'une type T donné de la liste se fait par une double indirection, mais celle-ci sera selon toute probabilité pleinement résolue à la compilation et n'impactera pas l'exécution du programme. Elle ne nécessite ni polymorphisme, ni dynamisme, dépendant seulement des types à l'appel. Seul l'héritage simple est utilisé, donc il n'y a pas de coût en espace ou en temps en comparaison avec une situation où on a plusieurs singletons statiques disjoints.

Les plus astucieuses et les plus astucieux constateront que nous avons assuré l'unicité d'un SingletonContext<TL> pour une liste de types TL donnée, mais que nous ne venons pas de garantir l'impossibilité de voir apparaître un même singleton dans deux SingletonContext distincts.

Cet irritant est couvert à l'exécution par SingletonWrapper<T> et son compteur interne du nombre d'instances.

#ifndef SINGLETON_DEFINITION_H
#define SINGLETON_DEFINITION_H
#include "GenerateurStochastique.h"
#include "GenerateurSequentiel.h"
#include "GenerateurUnique.h"
#include "GenerateurRecycleur.h"
#include "type_list.h"
#include "SingletonContext.h"
using les_singletons = type_list<
   GenerateurUnique, type_list<
      GenerateurSequentiel<int>, type_list<
         GenerateurRecycleur<int>, type_list<
            GenerateurStochastique>, Vide
         >
      >
   >
>
using singletons = SingletonContext<les_singletons>;
#endif
#ifndef SINGLETON_DEFINITION_H
#define SINGLETON_DEFINITION_H
#include "GenerateurStochastique.h"
#include "GenerateurSequentiel.h"
#include "GenerateurUnique.h"
#include "GenerateurRecycleur.h"
#include "type_list.h"
#include "SingletonContext.h"
using les_singletons = type_list<
   GenerateurUnique,
   GenerateurSequentiel<int>,
   GenerateurRecycleur<int>,
   GenerateurStochastique
>;
using singletons = SingletonContext<les_singletons>;
#endif

Utiliser le tout

Un squelette de programme de test possible serait celui proposé à droite. Vous pouvez vous amuser avec les exemples d'instructions placées en commentaires, dans la mesure où vos modifications demeurent cohérentes (p. ex. : si vous recyclez un identifiant, faites-le avec le générateur duquel vous l'avez obtenu à l'origine).

Vous pouvez insérer des appels aux méthodes générant des valeurs et à celles remettant des valeurs aux endroits opportuns. à l'utilisation, tout ce qui laisse transparaître la complexité de l'implémentation est le code en caractères gras. Ce code pourrait même être réduit à :

 template <class T>
   T& get_singleton() {
      return singletons::get().get_singleton<T>();
   }

...si nous avions l'assurance qu'un seul ordonnancement de singletons, nommé singletons, existera dans le programme.

#include "SingletonDefinition.h"
int generer() {
   return singletons::get().get_singleton<GenerateurStochastique>().prochain(Intervalle<int>{-4,-2});
   //return GenerateurRecycleur::get().prochain();
   //return GenerateurUnique::get().prochain();
   //return GenerateurStochastique::get().prochain(Intervalle(-4,-2));
   //return GenerateurSequentiel::get().prochain();
   //return GenerateurUsuel::get().prochain ();
}
void remettre(int val) {
   //GenerateurUnique::get().remettre(val);
   //GenerateurRecycleur<int>::get().remettre(val);
}
#include <iostream>
#include <algorithm>
#include <iterator>
int main() {
   using namespace std;
   enum { N = 10 };
   int tab[N];
   generate(begin(tab), end(tab), generer);
   copy(begin(tab), end(tab), ostream_iterator<int>{ cout, " " });
   cout << endl;
   for_each(begin(tab), end(tab), remettre);
   generate(begin(tab), end(tab), generer);
   copy(begin(tab), end(tab), ostream_iterator<int>{ cout, " " });
   cout << endl;
}

Horizons dévoilés par cette technique

Nous avons, pour arriver à nos fins, examiné (et eu recours à) plusieurs techniques. Certaines sont expliquées dans d'autres articles et vous pouvez consulter les liens proposés ici et là sur la présente page si vous souhaitez en savoir plus.

Ce qui peut être considéré comme un peu plus novateur dans cette démarche (même si les idées en question ne peuvent être considérées de moi; ce sont des idées dans l'air du temps, que plusieurs gens oeuvrant sur des problèmes semblable ont eues à peu près autour des mêmes dates), va comme suit :

De bons trucs pour la boîte à outils des programmeurs de code à haute performance, capables de vivre avec une solide dose d'abstraction.


Valid XHTML 1.0 Transitional

CSS Valide !