Facettes

Sans être très complexe, le code qui suit présume une familiarité avec la programmation générique à l'aide de templates et avec les conversions explicites de types ISO. Assurez-vous aussi d'avoir compris l'article sur l'effacement des types. D'autres a priori sont présumés mais vous trouverez des liens aux endroits opportuns dans le texte pour vous assister dans votre lecture.

Notez aussi que les quelques notations mathématiques de ce document sont affichées à l'aide de MathJax

Il arrive qu'un système doive être extensible sans pouvoir être polymorphique. L'exemple canonique d'une telle situation est celui des considérations culturelles et de l'internationalisation :

La bibliothèque standard de C++ propose, par la technique des facettes, une solution générale à ce problème. Les facettes mêlent héritage, polymorphisme (mais très peu et dans un rôle pointu), approche client/ serveur (CS) et programmation générique dans un design OO qui permet, en quelque sorte, d'accéder à des objets par leur type et de réaliser une forme de polymorphisme statique.

Voyons, par un exemple, comment appliquer cette technique de manière générale et ce qu'il nous est possible d'en tirer.

Un serveur de facettes d'un jeu

Imaginons que nous souhaitions rassembler en un même lieu logique un ensemble de facettes d'un jeu :

Chacune de ces facettes sera unique pour un jeu donné à un instant donné (un même jeu n'aura pas deux descriptifs d'ambiance en même temps[2]) mais chaque facette n'est pas un singleton et une facette d'un type donné peut, au besoin, être remplacée par une autre facette du même type. Le jeu, lui, sera un singleton, mais seulement dans le but de simplifier le portrait (ce n'est pas, à proprement parler, nécessaire).

Nous ne connaissons nécessairement pas d'opération commune à chacune de ces facettes. Certaines sont peut-être des racines polymorphiques, d'autres des gestionnaires, d'autres encore peuvent se limiter à offrir des constantes et des services descriptifs tout simples. Je n'implémenterai pour cet exemple que deux facettes très simples (ambiance et thème), à partir desquelles vous pourrez en imaginer d'autres.

Ce que nous souhaitons réaliser ici est une infrastructure telle que le programme suivant, pour un jeu de sport, affichera "Ce jeu est un jeu de Sport" :

#include "Jeu.h"
#include <iostream>
int main() {
   using namespace std;
   const auto &jeu = CaracteristiquesJeu::get();
   cout << "Ce jeu est un jeu de "
        << utiliser_facette<FacetteTheme>(jeu).categorie()
        << endl;
}

La méthode categorie() sera une méthode d'instance non polymorphique de la classe FacetteTheme. Remarquez que la fonction générique utiliser_facette() retrouve la facette FacetteTheme par son type; nous souhaitons permettre d'indexer et de retrouver des objets par leur type. Nous souhaitons aussi y parvenir de manière à obtenir une référence, non pas sur une abstraction mais bien sur le type de l'objet réellement obtenu.

Les facettes

Qu'est-ce qu'une facette? En fait, bien peu de choses – vous connaissez mon affection pour les classes vides et les choses simples.

Tout d'abord, une facette est une classe Incopiable dont les diverses facettes dériveront. La raison pour ce choix est que nous voudrons regrouper toutes les facettes dans un même conteneur et qu'il nous faut, pour cette raison, qu'elles aient toutes un parent commun et qu'elles soient entreposées sous cette forme.

Le serveur de facettes entreposera donc des pointeurs de Facette. Le seul recours au polymorphisme pour Facette sera dans le cas du nettoyage du serveur, ce qui explique la présence d'un destructeur virtuel dans cette classe.

Nous voudrons mettre en place un schème d'organisation et de recherche de facettes sur la base des types. Il nous faudra donc concevoir une forme de numérotation des types. Le type interne et public Facette::id jouera ce rôle.

La classe Facette définira le type Facette::id mais ne possédera aucun attribut de ce type. En retour, pour chaque facette particulière – chaque instance d'une classe dérivant de Facette – nous intégrerons un attribut de classe (pas un attribut d'instance) de type Facette::id.

Chaque instance de Facette::id aura une valeur différente des autres et sera comparable à l'aide de l'opérateur <, ce qui permettra d'utiliser Facette::id comme type de clé dans un conteneur tel que std::map. La technique pour assurer l'unicité des valeurs de Facette::id devrait vous sembler évidente à partir du code en exemple.

