Introduction aux design patterns

Tout effort de développement logiciel se heurte inévitablement à des problèmes de conception récurrents. Il arrive ainsi souvent de devoir adopter, consciemment ou pas, des stratégies similaires pour les résoudre.

Les design patterns (ou patrons de conception en français) sont des modèles reconnus qui proposent des solutions éprouvées, fiables et efficientes à un certain nombre de ces problèmes.

Ils permettent la conception de solutions (orientées objet) flexibles et réutilisables, via des objets faciles à créer, implémenter, modifier, tester et réutiliser.

"Design patterns are typical solutions to common problems in software design. Each pattern is like a blueprint that you can customize to solve a particular design problem in your code."

L'excellent refactoring.guru

Il est important de préciser que les design patterns ne sont pas la solution ultime à tous les problèmes rencontrés quotidiennement. Il ne s'agit pas de recettes miracles à utiliser à tout bout de champ, mais bel et bien de modèles à adapter à votre use case après une analyse sérieuse de ce dernier.

L'utilisation frénétique et abusive des design patterns serait même à classer dans la catégorie des anti-pattern.

On dénombre 23 Design Patterns (d'après le GoF) qu'il est d'usage de regrouper en trois catégories :

  • Creational (Patrons de Création)
  • Structural (Patrons Structurels)
  • Behavioural (Patrons Comportementaux).

Liste des 23 design patterns

Ce premier article sera consacré aux Creational design patterns.

Cette catégorie regroupe des patrons dont le but est la création de nouveaux objets tout en augmentant la flexibilité et la réutilisabilité du code :

Illustration par l'exemple

Bob 🧔 se lance dans un projet d'envergure : développer un jeu de rôle !

C'est un bon développeur mais il manque cruellement d'imagination : il a donc décidé d'appeler provisoirement son jeu SuperBob, the mighty hero. Il sera toujours à temps de changer plus tard quand le succès sera, à n'en pas douter, au rendez-vous.

Superbob against the evil warlock

Au cours du développement, il va rencontrer un certain nombre de problèmes qui peuvent être résolus avec des patterns créationnels.

La suite va nous donner un exemple d'utilisation pour chacun d'entre eux.

Singleton

singleton

Comme le jeu de Bob 🧔 est très, très verbeux, il souhaite concevoir une entité pour centraliser la persistence des dialogues dans un fichier texte.

Il ne faudrait cependant pas que l'écriture sur le système de fichiers passe via une classe ayant plus d'une instance (et donc, possiblement, plus d'un thread) : cela pourrait donner lieu à un fichier brouillé, plusieurs écritures concurrentielles pouvant avoir un résultat inattendu.

Sans doute le plus connu, mais aussi le plus controversé des patterns créationnels, le Singleton permet de s'assurer de l'unicité d'une instance de classe tout en proposant un point d'accès unique à cette instance.

Il est notamment utilisé lorsqu'on a besoin d'un seul objet pour coordonner des opérations dans un système ou quand le système est plus rapide / occupe moins de mémoire avec peu d'objets qu'avec beaucoup d'objets similaires.

Des exemples d'utilisation courante des singletons :

  • Une connexion à une base de données
  • Un logger
  • Une interface avec un système de fichiers
  • Un bus d'évenements

L'implémentation d'un Singleton est relativement simple :

  1. Rendre le constructeur par défaut privé, pour empêcher l'instanciation de la classe via un new Singleton().
  2. Implémenter une méthode statique de création de l'objet qui agit comme un constructeur. Sous le capot, cette méthode appelle le constructeur privé pour créer un objet et l'enregistre dans un champ statique. Tous les appels suivants à cette méthode renvoient l'objet mis en cache.

Voici ce que ça donnerait pour le Logger dont Bob 🧔 se servira pour enregistrer toutes les interactions verbales de son jeu :

class Logger {
  /**
   * L'attribut statique `instance` qui va contenir la référence
   * à la seule instance de la classe
   */
  private static instance: Logger;

  /**
   * Le constructeur privé, inacessible hors de la classe
   */
  private constructor() {}

  /**
   * La méthode statique `getInstance` responsable de :

   * - Créer une instance de la classe si elle n'existe pas
   * - La retourner dans tous les cas
   */
  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }

    return Logger.instance;
  }

  log(message: string) {
    // logique d'écriture dans un fichier
  }
}

