Journal Tirez-vous une bûche, qu'on cause C++ et singletons

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
17
14
août
2018

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  (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 instantier A. Par exemple :

    int main()
    {
        auto a = createA(1, 2);
        auto b = createA(2, 3);
        return 0;
    }
    

    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  (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):

      instance, created = models.Truc.objects.get_or_create(name="foo")
      
      if created:
          print("new instance created in ORM")
      else:
          print("old instance taken from ORM")

      C'est juste une proposition de API, hein ;)

      Courage !

    • [^] # Re: pas clair

      Posté par  (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  (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 de A. Mais je me demande quand même si ça vaut le coup de détruire l'instance de A 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  . É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  (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  . É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  . Évalué à 8.

          […] c'est la galère pour […] créer une instance locale avec des paramètres différents.

          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éthode creatA(). 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  . É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  (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  . É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  . É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  . Évalué à 9.

      Ce commentaire a été supprimé par l’équipe de modération.

    • [^] # Re: Utilité

      Posté par  (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  (site web personnel) . Évalué à 3.

      Mais personnellement j'ai abandonné définitivement tout singleton en utilisant de l'injection de dépendance

      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  . É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  . É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  . Évalué à 1.

          Le fais qu'il n'existe qu'une seule instance qu'une classe n'en fait pas un singleton.

          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  . É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  (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  . É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  . É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  . É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  (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  . Évalué à 0.

        On attrape pas les dinos avec des roupies de sansonnette.

  • # .

    Posté par  . É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  (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

      […]

      In declarations

      An identifier can be used to name objects, references, functions, enumerators, types, class members, namespaces, templates, template specializations, parameter packs, goto labels, and other entities, with the following exceptions:

      • the identifiers that are keywords cannot be used for other purposes;
      • the identifiers with a double underscore anywhere are reserved;
      • the identifiers that begin with an underscore followed by an uppercase letter are reserved;
      • the identifiers that begin with an underscore are reserved in the global namespace.

      […]

      Reformulons + exemples

      On reformule différemment :

      1. Éviter le tiret bas au début ;

        #ifndef _MA_CLASSE_H   # Non car commence par "_M »
        #define _MA_CLASSE_H
        […]
        #endif
        
        #ifndef MA_CLASSE_H_   # Oui, autorisé à la fin
        #define MA_CLASSE_H_
        […]
        #endif
        
      2. Éviter deux tirets bas consécutifs n’importe où ;

        #ifndef MA_CLASSE__H   # Non, pas de double tiret bas au milieu
        #define MA_CLASSE__H
        […]
        #endif
        
        #ifndef MA_CLASSE_H__  # Non, pas à la fin non plus
        #define MA_CLASSE_H__
        […]
        #endif
        
      3. Exception du tiret bas au début.
        Autorisé dans une portée locale (local scope) et suivi par une minuscule (ou un chiffre…).

        #ifndef MA_CLASSE_H   # Oui
        #define MA_CLASSE_H
        
        class _MaClasse       # Non
        {
          const double _PI = 3.14; # Non
          int _MonEntier = 0;      # Non
          int _monEntier = 0;      # Oui
          int __monEntier = 0;     # Non
          int _mon_entier = 0;     # Oui
          int _mon__entier = 0;    # Non
          int _0_0_ = 0;           # Oui
          int _ = 0;               # Oui
          int _Fonction();         # Non
          int _fonction();         # Oui
        };
        
        int _fonction();           # Non
        
        int fonction (int _a) {    # Oui
          int _ = _a;              # Oui
          return _;
        }
        
        #endif
        

      Règle simple

      On simplifie la règle pour la retenir :

      1. Jamais deux tirets bas consécutifs n’importe où ;
      2. Le tiret bas au début réservé aux _variablesMembres.

      Avantage à nommer ses variables membres avec un tiret bas au début

      Je cite les GreatPractices C++ rules

      N. CLS   Class data member in _camelCase

      • Same rule as for struct member but prefixed with an underscore "_"
      • This rule may not be applied:
      • when the class contains few functions and data members
      • for special members as static variables

      Rationale:

      • By convention struct has public data and provides few functions
      • In contrast, class has private data and often provides many functions
      • This rule avoids messing the Auto-Completion on class between data (private) and functions (public)
      • This rule allows Auto-Completion to separate data/functions (arranged in alphabetical order)
      • Developer knows if he/she wants to see the list of data members or the list of functions
      • No problem to start with "_" + lowercase inside a class scope (reserved only in global scope)
      • But starting with "_" + UPPERCASE or double "__" is reserved in any scope
      class MyClass
      {
      public:
        double getTradingVolume() const;
      
      private:
        int32_t _quantity;
        double _price;
        bool _isActive;
      };
      
      double MyClass::getTradingVolume() const
      {
        return _price * _quantity;
      }

      See also related questions:

      Dans la langue de Molière

      Constats :

      • En général, les struct fournissent des variables public et pas (peu) de fonctions ;
      • En général, les class fournissent des fonctions public (le reste est private) ;
      • Donc, dans une struct, pas besoin de distinguer les variables des fonctions ;
      • Par contre, dans une class ce serait pratique de les distinguer.

      Avantages de commencer par un tiret bas :

      • La déclaration des variables membres est alignée (c’est plus joli que les tiret bas à la fin) ;
      • L’auto-complétion tire bénéfice car on sélectionne les variables ou les fonctions selon si on commence à taper un tiret bas ou pas.

      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.