#ifndef FACETTE_H
#define FACETTE_H
#include "Incopiable.h"
struct Facette : Incopiable {
   struct id {
   private :
      static long cur;
      long val;
   public :
      id() noexcept : val{cur++} {
      }
      bool operator<(const id &autre) const noexcept {
         return val < autre.val;
      }
   };
   virtual ~Facette() = default;
};
#endif

Le code du fichier source Facette.cpp va de soi. Notez que l'initialisation de Facette::id::cur doit être faite dans un fichier source puisque seules les constantes entières de classe peuvent être initialisées dans la déclaration d'une classe.

#include "Facette.h"
long Facette::id::cur = {};

Chaque facette, prise individuellement, décrit un ensemble de caractéristiques ou de services associés à une idée. Un cas particulier de facette pourrait être celui de FacetteTheme, tel que présenté dans l'exemple à droite.

Ici, FacetteTheme décrit des thèmes possibles de jeu. Nous aurions pu y mettre autant de services dans cette facette que nous l'aurions souhaité, mais je me suis limité à un seul pour simplifier le propos.

L'idée ici est qu'un jeu peut (et non pas doit!) avoir une facette FacetteTheme qui en décrirait le thème.

Les deux seules clés qui font de la classe FacetteTheme une facette est que cette classe dérive de Facette et qu'elle possède un attribut de classe de type Facette::id. On constatera, sans doute avec plaisir, qu'il s'agit d'un contrat très peu contraignant.

Tout le reste de la facette (types, services, attributs, alouette!) est pleinement découplé des autres facettes et demeure indépendant d'elles.

#ifndef FACETTE_THEME_H
#define FACETTE_THEME_H
#include "Facette.h"
class FacetteTheme : public Facette {
public :
   static Facette::id id;
   enum Categorie {
      Enfantin, Horreur, Action, Sport
   };
private :
   Categorie cat;
public :
   FacetteTheme(Categorie c) noexcept : cat{c} {
   }
   Categorie categorie() const noexcept {
      return cat;
   }
};
#include <iosfwd>
std::ostream& operator <<
   (std::ostream&, const FacetteTheme::Categorie);
#endif

Un exemple de fichier source pour FacetteTheme pourrait être celui proposé à droite.

Ce code n'est qu'un exemple simple et n'a pas la prétention de constituer un exemple des meilleurs pratiques de programmation. Notez simplement que la seule règle à respecter est de définir l'attribut id de FacetteTheme.

#include "FacetteTheme.h"
#include <string>
#include <ostream>
// ... using ...
ostream& operator <<
   (ostream &os, const FacetteTheme::Categorie cat)
{
   static constexpr const char *NOMS[] = {
      "Enfantin", "Horreur", "Action", "Sport"
   };
   return os << NOMS[static_cast<short>(cat)];
}
Facette::id FacetteTheme::id;

La classe FacetteAmbiance est un autre exemple de facette possible pour un jeu.

La similitude entre les classes FacetteTheme et FacetteAmbiance tient seulement de ma propre paresse. Mis à part leur parent commun et leurs numéros d'identification respectifs (leur attribut de classe de type Facette::id), ces deux classes sont pleinement indépendantes l'une de l'autre et pourraient être pleinement dissemblables.

Le nombre de facettes dans un système donné est ouvert. La nature des services d'une facette donnée, leur nombre ou leur qualité, sont des questions ouvertes. On a, clairement, recours aux facettes lorsqu'on souhaite un serveur caractéristiques qui soit ouvert et découplé au maximum.

#ifndef FACETTE_AMBIANCE_H
#define FACETTE_AMBIANCE_H
#include "Facette.h"
class FacetteAmbiance : public Facette {
public :
   static Facette::id id;
   enum Categorie {
      Glauque, Enjoue, Stressant
   };
private :
   Categorie cat;
public :
   FacetteAmbiance(Categorie c) noexcept : cat{c} {
   }
   Categorie categorie() const noexcept {
      return cat;
   }
};
#endif

#include "FacetteAmbiance.h"
Facette::id FacetteAmbiance::id;

Le serveur de facettes

