Dans cette séance, on va s’intéresser à des design patterns permettant de construire de manière efficace et flexible des objets :
Reprenons l’exemple de la construction des Magiciens, Chevaliers et Voleurs du jeu de rôle de la séance 9. On avait vu que leurs créations par ÉtatJeu posait des problèmes car une version « naïve » violait le principe Ouvert/Fermé de la classe ÉtatJeu. On avait trouvé une parade en exploitant le type générique Class de Java. Mais cette solution est un peu restrictive. En effet, elle se complexifie si les classes que l’on souhaite créer sont génériques (Magicien<T>). De plus, elle fonctionne bien sous Java car, dans ce langage, les noms des classes manipulés par la machine virtuelle sont les mêmes que ceux que vous avez donné à vos classes (Chevalier = Chevalier). En C++, c’est plus complexe : le RTTI (run-time type information) utilise des chaînes de caractères moins lisibles. Par exemple, le code suivant :
#include <iostream>
template <typename C>
struct Toto {
C x;
};
int main () {
std::cout << typeid(double).name() << std::endl;
std::cout << typeid(std::string).name() << std::endl;
std::cout << typeid(Toto<int>).name() << std::endl;
return 0;
}
pourra vous produire un affichage similaire à celui ci-dessous (cf. g++) :
d
NSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
4TotoIiE
Et, comme souvent en C/C++, cet affichage est « implementation dependent ». Autrement dit, il varie d’un compilateur à l’autre. On aimerait donc trouver un moyen de s’en sortir tout en faisant en sorte que notre application reste maintenable (il est hors de question que la classe ÉtatJeu fasse des if/else pour déterminer quel constructeur appeler).
Définition
La problématique générale que le design pattern Factory essaie de résoudre est l’instanciation par une classe C d’objets dont les types ne sont pas connus à l’avance par C, sans que la classe C ne viole le principe Ouvert/Fermé.
Il existe de nombreux exemples de cette problématique. Par exemple, si l’on doit charger l’image des personnages, on peut avoir des fichiers jpg, png, gif, svg, etc., et on n’a pas envie que chaque type de personnage fasse des if/else pour sélectionner le constructeur adapté au type de l’image à charger. Si le jeu utilise un tableau pour sauvegarder des statistiques de performance des joueurs, il pourrait utiliser des fichiers xls, xlsx, csv, etc. On voit donc que c’est une problématique assez commune.
La solution apportée par le design pattern Factory peut être décrit grâce au diagramme UML suivant :
Au lieu que ce soit ÉtatJeu qui crée lui-même les personnages en utilisant des if/else, il délègue à JoueurFactory le soin de le faire : ÉtatJeu appelle la méthode creeNouveauPerso de JoueurFactory qui, elle, réalise les if/else et les new. Le code de JoueurFactory peut alors être :
package fr.polytech.aco.jeu;
public class JoueurFactory {
public JoueurFactory() {}
public Joueur creeNouveauPerso(String type, String nom, int x, int y)
throws NotImplementedException {
switch(type) {
case "Magicien":
return new Magicien(nom, x, y);
case "Chevalier":
return new Chevalier(nom, x, y);
case "Voleur":
return new Voleur(nom, x, y);
default:
throw new NotImplementedException("type Joueur " + type + " not implemented yet");
}
}
}
package fr.polytech.aco.jeu;
public class Main {
public static void main(String[] args) {
try {
var joueurFactory = new JoueurFactory();
Joueur perso1 = joueurFactory.creeNouveauPerso("Magicien", "toto", 10, 100);
Joueur perso2 = joueurFactory.creeNouveauPerso("Chevalier", "titi", 20, 50);
System.out.println(perso2);
}
catch (Exception e) {
e.printStackTrace();
}
}
}
Note
On voit que JoueurFactory viole le principe Ouvert/Fermé mais cela n’empêchera pas l’application d’être maintenable car ces violations seront limitées à quelques Factories bien identifiées plutôt que d’être disséminées dans tout le code de l’application.
Notez ici que, dans le switch, on lève une exception si l’on essaye de créer un type de personnage dont on n’a pas encore tenu compte dans la Factory. C’est donc à runtime plutôt qu’à compile time que l’on s’apercevra que l’on a oublié de mettre à jour la Factory quand on a rajouté le nouveau type de personnage. C’est moins bien qu’une erreur de compilation mais c’est plus robuste que de renvoyer un null.
Note
On peut « robustifier » un peu plus notre schéma :
en créant un package qui contient les classes Joueur, Magicien, Chevalier, Voleur et JoueurFactory, et uniquement ces classes, et
en faisant en sorte que les constructeurs des différentes classes de personnage soient package-private et celui de JoueurFactory soit public.
Dans ce cas, pour ÉtatJeu, le seul moyen de créer des personnages est d’utiliser JoueurFactory.
Avantages :
On désolidarise complètement le processus de création des classes qui utilisent ces créations.
On respecte le principe de responsabilité unique puisque l’unique rôle de la Factory est de créer des instances.
ÉtatJeu respecte le principe Ouvert/Fermé : de nouveaux types de construction des personnages n’impliquent aucune modification dans le code d’ÉtatJeu.
Inconvénients :
Le dernier diagramme UML est plus complexe que les précédents. Il ne faut donc pas trop abuser des Factories.
On souhaite maintenant que notre jeu de rôles soit adapté au niveaux des joueurs : les débutants auront pour Coffres des Malles remplies d’or et pour Ennemis uniquement des Gobelins (costauds mais pas fûtés). En revanche, les joueurs expérimentés auront pour Coffres des Boîtes contenant très peu d’or et pour Ennemis des Elfs (très fûtés et experts en magie).
On souhaiterait donc avoir une Factory qui s’adapte ainsi au niveau du joueur mais qui conserve une API unique pour que ÉtatJeu n’ait pas à faire de if/else pour s’adapter au niveau du joueur.
Définition
La problématique générale que le design pattern Abstract Factory essaie de résoudre est l’instanciation, via une unique API, d’objets appartenant à des ensembles de classes, ces ensembles étant différents en fonction de la valeur d’un certain critère.
Une solution UML consiste à créer une interface AbstractFactory qui sera implémentée par des factories « concrètes », une pour chaque niveau de joueur. Chacune de ces factories a une méthode pour construire les objets qui nous intéressent (une méthode pour construire des Coffres et une pour les Ennemis). Évidemment, pour que l’API soit unique, il faut que les objets instanciés (Gobelins, Elfs, Malles, Boîtes) appartiennent à des classes qui implémentent Coffres ou Ennemis.
Avantages :
Le principe de « Responsabilité Unique » est respecté par le client (ÉtatJeu) puisque ce n’est pas lui qui réalise un code complexe pour instancier les classes concrètes.
Le principe « Ouvert/Fermé » est respecté par le client (ÉtatJeu) puisqu’il ne connaît que l’API commune à toutes les classes concrètes. Ainsi, il a un couplage faible avec ces dernières.
Les classes concrètes instanciées par la fabrique abstraite ont la garantie d’être cohérentes les unes avec les autres.
Inconvénients :
Les classes concrètes doivent toutes implémenter des interfaces communes, de même que les fabriques concrètes doivent toutes implémenter la même fabrique abstraite. Cela peut complexifier le code de l’application.
Les Coffres de notre jeu de rôle peuvent contenir :
des armures qui augmentent les points de défense du joueur,
des potions magiques qui augmentent l’intelligence du joueur,
des potions nutritives qui augmentent l’endurance du joueur,
des bottes qui permettent d’augmenter l’agilité du joueur,
des armes qui augmentent la force du joueur,
des masques de beauté qui augmentent le charisme du joueur.
Un Coffre peut contenir zéro ou un objet de chacune des catégories ci-dessus. Autrement dit, il y a \(2^6 = 64\) combinaisons possibles, donc 64 constructeurs de Coffre possibles. Par exemple :
Coffre(Armure arm, Botte botte); // contient une armure et des bottes
Coffre(PotionMagique pot, Arme arm); // contient une potion magique et une arme
Coffre(Armure arm); // contient juste une armure
On se doute bien que l’on n’a pas envie d’écrire 64 constructeurs. On ne va donc en construire qu’un seul, qui contient tous les types de contenus possibles :
Coffre(Armure armure,
PotionMagique potMagique,
PotionNutritive potNutritive,
Botte botte,
Arme arme,
MasqueBeauté masque);
et on passera des null pour les objets que le Coffre ne contient pas. Mais cela résulte en un code un peu « lourd » à utiliser. Par exemple, pour simuler des appels aux 3 constructeurs indiqués plus hauts, on aurait les appels suivants :
Coffre(armure, null, null, botte, null, null);
Coffre(null, potion, null, null, arme, null);
Coffre(armure, null, null, null, null, null);
Bref, on est amené à passer beaucoup de null en arguments. L’objectif de Builder est de pallier cela.
Définition
L’objectif du design pattern Builder est de construire des instances d’objets complexes en les initialisant étape par étape. Cela permet, entre autres, d’éviter d’avoir un seul constructeur avec plein de paramètres optionnels.
Il existe plusieurs variantes du Builder, mais une idée clef consiste :
à encapsuler une classe statique Builder à l’intérieur de la classe que l’on souhaite construire avec, le Builder contenant les mêmes attributs que la classe à construire,
à passer en paramètre du constructeur de la classe à construire une instance du Builder,
à créer dans le Builder des méthodes permettant d’initialiser les attributs et les objets imbriqués, ces méthodes retournant le Builder,
à fournir dans le Builder une méthode build() qui renvoie l’instance que l’on souhaitait construire.
Cela amène à un diagramme UML similaire à :
Voici un code Java qui illustre l’idée : le seul constructeur de Coffre que l’on propose a un Builder en argument. Ce Builder contient les mêmes attributs que le Coffre. La classe Builder est encapsulée à l’intérieur de la classe Coffre afin que ce soit plus simple de la retrouver quand on fait des mises à jour de l’application. Le main() ci-dessous illustre bien l’idée : on fait un new Coffre.Builder() afin de créer le Builder. Le new retournant un Builder, on peut s’en servir pour appeler une des méthodes set. Comme celles-ci retournent aussi des Builders, on peut enchaîner les set. Enfin, quand on a affecté tout le contenu du coffre, on appelle la méthode build() qui crée un vrai Coffre en lui passant en paramètre le Builder que l’on a créé précédemment. Cela donne un code assez esthétique (1 seul constructeur de Coffre au lieu de 64, pas une foultitude de null à passer en arguments).
public class Coffre {
private Armure armure;
private PotionMagique potionMagique;
private PotionNutritive potionNutritive;
private Botte botte;
private Arme arme;
private MasqueBeaute masqueBeaute;
Coffre(Builder builder) {
this.armure = builder.armure;
this.potionMagique = builder.potionMagique;
this.potionNutritive = builder.potionNutritive;
this.botte = builder.botte;
this.arme = builder.arme;
this.masqueBeaute = builder.masqueBeaute;
}
public String toString() {
String res = "{";
if (this.armure != null) { res += "armure, "; }
if (this.potionMagique != null) { res += "potionMagique, "; }
if (this.potionNutritive != null) { res += "potionNutritive, "; }
if (this.botte != null) { res += "botte, "; }
if (this.arme != null) { res += "arme, "; }
if (this.masqueBeaute != null) { res += "masque, "; }
if (res.length() > 2 ) res = res.substring(0, res.length() - 2);
res = res + "}";
return res;
}
// ===================================================================
// le builder qui va permettre d'instancier correctement les attributs
// ===================================================================
public static class Builder {
private Armure armure;
private PotionMagique potionMagique;
private PotionNutritive potionNutritive;
private Botte botte;
private Arme arme;
private MasqueBeaute masqueBeaute;
public Builder() {}
public Builder setArmure(Armure armure) {
this.armure = armure;
return this;
}
public Builder setPotionMagique(PotionMagique potionMagique) {
this.potionMagique = potionMagique;
return this;
}
public Builder setPotionNutritive(PotionNutritive potionNutritive) {
this.potionNutritive = potionNutritive;
return this;
}
public Builder setBotte(Botte botte) {
this.botte = botte;
return this;
}
public Builder setArme(Arme arme) {
this.arme = arme;
return this;
}
public Builder setMasqueBeaute(MasqueBeaute masqueBeaute) {
this.masqueBeaute = masqueBeaute;
return this;
}
public Coffre build() {
return new Coffre(this);
}
}
// ===================================================================
// utilisation du Builder
// ===================================================================
public static void main(String[] args) {
Coffre coffre = new Coffre.Builder()
.setArmure(new Armure())
.setBotte(new Botte())
.build();
System.out.println(coffre);
}
}
Avantages :
On évite les constructeurs à rallonge avec plein de paramètres optionnels.
On peut construire des objets en plusieurs étapes (certaines peuvent même dépendre d’étapes précédentes).
On peut assurer qu’une fois l’instance créée, on ne peut plus la modifier (pas de setter accessible).
Le Builder assure le principe de Responsabilité Unique. En effet, la construction est déléguée au Builder, qui n’assure que cette tâche, et ce qui ne relève que du fonctionnement du Coffre (business logic) est localisé dans la classe Coffre.
Inconvénients :
Si l’on encapsule le Builder dans la classe qu’il construit, on obtient des fichiers assez volumineux.
Si l’on encapsule le Builder dans la classe qu’il construit, on doit dupliquer les attributs de cette classe dans le Builder. On a donc un couplage très fort entre ces deux classes.
L’objectif de ce patron de conception est de garantir qu’une application ne peut créer qu’une seule instance d’une classe donnée. On pourrait imaginer que l’on ne souhaite créer qu’une seule instance de notre Factory parce qu’elle conserve en mémoire un « pool » de personnages, qu’elle utilise chaque fois qu’on lui demande d’en construire un nouveau.
Définition
Le design pattern Singleton permet de garantir qu’une unique instance d’une classe donnée pourra être créée dans l’application. Si on essaye de créer deux instances, on récupérera la même.
Tout d’abord, il faut empêcher que n’importe qui puisse utiliser le ou les constructeur(s) de la classe. On va donc les mettre en private. Ensuite, pour pouvoir tout de même créer des instances, on va utiliser une méthode public static. En UML, cela correspond au diagramme de classes suivant :
En termes de programmation, on aurait un code similaire à :
public class MaClasse {
// l'unique instance qui sera créée
private static MaClasse instance;
// les constructeurs en private
private MaClasse() {
System.out.println("construction d'une instance de MaClasse");
}
// les méthodes de la classe
public String toString() {
return "MaClasse";
}
// la méthode qui va être appelée pour construire toutes les instances
public static MaClasse getInstance() {
if (instance == null) {
instance = new MaClasse();
}
return instance;
}
public static void main(String[] args) {
MaClasse maClasse1 = MaClasse.getInstance();
MaClasse maClasse2 = MaClasse.getInstance();
}
}
Dans le code ci-dessus, maClasse1 et maClasse2 référencent le même objet.
Attention
Il faut noter que le code ci-dessus n’est pas adapté pour le multithreading. En effet, rien n’empêche une thread appelant getInstance() d’être interrompue après avoir passé le if mais avant d’avoir affecté l’attribut instance. Dans ce cas, une autre thread peut appeler getInstance(), passer le if et affecter instance, puis la première thread peut reprendre son exécution et affecter à nouveau instance.
Une solution possible en Java pour contourner le problème du multithreading consiste à adapter le code ci-dessus en celui ci-dessous. On crée directement l’instance avant d’appeler la méthode getInstance(). Java garantit qu’on ne peut accéder à un objet d’une classe avant que la classe soit complètement chargée. Du coup, la 3ème ligne est exécutée avant qu’on puisse faire le return instance.
public class MaThreadedClasse {
// l'unique instance qui sera créée
private static MaThreadedClasse instance = new MaThreadedClasse();
// les constructeurs en private
private MaThreadedClasse() {
System.out.println("construction d'une instance de MaThreadedClasse");
}
// les méthodes de la classe
public String toString() {
return "MaThreadedClasse";
}
// la méthode qui va être appelée pour construire toutes les instances
public static MaThreadedClasse getInstance() {
return instance;
}
public static void main(String[] args) {
MaThreadedClasse maClasse1 = MaThreadedClasse.getInstance();
MaThreadedClasse maClasse2 = MaThreadedClasse.getInstance();
}
}
Notez qu’en C++, on trouve souvent sur internet des codes de Singleton similaires à celui de MaClasse.java. Ces codes ne sont pas thread-safe. En C++, il faut utiliser le **Singleton de Meyers** qui a en plus le bon goût d’être plus simple :
#include <iostream>
class MaThreadedClasse {
private:
// rendre les constructeurs et destructeurs privés
MaThreadedClasse() = default;
~MaThreadedClasse() = default;
public:
// empêcher les constructeurs/opérateurs de copie et de move
MaThreadedClasse(const MaThreadedClasse&) = delete;
MaThreadedClasse(MaThreadedClasse&&) = delete;
MaThreadedClasse& operator = (const MaThreadedClasse&) = delete;
MaThreadedClasse& operator = (MaThreadedClasse&&) = delete;
// la méthode pour créer les instance
static MaThreadedClasse& getInstance() {
static MaThreadedClasse instance;
return instance;
}
};
Les variables statiques définies dans une portée locale (à l’intérieur d’une fonction par exemple) sont créées quand elles sont utilisées la première fois. Donc C++ garantit que c’est la première thread qui accèdera à instance qui la créera. Et cette opération est bloquante : aucune autre thread ne pourra essayer de la créer.
Avantages :
On a la garantie qu’une seule instance est créée dans tout le programme.
On peut accéder à cette instance facilement en utilisant autant de fois que l’on veut la méthode getInstance().
Les version multithreadées sont « efficaces », c’est-à-dire qu’elles n’utilisent pas de mécanisme de synchronisation (lock).
Inconvénients :
Chaque fois que l’on appelle getInstance(), on obtient la même instance. Le programme doit donc tenir compte du fait que toutes ces instances sont « partagées », en particulier quand on travaille en multithreading.
Singleton empêche de faire de l’héritage dans la mesure où les constructeurs sont privés, pas protected.
Quels patrons pensez-vous pouvoir utiliser dans l’application de votre petite entreprise de restauration ? Modifiez en conséquence son diagramme de classes.