Les design patterns de construction

Dans cette séance, on va s’intéresser à des design patterns permettant de construire de manière efficace et flexible des objets :

Le design pattern Factory (Fabrique)

La problématique :

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 :

test.cpp
#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 Factory :

La solution apportée par le design pattern Factory peut être décrit grâce au diagramme UML suivant :

le design pattern Factory (Fabrique)

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 :

JoueurFactory.java
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");
    }
  }
}
Main.java
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 :

  1. en créant un package qui contient les classes Joueur, Magicien, Chevalier, Voleur et JoueurFactory, et uniquement ces classes, et

  2. 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.

Deux variantes (à voir en fin de séance s'il y a le temps)  

Avantages et inconvénients :

Avantages :

Inconvénients :

Le design pattern Abstract Factory (Fabrique abstraite)

La problématique :

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.

La solution apportée par Abstract Factory :

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.

le design pattern Abstract Factory (Fabrique abstraite)

Avantages et inconvénients :

Avantages :

Inconvénients :

Le design pattern Builder (Monteur)

La problématique :

Les Coffres de notre jeu de rôle peuvent contenir :

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.

La solution apportée par Builder :

Il existe plusieurs variantes du Builder, mais une idée clef consiste :

  1. à 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,

  2. à passer en paramètre du constructeur de la classe à construire une instance du Builder,

  3. à créer dans le Builder des méthodes permettant d’initialiser les attributs et les objets imbriqués, ces méthodes retournant le Builder,

  4. à fournir dans le Builder une méthode build() qui renvoie l’instance que l’on souhaitait construire.

Cela amène à un diagramme UML similaire à :

le design pattern Builder (Monteur)

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).

Coffre.java
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);
  }
}

Deux variantes (à voir en fin de séance s'il y a le temps)  

Avantages et inconvénients :

Avantages :

Inconvénients :

Le design pattern Singleton

La problématique :

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.

La solution apportée par Singleton :

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 :

le design pattern Singleton

En termes de programmation, on aurait un code similaire à :

MaClasse.java
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.

MaThreadedClasse.java
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 :

maThreadedClasse.cpp
#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 et inconvénients :

Avantages :

Inconvénients :

Exercice 1 : Le patron de ma petite entreprise   

Quels patrons pensez-vous pouvoir utiliser dans l’application de votre petite entreprise de restauration ? Modifiez en conséquence son diagramme de classes.

 
© C.G. 2007 - 2024