Un design pattern, ou patron de conception, est une solution éprouvée à un problème de conception qui revient d'un projet à l'autre, avec un nom que les développeurs partagent. La méthode pour t'en servir : repère ce qui varie dans ton code, isole-le derrière une interface (en Java, un type qui annonce des méthodes sans les coder) et fais travailler le reste avec cette interface.
Exemple : au lieu d'un if qui teste le mode de paiement dans ta classe Commande, chaque mode devient une classe qui implémente l'interface Paiement, et ajouter un mode ne touche plus Commande. C'est le pattern Strategy.
Ce qu'est un design pattern
Le livre de référence est Design Patterns: Elements of Reusable Object-Oriented Software, d'Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides, publié en 1994 (fiche de l'éditeur). Ses quatre auteurs sont surnommés le « Gang of Four », d'où le sigle GoF, et le livre décrit 23 patterns.
Définition : selon le GoF, un pattern a quatre éléments essentiels : un nom, le problème qu'il règle, la solution (une organisation de classes et d'objets, pas du code à copier) et les conséquences de ce choix, avantages comme inconvénients.
Les deux principes de base
Le livre pose deux principes dès son introduction, qu'Erich Gamma détaille dans un entretien publié en 2005.
Règles :
1. Programme pour une interface, pas pour une implémentation.
2. Préfère la composition d'objets à l'héritage de classes.
Le premier veut dire : déclare tes variables avec le type le plus général qui suffit, pas avec la classe concrète qui l'implémente. Écris List<String> noms = new ArrayList<>(); plutôt que ArrayList<String> noms. Si tu passes un jour à LinkedList, une seule ligne change.
Le second vient d'un défaut de l'héritage : une sous-classe dépend des détails de sa classe mère, qui peut la casser en changeant. La composition confie une partie du travail à un objet rangé dans un attribut, que tu peux remplacer, même pendant l'exécution, sans toucher à la classe qui s'en sert.
Les trois familles
Le GoF classe ses 23 patterns selon la question qu'ils règlent.
| Famille | Question réglée | Patterns |
|---|---|---|
| Création | Créer des objets sans figer leur classe concrète | Abstract Factory, Builder, Factory Method, Prototype, Singleton |
| Structure | Assembler classes et objets en ensembles plus grands | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy |
| Comportement | Répartir le travail entre objets et les faire communiquer | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor |
Les parties suivantes en détaillent six.
Strategy : un algorithme interchangeable
Le problème : un calcul varie (mode de paiement, tarif) et tu ne veux pas d'un if qui grossit à chaque nouveau cas. La solution : chaque variante devient une classe qui implémente la même interface, et la classe qui s'en sert, appelée le contexte, ne connaît que cette interface.
interface Paiement { void payer(int centimes); }
class Carte implements Paiement {
public void payer(int centimes) { /* débit de la carte */ }
}
class Commande {
private final Paiement paiement;
Commande(Paiement p) { paiement = p; }
void regler(int total) { paiement.payer(total); }
}
new Commande(new Carte()).regler(2490) paie 24,90 € par carte. Pour ajouter le virement, tu écris une classe Virement implements Paiement, et Commande ne bouge pas : c'est le principe ouvert-fermé (ouvert à l'extension, fermé à la modification).
En Java, liste.sort(comparateur) suit ce pattern : le Comparator reçu en paramètre est la stratégie de comparaison.
Piège classique : le pattern State a le même diagramme de classes que Strategy. Seule l'intention change : avec Strategy, le code extérieur choisit l'algorithme ; avec State, l'objet change lui-même de comportement quand son état interne change.
Observer : prévenir sans connaître
Le problème : quand un objet change, plusieurs autres doivent réagir, sans qu'il connaisse leur classe. Exemple : un capteur de température suivi par un écran et une alarme.
La solution : le capteur, appelé le sujet, garde une liste d'abonnés qui implémentent une interface Observateur avec une méthode actualiser(double t). À chaque mesure, il parcourt la liste et appelle actualiser sur chacun. Brancher un troisième abonné ne change rien au capteur. Les écouteurs d'événements (listeners) des interfaces graphiques fonctionnent ainsi.
Factory et Singleton : créer les objets
Écrire new Orque() partout attache ton code à la classe Orque. Une fabrique regroupe ce choix en un seul endroit : une méthode qui renvoie le type général Ennemi et choisit seule la classe concrète, Orque ou Dragon. Cette fabrique simple ne fait pas partie des 23. Le GoF décrit Factory Method, où une sous-classe décide quelle classe instancier, et Abstract Factory, qui crée des familles d'objets faits pour aller ensemble.
Le Singleton garantit qu'une classe n'a qu'une seule instance (un seul objet) et offre un point d'accès global à celle-ci. Le constructeur privé interdit tout new Configuration() hors de la classe :
public final class Configuration {
private static final Configuration INSTANCE = new Configuration();
private Configuration() { }
public static Configuration getInstance() { return INSTANCE; }
}
Piège classique : abuser du Singleton. C'est un état global : toute classe peut l'atteindre sans que ça se voie dans ses paramètres, et tes tests ne peuvent plus le remplacer par un faux objet. Erich Gamma s'est dit favorable à son retrait du catalogue, son usage étant selon lui presque toujours le signe d'un défaut de conception (entretien de 2009). Crée plutôt l'objet une fois et passe-le en paramètre.
Decorator et Adapter : envelopper un objet
Decorator ajoute une responsabilité à un objet sans sous-classe. Le décorateur implémente la même interface que l'objet qu'il enveloppe, lui transmet chaque appel et ajoute son travail ; ayant le même type, les décorateurs s'empilent. Dans new BufferedReader(new FileReader("notes.txt")), le BufferedReader est un Reader qui en enveloppe un autre pour lui ajouter une mémoire tampon (le fichier est lu par blocs).
Adapter sert quand une classe existante fait ce dont tu as besoin, mais avec une autre interface que celle que ton code attend. Tu écris une petite classe qui implémente l'interface attendue et traduit chaque appel vers la classe existante.
Piège classique : les deux enveloppent un objet, d'où la confusion. Decorator garde l'interface et ajoute du comportement ; Adapter change l'interface sans rien ajouter.
Exercices corrigés
Exercice 1 : Quel pattern convient, et dans quelle famille ?
a) Une station météo doit prévenir trois écrans à chaque nouvelle mesure.
b) Ton code attend une interface Localisable avec position(), mais la bibliothèque que tu utilises, et que tu ne peux pas modifier, fournit getPos().
c) Une boutique propose trois modes de livraison, chacun avec son propre calcul de frais.
Correction :
a) Observer, famille comportement : la station est le sujet, les écrans s'abonnent.
b) Adapter, famille structure : une classe implémente Localisable, et sa méthode position() appelle getPos().
c) Strategy, famille comportement : une classe par mode derrière une interface commune.
Exercice 2 : Un distributeur de boissons a trois états : en attente, pièce insérée, en panne. Chaque état est une classe qui implémente une interface Etat, et le distributeur passe lui-même d'un état à l'autre quand on insère une pièce ou qu'une panne survient. Un camarade affirme : « Même diagramme que Strategy, donc c'est Strategy. » A-t-il raison ?
Correction :
Non, c'est State. Le diagramme est bien celui de Strategy, mais personne à l'extérieur ne choisit le comportement : le distributeur change lui-même d'état, et son comportement suit. Avec Strategy, c'est le code qui crée le contexte qui choisit l'algorithme, et celui-ci ne change pas tout seul. Pour trancher, demande-toi qui décide du changement.
Exercice 3 : L'interface Boisson déclare int prix(), en centimes. Expresso l'implémente et renvoie 150. Les décorateurs Lait et Caramel l'implémentent aussi, gardent la boisson enveloppée dans un attribut base, et leur prix() renvoie base.prix() + 30 pour Lait, base.prix() + 50 pour Caramel.
1. Que renvoie new Caramel(new Lait(new Lait(new Expresso()))).prix() ? Donne le prix en euros.
2. En créant une sous-classe par combinaison, combien de classes faut-il pour un expresso avec ou sans chacun de 4 suppléments ? Et avec Decorator ?
Correction :
1. Le prix() de chaque décorateur appelle celui de la boisson qu'il enveloppe, jusqu'à l'expresso, puis les résultats remontent : $150 + 30 + 30 + 50 = 260$. L'appel renvoie 260 centimes, soit 2,60 €.
2. Chaque supplément est présent ou absent : $2^4 = 16$ combinaisons, donc 16 classes, expresso nature compris. Avec Decorator, il en faut $1 + 4 = 5$ : Expresso et une classe par supplément. Le double lait ne demande aucune classe de plus.
À retenir
À retenir :
- Un pattern : un nom, un problème, une solution, des conséquences.
- Programme pour une interface ; préfère la composition à l'héritage.
- Strategy et State : même diagramme, intentions différentes.
- Observer prévient des abonnés sans connaître leur classe.
- Decorator ajoute du comportement en gardant l'interface ; Adapter change l'interface.
- Singleton : un état global, à éviter quand un paramètre suffit.
Sources
- Gamma, Helm, Johnson et Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1994 : fiche de l'éditeur.
- Bill Venners, entretien avec Erich Gamma, Artima, 2005 : lire l'entretien.
- Larry O'Brien, entretien avec Erich Gamma, Richard Helm et Ralph Johnson, InformIT, 2009 : lire l'entretien.