Le métier est venu me voir. "On aime beaucoup ton composant A, celui qui cache les données depuis la base, mais on voudrait l'instancier tout plein de fois, sans qu'il recharge tout à chaque fois."
Comme j'aime bien le métier, je me suis attelé à la tâche, je me suis bouché le nez, et je leur ai planqué une petite variable globale des familles histoire de mettre en commun les caches. Et ils étaient fort contents.
Ensuite, j'ai regardé ma variable de plus près, et je me suis rendu compte avec horreur que je venais d'implémenter un singleton ! Miséricorde.
Chacun sait que le singleton canonique est un anti-patron :
- Il est difficile à rendre réentrant
- Sa destruction est difficile à contrôler
- Il ne joue pas bien du tout avec les tests unitaires
- Et enfin, non seulement c'est une variable globale, mais c'est une variable globale que l'on ne peut pas rendre locale : on ne peut pas instancier l'objet localement, puisque son constructeur est caché.
Sauf que je dois être dans un des rares cas qui marchent. En effet, mon cache n'a pas "besoin" d'être une variable globale, et il est parfaitement correct de considérer une instance comme étant indépendante. Le fait que les instances soient partagées est un détail d'implémentation qui correspond à une exigence non-fonctionnelle de performance ou de consommation mémoire. Si l'utilisateur instancie vraiment un objet séparé, ce n'est pas grave, je n'ai donc pas besoin de rendre mon constructeur privé.
Ensuite, en utilisant des pointeurs "weak", je m'assure que l'objet est vraiment détruit quand mon utilisateur a fini de s'en servir, et j'évite donc que l'objet survive au delà de ma séquence de destruction. Bonus, cela m'assure une destruction totale à la fin de chaque test unitaire.
Et enfin, en faisant un peu attention, la réentrance n'est pas si méchante.
Forcément, j'ai eu envie de le rendre générique, ce qui m'a permis d'utiliser des paramètres variadiques, ce qui est toujours plaisant. Je vous présente donc l'engin:
#include <memory>
#include <mutex>
#include <cassert>
// Le type générique singleton
template<typename T>
class Singleton
{
public:
// Constructeur variadique prenant les paramètres permettant de construire
// l'objet si besoin est
template<typename ... ARGS>
std::shared_ptr<T> create(ARGS ... args)
{
// On protège notre pointeur weak
std::lock_guard<std::mutex> lock(_mutex);
// Est-ce que le pointeur weak pointait déjà sur un pointeur strong?
auto strong = _weak.lock();
if(!strong)
{
// Non... Créons le
strong = std::make_shared<T>(args...);
// Et assignons le pointeur weak
_weak = strong;
}
// Retournons soit l'objet déjà existant,
// soit le nouveau que l'on vient de créer.
return strong;
}
private:
std::mutex _mutex;
// Comme l'on utilise un weak_ptr, l'objet sera détruit
// lorsque sera détruite la dernière instance utilisateur.
std::weak_ptr<T> _weak;
};
// Classe de test
class A
{
public:
A(int, double)
{
}
};
// Factory pour le type A
std::shared_ptr<A> createA(int a, double b)
{
// Astuce ! Une variable définie comme "static" au sein
// d'une fonction est en réalité globale, mais accessible
// seulement de cette fonction. Elle sera initialisée au
// premier appel de cette fonction, de manière thread-safe.
static Singleton<A> singletonA;
return singletonA.create(a, b);
}
// Test
int main()
{
auto a = createA(1, 2);
auto b = createA(1, 2);
assert(a == b);
return 0;
}
Après, l'on peut avoir besoin de créer des objets différents en fonction des paramètres d'entrée, et dans ce cas il faudrait plutôt utiliser un conteneur associatif pour garder les pointeurs weak (et la généralisation pourrait être plus ardue).
Enfin, l'on notera que cette technique ne marche que si l'utilisateur garde longtemps ses pointeurs sur A. S'il appelle createA, utilise l'objet de retour, et le détruit immédiatement, le cache sera perdu. En revanche, si A est voué à être gardé comme variable membre de plusieurs composants indépendants qui seront disponibles la majeure partie du temps de vie de l'application, le cache fonctionnera à plein.
# pas clair
Posté par Julien Jorge (site web personnel) . Évalué à 10.
L'interface n'est pas très claire, quand on passe des paramètres à
createA
il n'y a pas de garantie qu'ils soient utilisés pour instantierA
. Par exemple :Le deuxième appel ne va pas créer de
A
mais c'est impossible à deviner pour l'appelant. Et du coup les paramètres sont passés pour rien.[^] # Re: pas clair
Posté par François GUÉRIN (Mastodon) . Évalué à 5.
Salut,
J'y connais rien en c++, mais django propose une interface pour récupérer des objets de l'ORM avec une signature inintéressante :
get_or_create
qui retourne un tuple instance, created (boolean):C'est juste une proposition de API, hein ;)
Courage !
[^] # Re: pas clair
Posté par small_duck (site web personnel) . Évalué à 2.
Tout à fait, puisque l'on ne peut pas prévoir si l'objet sera réellement instancié, ou si l'on va renvoyer un objet déjà existant.
Si par exemple, on passe une connexion vers une base de données, il est raisonnable de l'ignorer si l'objet existe déjà, parce ce que nouvel objet ou pas, son utilisation sera exactement la même. L'appelant n'étant pas sûr d'être le premier, il ne peut que passer les paramètres, qui seront utilisés ou pas.
Si maintenant les paramètres peuvent être différents d'un appel à l'autre, la sémantique change complètement car on s'attend alors probablement à récupérer un objet différent en fonction des paramètres, et peut-être faut-il utiliser l'approche moins générique avec un conteneur associatif qui lie les paramètres à l'instance qui va bien.
[^] # Re: pas clair
Posté par Julien Jorge (site web personnel) . Évalué à 4.
Pour faire un truc générique je pense que j'aurais mis une association entre les tuples de paramètres et les instances créés. Forcément j'aurais du user de type erasure et ça serait devenu hyper compliqué pour un truc qui n'aurait géré que deux ou trois instances au final.
Comme barmic je pense que ton besoin est une factory plutôt qu'un singleton. Plus précisément, une factory de
shared_ptr<A>
, pas deA
. Mais je me demande quand même si ça vaut le coup de détruire l'instance deA
si elle risque d'être recrée par la suite. Pourquoi ne pas là garder ? D'ailleurs ne pourrais-tu pas simplement créer l'instance au lancement, dans une étape de setup, ce qui te permettrait d'utiliser une autre instance pour tes tests.[^] # Re: pas clair
Posté par flagos . Évalué à 9.
C'est précisement pour ce problème qu'un Singleton:
- n'expose qu'une méthode getInstance() et garde son constructeur privé
- son constructeur et sa méthode getInstance() ne possède pas d'arguments
Là, comme tu le soulignes, on ne peut savoir avec quelles paramètres l'objet a été créé, ce qui est un très mauvais choix.
Sinon utiliser std::weak_ptr pour cet usage, ca me semble une bonne idée.
[^] # Re: pas clair
Posté par small_duck (site web personnel) . Évalué à 2.
Ce qui me gêne dans le "getInstance" sans paramètres, c'est que les paramètres dont le singleton avait besoin pour sa construction deviennent implicites. Par exemple, une connexion à une base de données doit elle-même devenir un singleton, lequel va utiliser le singleton de la configuration, lequel est forcé d'utiliser des variables globales pour retrouver une variable d'environnement ou un paramètre en ligne de commande pour retrouver un fichier ou une surcharge d'option.
Et là, tout est implicite, tout est global, et c'est la galère pour faire tourner un test unitaire ou pour créer une instance locale avec des paramètres différents.
Idéalement, on n'utiliserait donc pas de singleton du tout, mais parfois, c'est la solution la moins laide. Je suis d'accord avec toi que d'avoir à repasser les paramètres à chaque fois est dangereux, et cela doit certainement être évité autant que possible. Mais je crois qu'il y a des cas où, bien documenté, quand les paramètres à passer sont peu ambigus comme un fichier de config, un loggeur ou une connexion à une base de données, alors c'est la moins pire des solutions.
À consommer avec modération, donc :)
[^] # Re: pas clair
Posté par jmax17 . Évalué à 4.
Je trouve le singleton pratique pour la configuration globale du système, (sinon je dois passer mon objet config comme paramètre à tout mes autres objets )
Dans ce cas la fonction main est ami du singleton, cela me permet de passer des paramètres à la création de l'objet à la condition qu'il soit le premier objet créer dans le main.
mon singleton dispose de paramètre par défaut pour les tests des autres objets
[^] # Re: pas clair
Posté par barmic . Évalué à 8.
C'est précisément ce que tu cherche à interdire avec un singleton.
Si tu veux juste permettre d'avoir différentes instances de la même classe, mais réutiliser les précédents s'ils ont des paramètres compatibles ce qu'il te faut c'est une
factory
et c'est ce qu'est ta méthodecreatA()
. Pourquoi tenter de contraindre à ce qu'une classe soit singleton ? Si tu ne peux la construire que via sa factory, cette dernière peut faire tout le job.[^] # Re: pas clair
Posté par Lutin . Évalué à 3.
Pour le tests unitaires de singleton (avec donc des paramètres différents dans le constructeur) j'ai souvent vu des
# ifdef TEST_U
…
# endif
dans le constructeur.
Par contre j'ignore si c'est un bonne ou une mauvaise pratique.
[^] # Re: pas clair
Posté par Julien Jorge (site web personnel) . Évalué à 8.
Ça oblige à compiler deux fois et c'est la porte ouverte à des comportements différents en test et en prod :/
Moi j'aime bien quand il n'y a qu'un seul flot d'instructions et quand les tests utilisent les mêmes binaires que le produit principal.
[^] # Re: pas clair
Posté par Lutin . Évalué à 1.
Quand on parle de tests unitaires (pas de tests fonctionnels), dans le cas d'un logiciel et pas d'une bibliothèque, il y a forcément une compilation différente.
# Utilité
Posté par barmic . Évalué à 9.
Salut,
Je ne comprends pas bien ton journal. D'un point de vu du besoin, je ne connais pas la durée de vie de ton runtime (quelques secondes en CLI ou des mois sur serveurs par exemple).
Mais personnellement j'ai abandonné définitivement tout singleton en utilisant de l'injection de dépendance. Avec ce pattern tu n'a plus à faire des pieds et des mains pour être thread safe, te poser la question de l'API, devoir retoucher tout le code appelant quand tu décide que quelque chose devient un singleton, tu normalise la manière de faire du lazy loading et ton code reste testable.
Le coût ne me parait pas si grand et ça apporte beaucoup de choses intéressantes.
[^] # Commentaire supprimé
Posté par Anonyme . Évalué à 9.
Ce commentaire a été supprimé par l’équipe de modération.
[^] # Re: Utilité
Posté par YBoy360 (site web personnel) . Évalué à 1.
Moi pour les singletons j'utilise des enums… L'injection de dépendance en c++, rien de plus fun.
[^] # Re: Utilité
Posté par devnewton 🍺 (site web personnel) . Évalué à 3.
Et du coup 98% de tes objets sont maintenant instanciés en tant que singletons :-)
Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.
[^] # Re: Utilité
Posté par groumly . Évalué à 6.
Qu'ils soient en pratique des singletons ou des multipletons n'est pas ce qui est important.
Ce qui compte, c'est que tu puisses les instancier et injecter leur dépendances dans test tests.
Linuxfr, le portail francais du logiciel libre et du neo nazisme.
[^] # Re: Utilité
Posté par barmic . Évalué à 5.
Pff j'ai abandonné l'utilisation du pattern singleton. Le fais qu'il n'existe qu'une seule instance qu'une classe n'en fait pas un singleton.
[^] # Re: Utilité
Posté par David Marec . Évalué à 1.
Surtout que dans de nombreux cas, il ne s'agit, de fait, que d'une variable globale.
(une seule, et au moins une instance)
# Le métier ?
Posté par steph1978 . Évalué à 5.
Ton métier vient te demander de pouvoir multi-instancier ton composant de cache de base de données !
On doit pas avoir affaire au même métier. Pour moi le métier exprime un besoin fonctionnel genre "pour les commandes de ce type de produits je voudrais avoir une date de réception en plus de la date d'expédition" ou à la rigueur "la recherche par description est trop lente, peux tu faire quelque chose".
[^] # Re: Le métier ?
Posté par small_duck (site web personnel) . Évalué à 6. Dernière modification le 16 août 2018 à 08:56.
Eh oui! Chez nous, le métier, ce sont, entre autres, les matheux qui utilisent mes API pour faire de l'analyse de données, et qui codent leurs outils soit directement en C++, soit via des langages de script. Ce genre de demande est donc assez fréquent.
# Ca existe encore le C++ ?
Posté par Enzo Bricolo 🛠⚙🛠 . Évalué à 2.
Je suis surpris, j'avais cru comprendre que c'était un "truc de dino" et qu'il valait mieux choisir "
Java/Python/Golang/F#/Rust/Closure/Node/Kotlin/R/Swift" (rayez les mentions inutiles) qui est vachement plus mieux (référencé dans Stack Overflow).[^] # Re: Ca existe encore le C++ ?
Posté par xcomcmdr . Évalué à 5. Dernière modification le 16 août 2018 à 17:11.
Fait gaffe, tu vas te couper à force d'être sur le fil du rasoir !
"Quand certains râlent contre systemd, d'autres s'attaquent aux vrais problèmes." (merci Sinma !)
[^] # Re: Ca existe encore le C++ ?
Posté par reno . Évalué à 3. Dernière modification le 17 août 2018 à 11:12.
Pour le futur peut-être mais ma boite a du mal à trouver des devs C++ alors que Rust pour trouver du travail..
[^] # Re: Ca existe encore le C++ ?
Posté par Boiethios (site web personnel) . Évalué à 3.
Sur Paris, je pense que c'est plus simple de trouver un dev Rust que C++ (bon, j'exagère peut-être).
A titre personnel, je me débrouille bien en C++, mais je préfère bosser en C# qui est quand même beaucoup plus agréable à utiliser, et en plus, on est souvent mieux payé. Donc je refuse les propositions de missions en C++. En revanche, si on me proposais un dev en Rust, je sauterais sur l'occasion. Et je ne suis pas seul dans ce cas.
[^] # Re: Ca existe encore le C++ ?
Posté par Enzo Bricolo 🛠⚙🛠 . Évalué à 0.
On attrape pas les dinos avec des roupies de sansonnette.
# .
Posté par guppy . Évalué à 7.
Personnellement je trouve l'énoncé pas très clair (mais c'est peut-être moi qui est un peu obtus ;) ) et j'ai donc bien du mal à estimer la pertinence de ta solution. De ce que je crois comprendre j'aurai implémenté une simple fabrique en lui ajoutant le support d'un cache (effectivement membre statique, pas le choix si on veut qu'il soit partagé).
Par contre j'ai une petite remarque de style : de nombreuses règles concernent les identificateurs qui commencent par un
_
. On peut en trouver certaines ici (ou directement dans la norme bien sûr), et ce n'est pas exhaustif, les problèmes de mangling ne sont pas mentionnés. En bref, dans certaines conventions d'appel (cdecl de mémoire) certains compilos vont rajouter un_
devant l'identificateur exporté ; cependant on croise parfois des librairies qui n'en ont pas, ce qui implique que des collisions sont possibles.Pour ma part je considère que soit on connaît la norme sur les first underscores sur le bout des doigts (ainsi que toute son équipe), soit on les abandonne. J'écrivais également les membres de classe ainsi à une certaine époque, mais j'ai changé de méthode : autant se passer de cette complexité, l'avantage en C++ c'est qu'on peut choisir sur quoi on va s'user le cerveau ;)
[^] # Règle des tirets bas (underscores)
Posté par Oliver (site web personnel) . Évalué à 3. Dernière modification le 18 août 2018 à 16:55.
C’est l’occasion de rappeler les règles du tiret bas (underscore) dans le nommage
\_o_/
La nomre C++
Le résumé de la norme C++ : https://en.cppreference.com/w/cpp/language/identifiers
Reformulons + exemples
On reformule différemment :
Éviter le tiret bas au début ;
Éviter deux tirets bas consécutifs n’importe où ;
Exception du tiret bas au début.
Autorisé dans une portée locale (local scope) et suivi par une minuscule (ou un chiffre…).
Règle simple
On simplifie la règle pour la retenir :
_variablesMembres
.Avantage à nommer ses variables membres avec un tiret bas au début
Je cite les GreatPractices C++ rules
Dans la langue de Molière
Constats :
struct
fournissent des variablespublic
et pas (peu) de fonctions ;class
fournissent des fonctionspublic
(le reste estprivate
) ;struct
, pas besoin de distinguer les variables des fonctions ;class
ce serait pratique de les distinguer.Avantages de commencer par un tiret bas :
Commentaire sous licence Creative Commons Zero CC0 1.0 Universal (Public Domain Dedication)
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.