Les design patterns

Les design patterns (on dit aussi patrons en français) sont des solutions générales, flexibles, éprouvées et bien connues pour résoudre des problèmes récurrents en programmation. Ils sont indépendants du langage de programmation objet que vous utilisez. C’est pourquoi ils sont assez incontournables.

Le « Gang of Four » (Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides) a formalisé 23 patrons dans un livre best-seller :

Design Patterns. Elements of Reusable Object Oriented Software.
Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides.
Addison Wesley, 1995.

On peut séparer ces design patterns en plusieurs catégories :

Une référence utile avec plein d'exemples et d'explications : Refactoring Guru.

Les design patterns de construction

Dans cette séance, on va s’intéresser à deux 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. 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 :

Exercice 1 : Moteur, ça tourne...   

Utilisez le patron Factory pour créer les différents types de moteurs de l’USS Orville.

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, 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 :

Exercice 2 : Moteur, ça retourne...   

Utilisez le patron Builder pour créer le système de propulsion d’un navire de la flotte pouvant contenir des moteurs à impulsion électromagnétique, des moteurs auxiliaires et/ou un hyperdrive.

 
© C.G. 2007 - 2025