Il est ainsi impossible d'instancier un objet via un new Logger() : Il faut forcément passer par la méthode statique getInstance.

/*
 * Toutes les instances de la classe `Logger` retournent le meme objet
 */
const logger = Logger.getInstance();
const loggerUsedSomeWhereElse = Logger.getInstance();

console.log(logger === loggerUsedSomeWhereElse); // true

/*
 * L'instanciation via new retourne une erreur
 */
new Logger(); // Constructor of class 'Logger' is private and only accessible within the class declaration

Factory Method

factory method

Bob 🧔 progresse dans le design de son jeu : il introduit la notion de Room contenant un ennemi à affronter. Dans un premier temps, il avait peuplé ses Rooms avec des ennemis de type Monster.

class Monster {
  public appear() {
    console.log('A wild monster appears !');
  }

  public makeSomeSillyFace() {
    console.log('👿');
  }

  public shout() {
    console.log('Arrrrg !');
  }
}

class Room {
  readonly monster: Monster = new Monster();

  public frightenHero() {
    this.monster.appear();
    this.monster.makeSomeSillyFace();
    this.monster.shout();
  }
}

function clientCode() {
  const room = new Room();
  room.frightenHero();
}

clientCode();
// A wild monster appears !
// 👿
// Arrrrg !

Cette partie du code client, comme de nombreuses autres, instancie directement une Room qui ne sait interagir qu'avec des ennemis de type Monster.

Cela pose problème quand Bob 🧔 veut introduire un peu de diversité dans les environnements et les ennemis de son jeu. Il voudrait par exemple que certaines Room ne soient pas peuplées de Monster, mais de Ghost. Pour celà, il est obligé d'introduire des branchements logiques (des switch par exemple) dans sa classe Room, ou dans tout autre partie du code instanciant directement des Monster.

Cela devient vite pénible, surtout car ce genre de manipulations doit être reproduit à chaque introduction d'un nouveau type d'ennemi.

C'est là que la Factory Method peut aider :

La Factory Method (Méthode Fabrique, ou tout simplement Fabrique) fournit une interface pour créer des objets dans une superclasse, mais permet aux sous-classes de modifier le type d'objet qui sera créé.

Le but est de déléguer la responsabilité de la création d'objets du même type (qui implémentent une interface commune) à la Factory Method afin de pouvoir disposer d'objets interchangeables dans l'application, la classe exacte de l'objet n'étant jamais connue par le système l'utilisant.

/**
 * La classe `RoomCreator` déclare la méthode de fabrique qui est censée retourner un
 * objet de type `Enemy` : `makeEnemy()`
 * Cette méthode est abstraite : Ce sont les sous-classes de `RoomCreator` qui fournissent une implémentation concrète de cette méthode.
 */
abstract class RoomCreator {
  /**
   * Notre factory method, dont le type de retour est `Enemy`
   */
  public abstract makeEnemy(): Enemy;

  /**
   * La responsabilité première de `RoomCreator` n'est pas la création d'ennemis.
   * Elle contient une logique métier qui s'appuie sur les objets `Enemy` retournés par la factory method.
   * Les sous-classes peuvent indirectement changer cette logique en surchargeant ladite méthode
   * et en renvoyant un type de produit différent.
   */
  public frightenHero() {
    const enemy = this.makeEnemy();
    enemy.appear();
    enemy.makeSomeSillyFace();
    enemy.shout();
  }
}

/**
 * Les créateurs concrets surchargent la factory method afin de changer le type d'ennemi retourné
 */
class MonsterRoomCreator extends RoomCreator {
  /**
   * La méthode retourne toujours le type `Enemy`, même si le type d'ennemi concret renvoyé par la méthode diffère.
   * De cette façon, le créateur peut rester indépendant des classes de produits concrets.
   */
  public makeEnemy(): Enemy {
    return new Monster();
  }
}