Les facettes regroupent des services. Pour leur accéder, il est approprié de mettre en place un serveur en bonne et due forme.

Le serveur de facettes ici sera un singleton nommé CaracteristiquesJeu. Il assurera l'unicité de chaque facette (on ne pourra avoir deux instances de FacetteAmbiance pour un même jeu, par exemple).

Le fait que le serveur soit un singleton dans cet exemple est un choix d'implémentation, pas une règle du modèle proposé.

#ifndef JEU_H
#define JEU_H
#include "Incopiable.h"
#include "Facette.h"
#include "FacetteAmbiance.h"
#include "Facettetheme.h"
#include <map>
#include <algorithm>
#include <utility>
class CaracteristiquesJeu : Incopiable {

Les attributs de classe de chaque facette, tous de type Facette::id, serviront de clé pour identifier sur la base des types chaque facette disponible (on dira installée) dans le serveur. Pour chaque identifiant de type, donc pour chaque Facette::id connu du serveur, on trouvera au plus une instance de Facette* (donc une indirection vers quelque chose qui soit au moins une Facette).

Le conteneur de facettes, typiquement, sera un tableau associatif ou un vecteur, selon les prévisions de patron d'utilisation. Ici, j'utiliserai un tableau associatif (std::map) de paires faites d'un Facette::id (la clé) et d'un Facette* (la valeur).

// ...
   std::map<Facette::id, Facette *> facettes;
public:
   static CaracteristiquesJeu &get() {
      static CaracteristiquesJeu singleton;
      return singleton;
   }

Pour informer le serveur de ses facettes, on y installera chaque facette souhaitée.

Dans cet exemple, j'ai choisi d'installer les facettes à la construction du serveur, mais ce n'est qu'un choix motivé par un souci de simplicité. Il est fréquent de laisser le code client installer lui-même les facettes qui lui semblent appropriées.

Remarquez que installer() est générique sur la base du type de facette et prend une abstraction (un Facette*) en paramètre. La connaissance par le code client du type effectif de facette utilisé permet de résoudre statiquement plutôt que dynamiquement des considérations telles que quel est le id de cette facette?

Ce code n'est pas Exception-Safe. Pourrait-on régler ce problème?

private:
   CaracteristiquesJeu() {
      installer<FacetteAmbiance>(
         new FacetteAmbiance{FacetteAmbiance::Enjoue}
      );
      installer<FacetteTheme>(
         new FacetteTheme{FacetteTheme::Sport}
      );
      // etc.
   }

Installer une facette signifie l'intégrer au serveur de facettes. Le choix fait ici d'utiliser une std::map à titre de conteneur fait en sorte que l'opération d'installation soit de complexité , dû à la recherche au préalable d'une facette déjà installée.

Si une facette du même type que celui de la facette à intégrer est déjà installée, alors nous la supprimons.

On triche ici en modifiant it->second parce que it->first ne change pas (on n'endommage pas l'arbre dans la std::map en ne modifiant pas la clé de la paire retrouvée).

On aurait aussi pu simplement effacer it puis intégrer la paire {F::id,p} de manière plus classique (appel à erase() suivi d'un appel à insert()).

public:
   template<class F>
      void installer(Facette *p) {
         using namespace std;
         auto it = facettes.find(F::id);
         if (it != end(facettes)) {
            swap(p, it->second);
            delete p;
         } else {
            facettes[F::id] = p;
         }
      }

Remarquez que nous obtenons le id du type de facette non pas à l'aide d'une invocation polymorphique sur p mais bien à partir du type générique F. C'est à la fois plus rapide et plus propre dans ce cas-ci puisque les id de facettes identifient des types, pas des objets.

Les facettes, une fois installées, sont la propriété du serveur et c'est lui qui les supprimera en temps et lieu. Ceci explique ce choix fait dans installer() de supprimer les facettes qui ont été remplacées par une nouvelle installation.

Le destructeur du serveur nettoiera les facettes encore installées. Son code sera placé. Dans le fichier source du serveur (plus bas) pour des raisons techniques.

   ~CaracteristiquesJeu();

Obtenir une facette par son type sera une opération d'une complexité , soit la complexité d'une opération de recherche dans une std::map. On voudra, pour alléger la syntaxe et réduire les risques, retourner une référence constante sur la facette obtenue – si la facette offre des services non constants, alors il faudra envisager deux déclinaisons de la méthode générique getFacette().

La méthode Facette() semble horrible mais est en réalité très jolie et plutôt efficace. Elle retrouve une facette d'un type F dans un conteneur de Facette* à partir de F::id. Ce faisant, elle est certaine du type véritable de la facette trouvée et peut convertir le Facette* en F* à la compilation plutôt qu'à l'exécution, ce qui évite une (coûteuse!) inférence dynamique de types.

   template <class F>
      const F& facette() const {
         return *static_cast<const F*>(
            const_cast<CaracteristiquesJeu*>(this)->facettes[F::id]
         );
      }
};

Enfin, pour faciliter la rédaction du code client souhaité, nous allégerons la syntaxe requise pour obtenir une facette en offrant une fonction utilitaire nommée utiliser_facette() qui, sur la base d'un type de conteneur et d'un type de facette, obtiendra la facette souhaitée du conteneur.

Puisque le type de facette souhaité sera suppléé par le code client à l'invocation de la fonction, aucune invocation polymorphique ne sera requise.

template<class F, class C>
   const F& utiliser_facette(const C &conteneur) {
      return conteneur.facette<F>();
   }
#endif

Ne reste plus qu'à examiner l'implémentation du destructeur du serveur de facettes.

Le code que je vous propose est simple et repose sur un foncteur interne au destructeur, capable de supprimer la valeur d'une paire faite d'un Facette::id et d'un Facette*.

La destruction successive de tous les éléments de la std::map est assurée par un algorithme standard tout ce qu'il y a de plus classique.

#include "Jeu.h"
#include <utility>
#include <algorithm>
using namespace std;
CaracteristiquesJeu::~CaracteristiquesJeu() {
   for (auto & p : facettes)
      delete p.second;
}

Quelques notes en passant :

Raffinement – Facettes non intrusives

Un défaut structurel de l'approche décrite plus haut est qu'elle est intrusive, au sens où elle impose aux classes qui serviront à titre de facettes de dériver d'un parent commun, Facette. De manière générale, en C++, les designs intrusifs sont mal vus, du fait que cela réduit leur applicabilité et introduit un couplage qu'on préférerait éviter.

Imaginons que l'on souhaite que le programme suivant compile et fonctionne tel qu'attendu. Notez que return {}; signifie « retourner la valeur par défaut », ce qui sollicite le constructeur par défaut du type retourné :

#include "FacetteServer.h"
#include <iostream>
struct Texture {
   const char *getTextureName() const noexcept {
      return "Je suis un nom de texture";
   }
};
class TextureManager {
public:
   Texture getTexture() const noexcept {
      return {};
   }
};

struct Sound {
   const char *getFileName() const noexcept {
      return "SomeSound.wav";
   }
};
class SoundManager {
public:
   Sound getSound() const noexcept {
      return {};
   }
};

int main() {
   using namespace std;
   auto &serveur = FacetteServer::get();
   serveur.installer(TextureManager{});
   serveur.installer(SoundManager{});
   // ...
   cout << utiliser_facette<SoundManager>(serveur).getSound().getFileName() << endl;
   cout << utiliser_facette<TextureManager>(serveur).getTexture().getTextureName() << endl;
}

La sortie à laquelle on s'attend ici est :

SomeSound.wav
Je suis un nom de texture

Ici, les classes SoundManager et TextureManager ne dérivent pas de Facette, évitant le couplage indu décrié plus haut, mais leurs instances sont tout de même utilisées dans un gestionnaire de facettes (le singleton nommé FacetteServer). Voici comment y arriver.

La classe Facette utilisée initialement demeure telle quelle ici. Je n'en ai répété la déclaration ici que pour faciliter la lecture.

#ifndef FACETTE_H
#define FACETTE_H
#include "Incopiable.h"
struct Facette : Incopiable {
   struct id {
   private :
      static long cur;
      long val;
   public :
      id() noexcept : val{cur++} {
      }
      bool operator<(const id &autre) const noexcept {
         return val < autre.val;
      }
   };
   virtual ~Facette() = default;
};
#endif

Le code du fichier source Facette.cpp reste tel quel lui aussi.

#include "Facette.h"
long Facette::id::cur = {};

Le truc pour arriver à nos fins est de définir un type auxiliaire, nommé FacetteWrapper<F> dans le code à droite, et de faire dériver ce type (plutôt que le type F lui-même) de la classe Facette.

Plutôt que de déposer des F dans le serveur de facettes, nous déposerons des FacetteWrapper<F>. Le type F demeurera encodé dans FacetteWrapper<F>, comme il est évidemment encodé... dans le type F lui-même (ça va de soi!).

Pour qui connaît le type F, il devient possible d'aller chercher le F dans un FacetteWrapper<F>. J'ai exposé l'attribut public Facette à cette fin.

Notez que puisque notre classe est générique, l'attribut de classe FacetteWrapper<F>::id est « défini » à même le fichier d'en-tête FacetteWrapper.h. Ce sera au compilateur d'assurer le respect de la règle ODR ici lorsqu'il générera la définition de cet attribut.

#ifndef FACETTE_WRAPPER_H
#define FACETTE_WRAPPER_H
#include "Facette.h"
template <class F>
   struct FacetteWrapper : Facette {
      F facette;
      static Facette::id id;
      FacetteWrapper(const F &facette) : facette{facette} {
      }
      FacetteWrapper(F &&facette) : facette{std::move(facette)} {
      }
   };
//
// Instanciation du id de FacetteWrapper<F>
//
template <class F>
   Facette::id FacetteWrapper<F>::id;
#endif

Enfin, le serveur de facettes lui-même entreposera toujours des pointeurs de Facette (avec pointeurs intelligents cette fois, mais j'y reviens). Cependant, la fonction installer(F) instanciera un FacetteWrapper<F> pour chaque type F utilisé à titre de facette, et transférera le F dans ce nouvel objet.

C'est avec FacetteWrapper<F>::id que l'association entre un type F et son identifiant id sera fait, mais la correspondance entre F et FacetteWrapper<F> se fait un pour un, ce qui permet d'utiliser l'un pour retrouver l'autre.

Notez que cette version utilise des unique_ptr<Facette>, ce qui évite de se préoccuper de la finalisation des objets qui y sont entreposés. Par contre, la méthode Facette<F>(), dans sa version non-const, demande que l'on accède à l'objet pointé (voir l'écriture *facettes[FacetteWrapper<F>::id]), passant d'un unique_ptr<FacetteWrapper<F>> à un FacetteWrapper<F>&, pour accéder à l'un de ses membres.

#ifndef FACETTE_SERVER_H
#define FACETTE_SERVER_H
#include "Incopiable.h"
#include "Facette.h"
#include "FacetteWrapper.h"
#include <map>
#include <algorithm>
#include <utility>
#include <memory>
class FacetteServer : Incopiable {
   std::map<Facette::id, std::unique_ptr<Facette>> facettes;
public:
   static FacetteServer &get() {
      static FacetteServer singleton;
      return singleton;
   }
private:
   FacetteServer() {
      // installer les facettes par défaut, s'il y a lieu
   }
public:
   template<class F>
      void installer(F &&f) {
         using namespace std;
         unique_ptr<Facette> p {new FacetteWrapper<F>{std::move(f)}}; // important: pas auto!
         auto it = facettes.find(FacetteWrapper<F>::id);
         if (it != end(facettes))
            swap(it->second, p);
         else
            facettes[FacetteWrapper<F>::id] = move(p);
      }
   template <class F>
      const F& facette() const {
         return const_cast<FacetteServer*>(this)->facette<F>();
      }
   template <class F>
      F& facette() {
         return static_cast<FacetteWrapper<F>&>(*facettes[FacetteWrapper<F>::id]).facette;
      }
};

template<class F, class S>
   const F& utiliser_facette(const S &serveur) {
      return serveur.facette<F>();
   }
#endif

Et voilà, le tour est joué!


[1] ...du moins, pas si on désire aussi un coût acceptable et si on souhaite éviter de compromettre la qualité du design par des conversions explicites de types.

[2] ...et si la facette d'ambiance doit permettre une liste d'ambiances, alors ce sera son rôle de tenir à jour cette liste. La facette, elle, demeurera unique.


Valid XHTML 1.0 Transitional

CSS Valide !