Les design patterns
Ce que ce chapitre apporte
- Définir ce qu'est un design pattern, et ce qu'il n'est pas.
- Nommer les trois familles et savoir y ranger un pattern donné.
- Lire un diagramme de classes de pattern et retrouver le rôle de chaque participant.
- Implémenter Stratégie, Observateur, Fabrique, Singleton et Pont.
- Justifier l'emploi d'un pattern par le couplage qu'il supprime.
- Distinguer Stratégie de Pont, que l'on confond presque toujours.
- Reconnaître les cas où un pattern coûte plus qu'il ne rapporte.
Ce qu'est un design pattern
Un design pattern, ou patron de conception, est une solution générale et réutilisable à un problème de conception qui revient fréquemment. Il décrit une organisation de classes et de responsabilités, pas un code à recopier.
Le terme vient de l'architecture, où Christopher Alexander avait catalogué des situations récurrentes de construction. Quatre auteurs, connus comme le Gang of Four, ont transposé l'idée au logiciel en 1994 et décrit vingt-trois patterns. Ce catalogue reste la référence, et la plupart des bibliothèques que vous utiliserez en portent la trace jusque dans le nom de leurs classes.
Un pattern ne s'installe pas comme une dépendance. Il se reconnaît dans une situation, puis s'adapte. Deux implémentations du même pattern dans deux projets ne se ressemblent pas forcément, et c'est normal : ce qui est commun est la structure des responsabilités, pas les lignes de code.
C'est aussi ce qui rend la relecture possible : un lecteur qui reconnaît le pattern n'a plus à comprendre le mécanisme, seulement à vérifier qu'il est correctement appliqué.
La fiche d'un pattern
Un pattern se décrit toujours par les mêmes rubriques, et c'est ce qui permet de les comparer.
| Rubrique | Question à laquelle elle répond |
|---|---|
| Intention | quel problème de conception le pattern résout-il ? |
| Participants | quelles classes, avec quel rôle ? |
| Structure | comment sont-elles reliées ? |
| Collaboration | dans quel ordre s'appellent-elles à l'exécution ? |
| Conséquences | qu'est-ce que cela coûte, et qu'est-ce que cela rend possible ? |
Ce coût se justifie quand le problème est réellement présent. Appliquer un pattern parce qu'on vient de l'apprendre produit une conception plus difficile à lire que le code direct qu'elle remplace. La question à se poser reste : quel couplage est-ce que je supprime, et est-ce qu'il me gêne aujourd'hui ?
Les trois familles
| Famille | Ce dont elle s'occupe | Patterns du catalogue |
|---|---|---|
| Création | comment les objets sont instanciés | Singleton, Fabrique, Fabrique abstraite, Monteur, Prototype |
| Structure | comment les objets se composent | Pont, Adaptateur, Décorateur, Façade, Composite, Proxy |
| Comportement | comment les objets communiquent | Stratégie, Observateur, Commande, Itérateur, État, Visiteur |
Le classement se retrouve à partir d'une seule question : le pattern parle-t-il de la naissance des objets, de leur assemblage, ou de leurs échanges ?
Le principe commun
Presque tous les patterns appliquent la même idée : dépendre d'une abstraction plutôt que d'une implémentation. C'est le cinquième principe SOLID, l'inversion des dépendances, vu au premier chapitre.
Regardez ce que fait ce code, et surtout ce qu'il empêche.
Trois défauts, et ils se cumulent. Ajouter un mode de livraison oblige à modifier Facture, alors qu'elle n'a rien à voir avec la question. Tester la tarification express demande de construire une facture entière. Et le jour où le calcul express dépend du jour de la semaine, la méthode se met à grossir dans une classe qui ne parle pas de tarifs.
Le pattern Stratégie règle exactement cela.
Stratégie
Définir une famille d'algorithmes, les encapsuler chacun dans une classe, et les rendre interchangeables. L'algorithme varie indépendamment du code qui l'utilise.
Les rôles : Facture est le contexte, il détient une référence vers l'abstraction et ne connaît rien d'autre. IFraisLivraison est la stratégie, le contrat commun. Les trois classes concrètes sont les stratégies concrètes.
Ce que le changement apporte : ajouter un mode de livraison n'oblige plus à toucher Facture, on écrit une classe de plus. Tester FraisExpress demande trois lignes et aucune facture. Et le choix du mode peut désormais venir d'une configuration, d'une base de données ou d'un test, puisqu'il se fait au moment de la construction.
"express". La différence est qu'il est seul, à la frontière du système, là où la donnée arrive, au lieu d'être disséminé dans chaque classe qui a besoin du calcul.Ce point de décision unique est très souvent une fabrique, le pattern suivant.
À l'exécution, la collaboration est d'une simplicité qui vaut d'être vue : le contexte ne fait que déléguer.
Le même pattern en Python
Un objet-stratégie qui n'a qu'une méthode est, dans un langage où les fonctions sont des valeurs, simplement une fonction. Le pattern ne disparaît pas, il s'écrit plus court.
Si les variantes ont besoin de données différentes, le contrat commun devient un sac de paramètres facultatifs, et la signature ment sur ce que chaque implémentation utilise vraiment. C'est le signe que le découpage n'est pas au bon endroit.
Observateur
Définir une dépendance de un vers plusieurs entre objets, de sorte que lorsque l'un change d'état, tous ceux qui en dépendent en soient avertis, sans qu'il ait à les connaître.
Le problème se voit bien sur un capteur. Une température est relevée, et trois choses doivent en découler : l'affichage se met à jour, le journal enregistre, une alerte part au-delà d'un seuil. Écrit naïvement, le capteur appelle les trois, donc il en dépend, donc ajouter un quatrième usage revient à modifier le capteur. Et le capteur, qui devrait ne parler que de mesure, se met à connaître l'interface graphique.
Le sujet est Capteur : il tient la liste des abonnés et les prévient. Les observateurs sont les trois classes concrètes. Le sens de la dépendance est l'essentiel du pattern : ce sont les observateurs qui connaissent le sujet, jamais l'inverse.
En .NET, le pattern est intégré au langage sous la forme des événements, qui en sont exactement la structure : event déclare la liste d'abonnés, += abonne, -= désabonne.
L'exception dans un observateur. Si le troisième abonné lève une exception, les suivants ne sont jamais appelés. Un sujet robuste protège chaque notification.
L'ordre supposé. Rien ne garantit l'ordre des notifications, et un code qui en dépend fonctionnera jusqu'au jour où l'on ajoutera un abonné.
Le mécanisme tient en peu de lignes, et l'écrire une fois suffit à ne plus jamais le confondre avec autre chose.
Fabrique
Déléguer la création d'un objet à un code dédié, pour que le client n'ait pas à connaître la classe concrète qu'il obtient.
Le terme recouvre trois choses distinctes, souvent confondues, et il vaut mieux les nommer correctement.
| Nom | Ce que c'est | Quand |
|---|---|---|
| Fabrique simple | une méthode qui, selon une donnée, retourne l'implémentation adéquate | le cas courant |
| Fabrique (méthode) | une méthode d'instance redéfinie par les sous-classes, chacune créant son produit | quand la création dépend de la sous-classe |
| Fabrique abstraite | un objet qui crée une famille cohérente de produits | quand plusieurs produits doivent aller ensemble |
La fabrique simple, la plus employée, est ce point de décision unique évoqué plus haut.
Le test sur la chaîne existe toujours, mais il est seul, à un endroit qu'on sait désigner, et c'est le seul fichier à modifier pour ajouter un mode. Le reste du programme ne manipule que IFraisLivraison.
Si le type est connu à l'écriture du code,
new suffit. Une fabrique qui n'a qu'un cas est une indirection gratuite.
La fabrique abstraite répond à un autre besoin : garantir la cohérence d'une famille. Une application qui doit produire ses documents entièrement en PDF, ou entièrement en HTML, sans jamais mélanger un en-tête PDF avec un tableau HTML, s'en remet à un objet qui crée les deux.
Singleton
Garantir qu'une classe ne possède qu'une seule instance, et fournir un point d'accès à celle-ci.
La structure repose sur trois éléments : un constructeur privé, qui interdit le new depuis l'extérieur, un champ statique qui détient l'unique instance, et une propriété statique qui la retourne.
Les solutions correctes sont un champ
static readonly initialisé à la déclaration, ou Lazy<T> comme ci-dessus. Le verrouillage à double vérification, longtemps recommandé, est difficile à écrire correctement et n'apporte plus rien.
C'est le pattern le plus connu et le plus critiqué, et il faut savoir pourquoi.
Un singleton est un état global. Tout code peut l'atteindre sans le déclarer, donc les dépendances d'une classe ne se lisent plus dans sa signature : un constructeur qui ne prend rien peut dépendre de six singletons. Il rend aussi les tests difficiles, puisqu'on ne peut pas lui substituer une version d'essai, et que l'instance survit d'un test à l'autre avec l'état laissé par le précédent.
On obtient l'unicité en construisant l'objet une fois au démarrage et en le passant à ceux qui en ont besoin. C'est ce que fait un conteneur d'injection de dépendances avec un cycle de vie singleton : une instance unique, mais déclarée dans les constructeurs, donc visible et remplaçable en test.
Le singleton classique reste défendable pour un accès à une ressource matérielle unique, ou pour un objet sans état comme un journal.
Pont
Découpler une abstraction de son implémentation, afin que les deux puissent varier indépendamment.
Le problème est celui de la multiplication des sous-classes. Une application produit des rapports, qui existent en deux formes, résumé et détaillé, et doivent partir vers trois destinations, écran, PDF, courriel. Par l'héritage seul, il faut six classes : ResumeEcran, ResumePdf, ResumeCourriel, DetailEcran, et ainsi de suite. Un quatrième canal en ajoute deux, un troisième type de rapport en ajoute quatre. La formule est en n × m, et le code de rendu se retrouve dupliqué dans chaque branche.
Le pont sépare les deux axes et les relie par une référence.
On passe de n × m classes à n + m. Ajouter un canal coûte une classe, et aucun rapport ne change.
Stratégie fait varier un algorithme, souvent interchangeable en cours d'exécution, et le contexte n'a en général pas de hiérarchie propre.
Pont fait varier toute une hiérarchie d'abstraction en parallèle d'une hiérarchie d'implémentation. Il y a des sous-classes des deux côtés, et le lien est fixé à la construction.
Le test qui tranche : s'il n'y a qu'une seule classe du côté gauche, c'est une stratégie ; s'il y a deux hiérarchies qui grossiraient l'une par l'autre, c'est un pont.
Trois autres que vous croiserez
Adaptateur (structure). Faire collaborer deux interfaces incompatibles en intercalant une classe qui traduit l'une dans l'autre. C'est le pattern qu'on écrit sans le savoir chaque fois qu'on enveloppe une bibliothèque externe pour ne pas en dépendre partout. Il ne change aucun comportement, il change une signature.
Décorateur (structure). Ajouter une responsabilité à un objet en l'enveloppant dans un autre qui implémente la même interface et délègue. Les flux de .NET en sont l'exemple canonique : un GZipStream enveloppe un FileStream, un BufferedStream enveloppe le tout, et chacun ignore les autres. À la différence de l'héritage, les décorations se composent à l'exécution et dans n'importe quel ordre.
Commande (comportement). Transformer une demande en objet, avec ses paramètres. Ce qui semble une complication gratuite devient évident dès qu'on veut une file d'attente, une exécution différée, un journal des actions, ou une annulation : un objet se stocke, se rejoue et sait s'inverser, ce qu'un appel de méthode ne sait pas faire.
Reconnaître le pattern qui manque
Un pattern se choisit à partir d'un symptôme dans le code, jamais à partir d'une envie.
| Symptôme dans le code | Pattern à envisager |
|---|---|
Un switch sur un type qui grossit à chaque nouveau cas | Stratégie, ou Fabrique |
| Une classe qui appelle explicitement trois autres pour les prévenir | Observateur |
Des new de classes concrètes disséminés dans tout le programme | Fabrique |
Des sous-classes dont le nom combine deux axes (ResumePdf) | Pont |
| Une bibliothèque externe dont le nom apparaît dans vingt fichiers | Adaptateur |
| Des options empilées par des booléens dans un constructeur | Décorateur |
| Un besoin d'annuler, de rejouer ou de mettre en file | Commande |
Le résultat est un code où chaque appel demande d'ouvrir trois fichiers pour savoir ce qui s'exécute, sans qu'aucune souplesse ait jamais servi. Le remède est de laisser le besoin arriver : la deuxième variante justifie l'abstraction, pas la première.
Exercices type
Quelle différence entre un design pattern et une bibliothèque ?
Une bibliothèque est du code que l'on installe et que l'on appelle. Elle est déjà écrite, et elle impose sa forme.
Un pattern est une structure de responsabilités que l'on écrit soi-même, adaptée au contexte. Deux applications du même pattern peuvent avoir des noms de classes et des signatures différents.
Conséquence pratique : on n'ajoute pas un pattern comme une dépendance, on le reconnaît dans un problème. Et si le problème n'est pas là, appliquer le pattern n'apporte que de l'indirection.
Un switch sur le mode de livraison apparaît dans quatre classes. Que faire ?
C'est le symptôme du pattern Stratégie. Chaque branche du test devient une classe implémentant une interface commune, et les quatre classes reçoivent l'interface au lieu de la chaîne de caractères.
Le test lui-même ne disparaît pas : il subsiste une fois, dans une fabrique, à l'endroit où la donnée entre dans le système.
Le gain se mesure au moment d'ajouter un mode : une classe nouvelle et une ligne dans la fabrique, au lieu de quatre switch à retrouver et à modifier sans en oublier.
Pourquoi la dépendance va-t-elle de l'observateur vers le sujet, et non l'inverse ?
Parce que c'est ce qui permet au sujet d'ignorer qui l'écoute.
Si le capteur appelait l'affichage, le journal et l'alerte, il en dépendrait toutes les trois : ajouter un usage voudrait dire modifier le capteur, et un capteur ne devrait pas connaître une interface graphique.
En inversant, le sujet ne connaît qu'une interface IObservateur et une liste. Les abonnés viennent à lui. Ajouter un usage n'oblige à modifier ni le capteur ni les observateurs existants, ce qui est exactement le principe ouvert-fermé.
Un singleton complique les tests. Pourquoi, et par quoi le remplacer ?
Pour deux raisons. On ne peut pas lui substituer une version d'essai, puisque le point d'accès est statique et le constructeur privé. Et l'instance survit d'un test à l'autre, donc l'état laissé par le premier influence le second, ce qui produit des échecs qui dépendent de l'ordre d'exécution.
Le remplacement consiste à séparer les deux besoins que le pattern confond : l'unicité et l'accès global. On construit l'objet une seule fois au démarrage, et on le passe par constructeur à ceux qui en ont besoin.
La dépendance redevient visible dans la signature, et un test fournit ce qu'il veut. C'est ce que fait un conteneur d'injection avec un cycle de vie singleton.
Comment distinguer Pont de Stratégie ?
Par le nombre de hiérarchies.
Stratégie fait varier un algorithme : une seule famille de classes interchangeables, souvent remplaçables en cours d'exécution, et le client n'a pas de sous-classes.
Pont fait varier deux dimensions en parallèle : il y a des sous-classes des deux côtés, et le lien est établi à la construction pour la durée de vie de l'objet.
Le test décisif : demandez-vous si, sans le pattern, vous auriez n × m classes dont le nom combine deux axes. Si oui, c'est un pont.
Un collègue propose une interface, une fabrique et un observateur pour une fonctionnalité qui n'a qu'un seul cas. Que répondre ?
Que le coût est immédiat et le bénéfice hypothétique.
Chaque indirection rend le code plus difficile à suivre : pour savoir ce qui s'exécute, il faut ouvrir plusieurs fichiers et remonter une référence. Une interface à implémentation unique n'apporte aucune souplesse, seulement un fichier.
L'argument « on en aura besoin plus tard » se retourne : le jour où la deuxième implémentation arrive, extraire l'interface est une opération mécanique que l'éditeur fait en une commande. Anticiper coûte tout de suite ; extraire plus tard coûte une minute.
Où se trouve la fuite mémoire classique de l'observateur ?
Dans l'abonnement jamais résilié.
Le sujet conserve une référence vers chaque abonné. Tant que le sujet vit, aucun abonné ne peut être libéré, même si plus rien d'autre ne le référence. Une fenêtre fermée mais toujours abonnée reste en mémoire et continue de réagir aux notifications.
La règle est que celui qui s'abonne se désabonne, au moment où il cesse d'être utile : Dispose, fermeture de la fenêtre, fin du traitement. En .NET, cela veut dire un -= pour chaque +=.
La méthode
- Pars du symptôme, pas du catalogue : quel couplage te gêne aujourd'hui ?
- Nomme les participants avant d'écrire : qui est le contexte, qui est l'abstraction ?
- Vérifie qu'il y aura une deuxième implémentation. S'il n'y en a qu'une, attends.
- Fais entrer la dépendance par le constructeur, jamais par un accès statique.
- Regroupe les décisions de création en un point unique plutôt que de les disséminer.
- Désabonne ce que tu as abonné, dans le même objet.
- Relis en te demandant si un lecteur qui ne connaît pas le pattern comprendra le code.
En résumé
- Un design pattern est une structure de responsabilités, pas du code à recopier.
- Le vocabulaire commun est la moitié de l'intérêt : il rend la conception discutable.
- Trois familles : création, structure, comportement.
- Presque tous appliquent la même idée : dépendre d'une abstraction.
- Stratégie encapsule chaque algorithme dans une classe interchangeable.
- Observateur inverse la dépendance : le sujet ignore qui l'écoute.
- Fabrique rassemble en un point unique le choix de la classe concrète.
- Singleton confond unicité et accès global ; l'injection sépare les deux.
- Pont transforme
n × msous-classes enn + m. - Un pattern appliqué sans son problème ne laisse que le coût.
Et ensuite ? Ces patterns organisent des classes entre elles. À l'échelle d'une application entière, la même question se pose et reçoit des réponses du même genre : le chapitre suivant traite des architectures MVC et MVVM.