class GhostRoomCreator extends RoomCreator {
  public makeEnemy(): Enemy {
    return new Ghost();
  }
}

/**
 * Toutes les instances concrètes d'`Enemy` doivent implémenter ces méthodes.
 */
interface Enemy {
  appear(): void;
  makeSomeSillyFace(): void;
  shout(): void;
}

/**
 * Des exemples d'implémentations concrètes d'`Enemy`.
 */
class Monster implements Enemy {
  public appear() {
    console.log('A wild monster appears !');
  }
  public makeSomeSillyFace() {
    console.log('👿');
  }
  public shout() {
    console.log('Arrrrg !');
  }
}

class Ghost implements Enemy {
  public appear() {
    console.log('A ghost appears !');
  }
  public makeSomeSillyFace() {
    console.log('👻');
  }
  public shout() {
    console.log('woooooooo !');
  }
}

/**
 * Le code client reçoit une instance concrète de `RoomCreator`, mais typée en tant que l'interface implémentée.
 * On peut donc lui passer tout type d'implementation concrete.
 */
function clientCode(room: RoomCreator) {
  room.frightenHero();
}

/**
 * Il est maintenant possible d'éxécuter notre code client en lui passant
 * une de nos deux classes de création sans que ce dernier n'ait conaissance de ce qu'on lui passe
 */

// Application lancée avec le MonsterRoomCreator
clientCode(new MonsterRoomCreator());
// A wild monster appears !
// 👿
// Arrrrg !

// Application lancée avec le GhostRoomCreator
clientCode(new GhostRoomCreator());
// A ghost appears !
// 👻
// woooooooo !

Abstract Factory

factory

Son jeu connaissant un succès fulgurant, Bob 🧔 souhaite introduire des donjons !

Un donjon, dans son jeu, n'est composé pour le moment que de deux choses : un Bâtiment et un Boss.

Ces deux objets appartiennent à une même famille et sont liés par une thèmatique commune. Fort de son expérience avec la Factory Method, il souhaite se laisser la possibilité de passer différents types de donjons (et les produits les composant) à son code métier.

L'Abstract Factory (Fabrique Abstraite) est un modèle qui permet de produire des familles d'objets connexes ou dépendants sans spécifier leurs classes concrètes.

Par "familles d'objets" on peut imaginer, par exemple Véhicule + Moteur + Contrôles dans le cadre d'une application de simulation de conduite. Il est ensuite tout à fait possible d'introduire des variantes à l'ensemble de ces objets, par ex:

  • Voiture + Moteur à combustion + Volant
  • Avion + Moteur à réaction + Manche à balai
  • Voiture du futur + Moteur à fusion nucléaire + Commandes vocales, etc.

Si votre programme ne fonctionne pas avec des familles de produits, alors vous n'avez probablement pas besoin d'une fabrique abstraite.

Mais attention, Bob 🧔 souhaite que les différentes composantes de chaque donjon restent cohérentes : il serait par exemple dommage de se retrouver avec un boss aquatique dans un bâtiment aérien 🤦‍♂️.

/**
 * L'Abstract Factory déclare un ensemble de méthodes renvoyant différents produits abstraits.
 * Ces produits forment une `famille` dont les composantes sont liées par un thème ou un concept de haut niveau.
 * Les produits d'une même famille sont généralement capables de collaborer entre eux.
 * Une famille de produits peut avoir plusieurs variantes, mais les produits d'une variante sont incompatibles avec les produits d'une autre.
 */
interface DungeonFactory {
  createBuilding(): AbstractBuilding;
  createBoss(): AbstractBoss;
}

/**
 * Les factory concrètes produisent des produits appartenant à la même famille.
 * La factory garantit que les produits résultants sont compatibles.
 * Notez que les signatures des méthodes de la Fabrique Concrète retournent un produit abstrait
 * alors que la méthode instancie bel et bien un produit concret (cf cas de la Factory Method)
 */
