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.
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.
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. |
|
|
|
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 |
|
|
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.
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é. |
|
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.
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.
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).
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é.
#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.
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.
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 :
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. |
|
Pour notre implémentation, les types Intervalle<T> et Sequence<T> seront les suivants (classes somme toute banales).
Fichier Intervalle.h | Fichier Sequence.h |
---|---|
|
|
Un autre générateur sera GenerateurUnique. Son implémentation aura les particularités suivantes :
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. |
|
|
|
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. |
|
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. |
|
|
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. |
|
|
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 :
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 :
|
|
|
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 :
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) :
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. |
|
|
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.
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. |
|
|
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 à :
...si nous avions l'assurance qu'un seul ordonnancement de singletons, nommé singletons, existera dans le programme. |
|
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.