Les patrons comportementaux (2/2)
Faire varier les comportements : Observateur, État, Stratégie, Patron de méthode et Visiteur.
Les patrons comportementaux décrivent comment des objets se répartissent des responsabilités et communiquent. Dans ce second volet, cinq patrons abordent une même obsession sous des angles différents : faire varier un comportement sans réécrire le code qui l'utilise. L'observateur (Observer) propage des notifications, l'état (State) et la stratégie (Strategy) permutent des comportements à l'exécution, le patron de méthode (Template Method) fige un squelette d'algorithme tout en ouvrant ses étapes, et le visiteur (Visitor) ajoute des opérations à une hiérarchie de classes sans y toucher.
Chacun répond à une douleur concrète : un switch qui grossit, une classe qui double de taille à chaque fonctionnalité, des conditionnelles qui contaminent toutes les méthodes, ou un architecte qui refuse de laisser modifier des classes « précieuses et fragiles ». Nous suivons fidèlement les analogies du livre d'Alexander Shvets, en traduisant chaque pseudo-code en TypeScript minimal mais complet.
L'observateur (Observer)
Intention
L'observateur (Observer), aussi appelé Event-Subscriber ou Listener, définit un mécanisme d'abonnement permettant à plusieurs objets d'être notifiés automatiquement des événements qui surviennent sur l'objet qu'ils observent.
Le problème
Imaginez deux types d'objets : un client (Customer) et un magasin (Store). Le client guette un produit précis — disons un nouveau modèle d'iPhone — qui doit bientôt arriver en rayon. Il a deux mauvaises options. Soit il passe au magasin chaque jour pour vérifier la disponibilité : tant que le produit est en route, ces visites sont du temps perdu. Soit le magasin envoie un email à tous ses clients à chaque arrivage : cela épargne quelques déplacements, mais inonde de spam ceux que ce produit n'intéresse pas.
Nous voilà devant un conflit : soit le client gaspille son temps, soit le magasin gaspille ses ressources à notifier les mauvaises personnes.
La solution
L'objet qui détient un état intéressant et qui va notifier les autres s'appelle le sujet ou, ici, le publieur (publisher). Les objets qui veulent suivre ses changements sont les abonnés (subscribers). L'observateur suggère d'ajouter au publieur un mécanisme d'abonnement : 1) un tableau de références vers les abonnés, et 2) quelques méthodes publiques pour s'inscrire et se désinscrire. Quand un événement important survient, le publieur parcourt sa liste et appelle sur chaque abonné une méthode de notification commune.
Le point crucial : tous les abonnés implémentent la même interface, et le publieur ne communique avec eux que par celle-ci. Ainsi il ne dépend d'aucune classe concrète d'abonné — il peut même en exister qu'il ne connaît pas à l'avance.
Astuce
L'analogie du livre est l'abonnement à un magazine. Une fois abonné, vous n'allez plus au kiosque vérifier si le numéro est sorti : l'éditeur l'envoie directement dans votre boîte. Et vous pouvez vous désabonner quand bon vous semble.
// Interface commune à tous les abonnés.
interface Abonne {
actualiser(nomProduit: string): void;
}
// Le publieur gère la liste et notifie.
class Magasin {
private abonnes: Abonne[] = [];
abonner(a: Abonne): void {
this.abonnes.push(a);
}
desabonner(a: Abonne): void {
this.abonnes = this.abonnes.filter((x) => x !== a);
}
reapprovisionner(nomProduit: string): void {
// Logique métier... puis notification.
for (const a of this.abonnes) a.actualiser(nomProduit);
}
}
// Abonnés concrets : réactions différentes, même interface.
class ClientEmail implements Abonne {
constructor(private email: string) {}
actualiser(nomProduit: string): void {
console.log(`Email a ${this.email} : ${nomProduit} dispo !`);
}
}
class JournalReassort implements Abonne {
actualiser(nomProduit: string): void {
console.log(`[log] reassort de ${nomProduit}`);
}
}
// Le client câble publieur et abonnés à l'exécution.
const magasin = new Magasin();
magasin.abonner(new ClientEmail("alice@example.com"));
magasin.abonner(new JournalReassort());
magasin.reapprovisionner("iPhone 42"); Notez que actualiser reçoit ici une donnée de contexte (le nom du produit). Une variante consiste à passer le publieur lui-même en argument, laissant l'abonné aller chercher les données dont il a besoin.
Quand l'utiliser
- Quand un changement d'état d'un objet peut nécessiter d'en modifier d'autres, et que cet ensemble d'objets est inconnu à l'avance ou change dynamiquement.
- Quand certains objets ne doivent en observer d'autres que temporairement ou dans des cas précis : la liste d'abonnement étant dynamique, ils s'inscrivent et se retirent à volonté.
C'est le fondement du pub/sub et du modèle événementiel des interfaces graphiques : un bouton expose un mécanisme d'abonnement, et les clients y branchent leur propre code. Limite à connaître : les abonnés sont notifiés dans un ordre aléatoire, n'en dépendez pas.
L'état (State)
Intention
L'état (State) permet à un objet de modifier son comportement quand son état interne change ; tout se passe comme si l'objet changeait de classe.
Le problème
L'état est intimement lié à la notion de machine à états finis (finite-state machine) : à tout instant, le programme est dans l'un d'un nombre fini d'états, se comporte différemment dans chacun, et passe de l'un à l'autre selon des transitions prédéterminées. Prenez un document : il peut être en Brouillon, en Modération ou Publié. Sa méthode publier agit différemment selon l'état — elle envoie le brouillon en modération, publie le document en modération uniquement si l'utilisateur est administrateur, et ne fait rien sur un document déjà publié.
On implémente d'ordinaire cela avec d'énormes switch qui aiguillent le comportement selon un champ state. Le piège se referme dès qu'on ajoute des états et des comportements : chaque méthode finit truffée de conditionnelles monstrueuses, et toute modification de la logique de transition oblige à reprendre le switch de toutes les méthodes. La machine à états « propre » se transforme en bouillie ingérable.
La solution
L'état suggère de créer une classe par état et d'y extraire tout le comportement spécifique. L'objet d'origine, appelé contexte, garde une référence vers un objet-état représentant son état courant et lui délègue tout le travail. Pour changer d'état, on remplace l'objet-état actif par un autre. Cela n'est possible que si toutes les classes d'état suivent la même interface et que le contexte travaille à travers elle.
Note
Analogie du livre : les boutons de votre smartphone se comportent différemment selon l'état de l'appareil. Téléphone déverrouillé, un appui lance une fonction ; verrouillé, le même appui ouvre l'écran de déverrouillage ; batterie faible, il affiche l'écran de charge.
// Interface commune à tous les états du lecteur.
interface EtatLecteur {
cliquerLecture(): void;
cliquerVerrou(): void;
}
// Le contexte délègue à l'état courant.
class LecteurAudio {
private etat: EtatLecteur;
constructor() {
this.etat = new EtatPret(this);
}
changerEtat(etat: EtatLecteur): void {
this.etat = etat;
}
// L'UI délègue à l'état actif.
cliquerLecture(): void {
this.etat.cliquerLecture();
}
cliquerVerrou(): void {
this.etat.cliquerVerrou();
}
// Services appelés par les états.
demarrer(): void {
console.log("lecture...");
}
arreter(): void {
console.log("pause.");
}
}
class EtatPret implements EtatLecteur {
constructor(private lecteur: LecteurAudio) {}
cliquerLecture(): void {
this.lecteur.demarrer();
this.lecteur.changerEtat(new EtatLecture(this.lecteur));
}
cliquerVerrou(): void {
this.lecteur.changerEtat(new EtatVerrouille(this.lecteur));
}
}
class EtatLecture implements EtatLecteur {
constructor(private lecteur: LecteurAudio) {}
cliquerLecture(): void {
this.lecteur.arreter();
this.lecteur.changerEtat(new EtatPret(this.lecteur));
}
cliquerVerrou(): void {
this.lecteur.changerEtat(new EtatVerrouille(this.lecteur));
}
}
class EtatVerrouille implements EtatLecteur {
constructor(private lecteur: LecteurAudio) {}
cliquerLecture(): void {
/* verrouillé : ne rien faire */
}
cliquerVerrou(): void {
this.lecteur.changerEtat(new EtatPret(this.lecteur));
}
} Chaque état détient une rétroréférence au contexte, ce qui lui permet de lire ses données et surtout de déclencher lui-même les transitions — EtatPret bascule vers EtatLecture, etc.
Quand l'utiliser
- Quand un objet se comporte différemment selon son état, que le nombre d'états est grand et que le code dépendant de l'état change souvent.
- Quand une classe est polluée par d'énormes conditionnelles qui modifient son comportement selon ses champs.
- Quand vous avez beaucoup de code dupliqué entre états et transitions d'une machine à états à base de
if.
Limite : appliquer l'état est de la sur-ingénierie si la machine n'a que quelques états ou change rarement.
La stratégie (Strategy)
Intention
La stratégie (Strategy) définit une famille d'algorithmes, place chacun dans sa propre classe et les rend interchangeables à l'exécution.
Le problème
Vous créez une appli de navigation. La première version ne trace des itinéraires que par la route ; les automobilistes sont ravis. Puis vous ajoutez les trajets à pied, puis en transports en commun, et vous prévoyez le vélo et les circuits touristiques. Côté business c'est un succès, côté technique un cauchemar : à chaque algorithme ajouté, la classe principale du navigateur double de volume. Le moindre correctif sur un algorithme menace tout le reste, et l'équipe passe son temps à résoudre des conflits de fusion sur cette même classe géante.
La solution
La stratégie suggère d'extraire chaque algorithme dans sa propre classe, appelée stratégie. Le contexte (le navigateur) ne garde qu'une référence vers une stratégie et lui délègue le travail au lieu de l'exécuter lui-même. Surtout, le contexte ne choisit pas l'algorithme : c'est le client qui lui injecte la stratégie voulue. Le contexte ne connaît les stratégies qu'à travers une interface générique exposant une unique méthode de déclenchement.
Astuce
Analogie : pour aller à l'aéroport, vous pouvez prendre le bus, un taxi ou votre vélo. Ce sont vos stratégies de transport, choisies selon votre budget ou votre temps. La destination ne change pas ; la manière d'y parvenir, si.
// Interface commune à toutes les stratégies.
interface StrategieItineraire {
construire(depart: string, arrivee: string): string[];
}
class ItineraireVoiture implements StrategieItineraire {
construire(depart: string, arrivee: string): string[] {
return [depart, "autoroute A1", arrivee];
}
}
class ItineraireMarche implements StrategieItineraire {
construire(depart: string, arrivee: string): string[] {
return [depart, "sentier", "parc", arrivee];
}
}
class ItineraireTransports implements StrategieItineraire {
construire(depart: string, arrivee: string): string[] {
return [depart, "metro L4", "bus 38", arrivee];
}
}
// Le contexte délègue, sans connaître la classe concrète.
class Navigateur {
constructor(private strategie: StrategieItineraire) {}
definirStrategie(s: StrategieItineraire): void {
this.strategie = s;
}
tracer(depart: string, arrivee: string): string[] {
return this.strategie.construire(depart, arrivee);
}
}
// Le client choisit la stratégie à l'exécution.
const nav = new Navigateur(new ItineraireVoiture());
nav.tracer("Gare", "Aeroport");
nav.definirStrategie(new ItineraireTransports());
nav.tracer("Gare", "Aeroport"); Quand l'utiliser
- Quand vous voulez différentes variantes d'un algorithme dans un objet et pouvoir basculer de l'une à l'autre à l'exécution.
- Quand vous avez de nombreuses classes voisines qui ne diffèrent que par la façon d'exécuter un comportement.
- Quand un gros
switchaiguille entre variantes d'un même algorithme : la stratégie le fait disparaître.
Piège courant
Si vous n'avez que deux algorithmes qui changent rarement, inutile d'alourdir le code avec ces classes et interfaces. Par ailleurs, le client doit connaître les différences entre stratégies pour choisir la bonne. Dans un langage à fonctions de première classe comme TypeScript, de simples fonctions peuvent parfois remplacer les classes-stratégies.
Le patron de méthode (Template Method)
Intention
Le patron de méthode (Template Method) définit le squelette d'un algorithme dans une méthode de la classe de base, en laissant les sous-classes redéfinir certaines étapes sans altérer la structure d'ensemble.
Le problème
Vous écrivez une application de data mining qui extrait des données de documents en divers formats (PDF, DOC, CSV) et les restitue uniformément. La première version ne gère que le DOC, puis vient le CSV, puis le PDF. Vous constatez alors que les trois classes partagent énormément de code : si l'ouverture et l'analyse brute diffèrent selon le format, le traitement et l'analyse des données sont quasi identiques. Pire, le code client est truffé de conditionnelles pour choisir la bonne classe de traitement.
La solution
Le patron de méthode suggère de découper l'algorithme en une série d'étapes, d'en faire des méthodes, et de placer leurs appels dans une unique méthode patron. Les étapes sont soit abstraites (chaque sous-classe doit les fournir), soit dotées d'une implémentation par défaut que l'on peut redéfinir. Les sous-classes redéfinissent les étapes, jamais la méthode patron. Les étapes communes — analyse des données, composition du rapport — remontent dans la classe de base et sont partagées.
Note
Il existe un troisième type d'étape, le hook : une étape optionnelle à corps vide, placée avant ou après une étape cruciale, qui offre aux sous-classes des points d'extension supplémentaires sans les y obliger.
// La classe abstraite définit le squelette : la methode patron.
abstract class ExtracteurDonnees {
// Methode patron : NON redéfinissable par les sous-classes.
extraire(chemin: string): string {
const brut = this.ouvrir(chemin);
const donnees = this.analyser(brut);
const rapport = this.composerRapport(donnees); // partagé
this.fermer();
return rapport;
}
// Étapes abstraites : propres à chaque format.
protected abstract ouvrir(chemin: string): string;
protected abstract analyser(brut: string): string[];
// Étape commune, remontée dans la base.
protected composerRapport(donnees: string[]): string {
return `Rapport (${donnees.length} lignes)`;
}
// Hook optionnel, vide par défaut.
protected fermer(): void {}
}
class ExtracteurCsv extends ExtracteurDonnees {
protected ouvrir(chemin: string): string {
return `contenu CSV de ${chemin}`;
}
protected analyser(brut: string): string[] {
return brut.split(",");
}
}
class ExtracteurPdf extends ExtracteurDonnees {
protected ouvrir(chemin: string): string {
return `contenu PDF de ${chemin}`;
}
protected analyser(brut: string): string[] {
return brut.split(" ");
}
// Redéfinit le hook pour libérer une ressource.
protected fermer(): void {
console.log("flux PDF fermé");
}
}
const csv = new ExtracteurCsv();
console.log(csv.extraire("data.csv")); Quand l'utiliser
- Quand vous voulez que les clients n'étendent que certaines étapes d'un algorithme, pas sa structure globale.
- Quand plusieurs classes contiennent des algorithmes quasi identiques aux différences mineures : transformez l'algorithme en méthode patron et remontez les étapes communes dans la superclasse.
À retenir
Le patron de méthode repose sur l'héritage et agit au niveau de la classe : il est statique. La stratégie repose sur la composition et agit au niveau de l'objet : elle permute les comportements à l'exécution. Pièges : certains clients peuvent se sentir bridés par le squelette imposé, et supprimer une étape par défaut dans une sous-classe risque de violer le principe de substitution de Liskov.
Le visiteur (Visitor)
Intention
Le visiteur (Visitor) sépare des algorithmes des objets sur lesquels ils opèrent, ce qui permet d'ajouter de nouvelles opérations sans modifier les classes de ces objets.
Le problème
Votre équipe développe une application qui manipule des données géographiques sous forme d'un immense graphe. Chaque nœud — une ville, une industrie, un site touristique — possède sa propre classe. On vous demande d'exporter le graphe en XML. La solution évidente serait d'ajouter une méthode exporter à chaque classe de nœud. Mais l'architecte refuse : le code est en production, il ne veut pas risquer un bug dans ces classes. De plus, l'export XML n'a rien à faire dans des classes dont le métier est la géodonnée. Et demain, le marketing voudra exporter dans un autre format, forçant à toucher de nouveau à ces classes fragiles.
La solution
Le visiteur place le nouveau comportement dans une classe visiteur séparée. L'objet à traiter est passé en argument à l'une des méthodes du visiteur, qui définit une méthode par classe d'élément (visiterVille, visiterIndustrie…). Mais comment appeler la bonne méthode quand on parcourt un graphe d'objets de classes différentes ? La surcharge ne suffit pas : la classe exacte d'un nœud étant inconnue à la compilation, elle se rabattrait sur la méthode du type de base.
La parade est le double dispatch (double répartition). Au lieu de laisser le client choisir la méthode, on délègue ce choix aux objets visités. Chaque élément implémente une méthode accepter(visiteur) qui rappelle la méthode du visiteur correspondant à sa propre classe. L'objet connaît sa classe, il sait donc quelle méthode invoquer.
Astuce
Analogie : un agent d'assurance chevronné visite chaque bâtiment d'un quartier. Selon le type d'occupant, il propose une police adaptée — assurance santé pour un immeuble résidentiel, assurance vol pour une banque, assurance incendie pour un café. Un même « visiteur », des comportements taillés par type d'élément.
// Le visiteur déclare une méthode par classe d'élément.
interface Visiteur {
visiterPoint(p: Point): string;
visiterCercle(c: Cercle): string;
}
// Les éléments acceptent un visiteur.
interface Forme {
accepter(v: Visiteur): string;
}
class Point implements Forme {
constructor(public x: number, public y: number) {}
// Redirige vers la méthode qui correspond a SA classe.
accepter(v: Visiteur): string {
return v.visiterPoint(this);
}
}
class Cercle implements Forme {
constructor(public x: number, public y: number, public r: number) {}
accepter(v: Visiteur): string {
return v.visiterCercle(this);
}
}
// Un nouvel algorithme = une nouvelle classe, sans toucher
// aux formes existantes.
class ExportXml implements Visiteur {
visiterPoint(p: Point): string {
return `<point x="${p.x}" y="${p.y}"/>`;
}
visiterCercle(c: Cercle): string {
return `<cercle x="${c.x}" y="${c.y}" r="${c.r}"/>`;
}
}
// Le client opère sur le graphe sans connaître les classes.
const formes: Forme[] = [new Point(1, 2), new Cercle(0, 0, 5)];
const exporteur = new ExportXml();
for (const f of formes) console.log(f.accepter(exporteur)); Pour ajouter un export JSON, il suffit d'écrire une nouvelle classe ExportJson implements Visiteur : aucune classe de forme n'est modifiée.
Quand l'utiliser
- Quand vous devez exécuter une opération sur tous les éléments d'une structure complexe (un arbre d'objets, par exemple un composite).
- Quand vous voulez nettoyer la logique métier de comportements auxiliaires, en gardant les classes principales centrées sur leur rôle.
- Quand un comportement n'a de sens que pour certaines classes d'une hiérarchie : laissez les autres méthodes de visite vides.
Attention
Le visiteur a un coût d'évolution : chaque ajout ou retrait d'une classe d'élément oblige à mettre à jour tous les visiteurs. Et un visiteur peut manquer d'accès aux champs privés des éléments qu'il traite. Réservez-le aux hiérarchies d'éléments stables sur lesquelles les opérations, elles, se multiplient.
État ou stratégie ? Deux jumeaux, deux intentions
Ces deux patrons partagent une structure quasi identique — un contexte qui délègue à des objets auxiliaires interchangeables via une interface commune — au point qu'on peut voir l'état comme une extension de la stratégie. La différence est dans l'intention, et un patron n'est pas qu'une recette : il communique aux autres développeurs le problème qu'il résout.
À retenir
Dans la stratégie, les algorithmes sont totalement indépendants et ignorants les uns des autres : c'est le client qui décide lequel utiliser. Dans l'état, les états concrets se connaissent et déclenchent eux-mêmes les transitions vers d'autres états. Autrement dit : la stratégie décrit plusieurs façons de faire la même chose ; l'état décrit les phases successives d'un même objet.
Tableau récapitulatif
| Patron | Intention | Quand l'utiliser |
|---|---|---|
| Observateur | Notifier automatiquement un ensemble dynamique d'abonnés des événements d'un sujet. | Un changement d'état doit en propager d'autres, dont l'ensemble est inconnu ou variable (pub/sub, événements UI). |
| État | Changer le comportement d'un objet selon son état interne, comme s'il changeait de classe. | Comportement très dépendant de l'état, nombreux états, conditionnelles envahissantes. |
| Stratégie | Encapsuler une famille d'algorithmes interchangeables et permutables à l'exécution. | Variantes d'un algorithme à choisir au runtime ; remplacer un gros switch. |
| Patron de méthode | Figer le squelette d'un algorithme dans la base, ouvrir certaines étapes aux sous-classes. | Algorithmes quasi identiques aux étapes variables ; factoriser la duplication par héritage. |
| Visiteur | Séparer des algorithmes des objets visités pour ajouter des opérations sans modifier ces classes. | Hiérarchie d'éléments stable, opérations nombreuses et évolutives (export, parcours d'arbre). |
À retenir
- Observateur : un publieur, des abonnés, une interface de notification commune — le socle du pub/sub. La liste est dynamique, l'ordre de notification ne l'est pas (aléatoire).
- État : une classe par état, le contexte délègue et les états déclenchent leurs propres transitions ; idéal contre les
switchd'états qui contaminent toutes les méthodes. - Stratégie : une famille d'algorithmes interchangeables, choisis par le client à l'exécution ; le contexte ne sait pas lequel il exécute.
- État vs Stratégie : structures jumelles, intentions distinctes — les stratégies s'ignorent, les états se connaissent et transitent entre eux.
- Patron de méthode : squelette figé dans la base (héritage, statique), étapes redéfinissables et hooks dans les sous-classes ; ne jamais redéfinir la méthode patron.
- Visiteur : double dispatch via
accepter, pour ajouter des opérations sans toucher aux classes ; réservé aux hiérarchies stables, car tout nouvel élément impose de mettre à jour chaque visiteur.