class EvilWarlockDungeonFactory implements DungeonFactory {
  public createBuilding(): AbstractBuilding {
    return new EvilWarlockLibrary();
  }

  public createBoss(): AbstractBoss {
    return new EvilWarlock();
  }
}

/**
 * À chaque factory concrète correspond une variante de produit.
 */
class DarkKnightDungeonFactory implements DungeonFactory {
  public createBuilding(): AbstractBuilding {
    return new DarkKnightCastle();
  }

  public createBoss(): AbstractBoss {
    return new DarkKnight();
  }
}

/**
 * Chaque produit d'une famille de produits doit avoir une interface de base que toutes les
 * variantes concrètes doivent implémenter.
 */
interface AbstractBuilding {
  playMusic(): void;
  getTreasureKey(): string;
}

/**
 * Les classes concrètes seront créées par les factory concrètes associées.
 */
class EvilWarlockLibrary implements AbstractBuilding {
  public playMusic() {
    console.log('Some eerie music starts playing...');
  }
  public getTreasureKey(): string {
    return 'an ancient key made with bones';
  }
}

class DarkKnightCastle implements AbstractBuilding {
  public playMusic() {
    console.log('Some dark music starts playing...');
  }
  public getTreasureKey(): string {
    return 'a dark and heavy metal key';
  }
}

/**
 * Les produits concrets peuvent interargir entre eux, mais seulement si ce sont les produits de la même Factory concrète.
 */
interface AbstractBoss {
  stateTheirName(): void;
  protectBuildingTreasure(collaborator: AbstractBuilding): void;
}

class EvilWarlock implements AbstractBoss {
  public stateTheirName() {
    console.log('I am the almighty Evil Warlock 🧙‍‍️ !!!');
  }

  /**
   * Un `Boss` ne peut interagir qu'avec un `Building` issu de la même `DungeonFactory` concrete.
   */
  public protectBuildingTreasure(building: AbstractBuilding) {
    const key = building.getTreasureKey();
    console.log(`Evil warlock is protecting ${key} !`);
  }
}

class DarkKnight implements AbstractBoss {
  public stateTheirName() {
    console.log('I am the invincible Dark Knight 🔪 !!!');
  }

  public protectBuildingTreasure(building: AbstractBuilding) {
    const key = building.getTreasureKey();
    console.log(`Dark night is protecting ${key} !`);
  }
}

/**
 * Le code client n'a connaissance des produits et des Factory que via leur interface (DungeonFactory / AbstractBuilding | AbstractBoss)
 */
function clientCode(dungeon: DungeonFactory) {
  const building = dungeon.createBuilding();
  const boss = dungeon.createBoss();

  building.playMusic();
  boss.stateTheirName();
  boss.protectBuildingTreasure(building);
}

/**
 * Le code client peut désormais fonctionner avec n'importe quelle factory concrète !
 */
clientCode(new EvilWarlockDungeonFactory());
// Some eerie music starts playing...
// I am the almighty Evil Warlock 🧙‍‍️ !!!
// Evil warlock is protecting an ancient key made with bones !

clientCode(new DarkKnightDungeonFactory());
// Some dark music starts playing...
// I am the invincible Dark Knight 🔪 !!!
// Dark night is protecting a dark and heavy metal key !

Attention, il ne suffit pas de déclarer une Factory en abstract pour en faire une une Abstract factory 😏 !

Builder

builder

Bob 🧔 décide d'enrichir son jeu avec la notion de monture, qu'il est possible de louer avant chaque mission pour assister son héros :

class Mount {
  constructor(private name: string, private color: string, private speed: number) {}
}

const basicHorse = new Mount('Thunder', 'greyish', 22.5);

Au fur et à mesure du développement, de nouvelles caractéristiques ont été associées aux montures, et le tout est très vite devenu ingérable :

class Mount {
  constructor(
    private name?: string,
    private color?: string,
    private speed?: number,
    private legs: number = 4,
    private rarity: number = 1,
    private canFly: boolean = false,
    private canBreatheFire: boolean = false,
    private age?: number,
  ) {
    // ...
  }
}

