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.
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;
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.
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. J'ai utilisé une variable inline (de C++ 17) pour simplifier le tout. Sans ce mécanisme, il aurait fallu distinguer la déclaration et la définition de Facette::cur pour éviter les violations d'ODR. |
|
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. |
|
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. |
|
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. |
|
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é. |
|
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). |
|
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? |
|
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()). |
|
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. |
|
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 facette().
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].get()
);
}
};
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. |
|
Quelques notes en passant :
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>
#include <string_view>
using namespace std;
struct Texture {
constexpr auto getTextureName() const noexcept {
return "Je suis un nom de texture"sv;
}
};
class TextureManager {
public:
Texture getTexture() const noexcept {
return {};
}
};
struct Sound {
constexpr auto getFileName() const noexcept {
return "SomeSound.wav"sv;
}
};
class SoundManager {
public:
Sound getSound() const noexcept {
return {};
}
};
int main() {
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. |
|
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. |
|
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. |
|
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.