const basicHorse = new Mount('Thunder', 'greyish', 22.5, null, null, null, null, 5);

const mythicalHellishHorse = new Mount('Armaggedon', 'red', 145, null, 5, null, null, 8);

const mutantFlyingCreature = new Mount(
  'x3zU!zz762uxKivargzTToLdjskdaqiwdjdqdqu',
  'lavender',
  800,
  8,
  9,
  true,
  true,
  99,
);

Cette approche présente un inconvénient majeur (en plus de sa lourdeur et de son manque de lisibilité) : la plupart des paramètres ne sont utiles que pour un seul type d'objet, et donc inutiles le reste du temps...

Une seconde approche serait de créer une classe différente pour chaque type de monture : Horse, MythicalHorse, MutantFLyingCreature, etc. Là aussi, cela n'est pas idéal : chaque nouvelle variante introduit une nouvelle classe, et le programme devient très dur à maintenir.

C'est à ce moment que le Builder entre en jeu :

Le Builder (Monteur en français) est un modèle de conception créationnel permettant de construire des objets complexes étape par étape. Ce modèle vous permet de produire différents types et représentations d'un objet en utilisant le même code de construction.

Pour cela il faut :

  • Encapsuler la création et l'assemblage des parties d'un objet complexe dans un objet Builder.
  • Le code client délègue la création d'objets à un objet Builder au lieu de créer les objets directement.
class MountBuilder {
  private mount: Mount;

  /**
   * Une nouvelle instance de `MontBuilder` doit contenir un objet `Mount` vierge, qui sera ensuite
   * utilisé dans les différentes étapes de production
   */
  constructor() {
    this.reset();
  }

  public reset(): void {
    this.mount = new Mount();
  }

  /**
   * Toutes les étapes de production s'appliquent à l'instance de `Mount`, qui est
   * retournée à chaque fois pour permettre de chaîner les appels auxdites étapes.
   */
  public setName(name: string): this {
    this.mount.setName(name);
    return this;
  }

  public setColor(color: string): this {
    this.mount.setColor(color);
    return this;
  }

  public setLegs(amount: number): this {
    this.mount.setLegs(amount);
    return this;
  }

  public setRarity(amount: number): this {
    this.mount.setRarity(amount);
    return this;
  }

  public canFly(): this {
    this.mount.addFlyingAbility();
    return this;
  }

  public canBreatheFire(): this {
    this.mount.addBreathingFireAbility();
    return this;
  }

  public setAge(age: number): this {
    this.mount.setAge(age);
    return this;
  }

  /**
   * Il est d'usage de reset le builder lorsqu'un produit "assemblé" est retourné au client.
   * Ainsi le builder peut directement produire un nouvel objet from scratch
   * débarassé de toute trace du produit précédemment créé.
   */
  public getMount(): Mount {
    const mount = this.mount;
    this.reset();
    return mount;
  }
}

const mountBuilder = new MountBuilder();

const basicHorse = mountBuilder
.setName('Thunder')
.setColor('greyish')
.setSpeed(22.5)
.setAge(5);

const mythicalHellishHorse = mountBuilder
  .setName('Armaggedon')
  .setColor('red')
  .setSpeed(145)
  .setRarity(5)
  .setAge(8);

const mutantHorse = mountBuilder
  .setName('x3zU!zz762uxKivargzTToLdjskdaqiwdjdqdqu')
  .setColor('lavender')
  .setSpeed(800)
  .setLegs(8)
  .setRarity(9)
  .canFly();
  .canBreatherFire();
  .setAge(99);

Prototype

prototype

Le Prototype est un modèle qui reposant sur le principe du clonage d'objets : il permet de créer de nouveaux objets à partir d’objets existants sans rendre le code client dépendant de la classe desdits objets.

Prenons l'exemple suivant : dans le jeu, il existe une chambre maudite qui fait apparaitre un ennemi. Si notre héros ne terrasse pas l'ennemi dans le temps imparti, celui-ci se clone et il faut désormais combattre deux ennemis identiques !

class Weapon {
  constructor(public type: string, public powerLevel: number) {}
}

class Monster {
  public type?: string;
  public weapon?: Weapon;
  public hp?: number;
  public color?: string;
  private _isEnraged: boolean;

  constructor() {
    // Une chance sur dix
    this._isEnraged = Math.random() < 0.1;
  }

  get isEnraged(): boolean {
    return this._isEnraged;
  }
}

const cloneEnemy = (monster: Monster): Monster => {
  const clone = new Monster();
  clone.type = monster.type;
  clone.weapon = monster.weapon;
  clone.hp = monster.hp;
  return clone;
};

const monster = new Monster();
monster.type = 'BASIC';
monster.hp = 500;
monster.color = 'red';
monster.weapon = new Weapon('AXE', 9001);

const monster2 = cloneEnemy(monster);

console.log(monster.type === monster2.type); // true
console.log(monster.hp === monster2.hp); // true
console.log(monster.weapon === monster2.weapon); // ❌ true, les deux `Monsters` partagent la même instance de `Weapon` !
console.log(monster.isEnraged === monster2.isEnraged); // ❌ true 1% du temps, false le reste

Ça fonctionne presque, mais Bob 🧔 se rend compte des limites du modèle :

  • Chaque ajout de caractéristique sur le monstre l'oblige à modifier tous les entités dans lequel ce monstre est cloné, ce qui rend le code très rigide.
  • De plus, il se trouve que notre monstre a une propriété privée isEnraged dont il n'est pas possible de définir l'état manuellement et qui a un impact sur les dommages qu'il va recevoir, son apparence, etc.
  • Enfin, il souhaiterait avoir la possibilité de passer n'importe quel type d'ennemi à la fonction cloneEnemy : ce n'est pas possible en l'état, celle-ci instanciant directement un objet de type Monster.

L'utilisation du pattern Prototype pourrait résoudre ce problème :

class Weapon {
  constructor(public type: string, public powerLevel: number) {}
}

abstract class EnemyPrototype {
  constructor(
    public type?: string,
    public hp?: number,
    public color?: string,
    public weapon?: Weapon
  ) {}

  clone(): this {
    const clone = Object.create(this);
    /**
     * Une nouvelle `Weapon` est créée déclarativement, mais on pourrait aussi rendre la classe Weapon clonable 😏
     */
    if (this.weapon) {
      clone.weapon = new Weapon(this.weapon.type, this.weapon.powerLevel);
    }

    return clone;
  }
}

class Monster extends EnemyPrototype {
  private _isEnraged: boolean;

  constructor() {
    // Une chance sur dix
    super();
    this._isEnraged = Math.random() < 0.1;
  }

  get isEnraged(): boolean {
    return this._isEnraged;
  }
}

/**
 * Il est désormais possible de cloner tout type d'ennemi, tant que ce sont des instances
 * d'une classe etendant `EnemyPrototype`.
 * Toute la logique de clonage est encapsulée dans `EnemyPrototype`.
 */
const cloneEnemy = <T extends EnemyPrototype>(enemy: T): T => {
  return enemy.clone();
};

const monster = new Monster();
monster.type = "BASIC";
monster.hp = 500;
monster.color = "red";
monster.weapon = new Weapon("AXE", 9001);

const monster2 = cloneEnemy(monster);

console.log(monster.type === monster2.type); // true
console.log(monster.hp === monster2.hp); // true
console.log(monster.weapon === monster2.weapon); // ✅ false, ce n'est pas la meme instance de Weapon
console.log(monster.isEnraged === monster2.isEnraged); // ✅ true

Conclusion

Nous avons vu ensemble, avec l'aide de Bob 🧔, que les design patterns créationnels pouvaient constituer une solution élégante et efficace aux problèmes rencontrés lors de la création de son jeu.

Ce ne sont cependant pas les seuls patrons qui pourraient s'avérer utiles. Nous verrons ainsi dans un prochain article les Patrons structurels et les Patrons comportementaux.