Aller au contenu principal

Paradigmes & patterns · L2 · Section 3/4

Patrons de conception

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Patrons de conception

Un patron de conception (pattern) est une solution éprouvée à un problème récurrent de structure de code. Chaque patron a un contexte, des forces en présence et des coûts : appliqué hors contexte, il complique au lieu de simplifier. Ce chapitre présente les cinq patrons les plus rentables (stratégie, observateur, fabrique, décorateur, adaptateur) avec pour chacun le problème déclencheur, la transformation concrète du code avant/après, et le moment où il ne faut pas l'utiliser.

Prérequis : pages Impératif et objet (encapsulation, composition, injection de dépendances) et Fonctionnel (closures, composition) du même module. Le décorateur objet et le décorateur Python (@...) seront distingués explicitement.

Objectifs d'apprentissage :

  • Citer pour chaque patron le problème précis qu'il résout, pas seulement son diagramme.
  • Transformer un code avec des if/elif de variantes en stratégie, un couplage direct en observateur, une construction éclatée en fabrique.
  • Reconnaître les contextes où un patron coûte plus qu'il ne rapporte (une seule variante, un seul consommateur).
  • Relier les patrons objets à leurs équivalents fonctionnels (stratégie = fonction injectée, fabrique = closure).

#Carte rapide des patterns (usage typique)

Stratégie
Comportement interchangeable (politiques)
Observateur
Réagir aux changements (events)
Fabrique
Déléguer la création
Décorateur
Ajouter des responsabilités
Adaptateur
Adapter une interface existante

#Pattern Stratégie

Problème déclencheur. Un même traitement existe en plusieurs variantes sélectionnées à l'exécution (politique de remise, méthode de tri, algorithme de compression), et le code client s'encombre de if/elif à chaque appel. Chaque nouvelle variante oblige à modifier tous les points de branchement.

Transformation. Avant : le choix est codé en dur dans la fonction.

Chargement de l’éditeur...

Après : chaque variante devient un objet interchangeable derrière une même interface, et le client ne sait plus quels modes existent.

Chargement de l’éditeur...

Ce qui a changé, ligne à ligne : la fonction prix_total ne contient plus un seul branchement ; l'ajout d'une réduction « fidélité à 15% » se fait en écrivant une nouvelle classe de quatre lignes, sans toucher ni au client ni aux autres politiques ; et chaque politique se teste en l'appelant directement sur un nombre, sans panier. En Python fonctionnel, l'équivalent exact est d'injecter une fonction : prix_total(panier, lambda s: s * 0.8) ; la classe n'apporte quelque chose que si les stratégies portent un état ou plusieurs méthodes coordonnées.

Quand ne pas l'utiliser. S'il n'existe qu'une variante et aucun signe d'une deuxième, une simple fonction suffit : le patron prématuré crée trois classes pour un if qu'on n'a pas.

#Pattern Observateur

Problème déclencheur. Un objet change d'état et plusieurs consommateurs doivent réagir (logger, rafraîchir une vue, envoyer une métrique), mais l'objet ne doit connaître aucun d'eux : il notifierait sinon des modules sans rapport avec son métier, et chaque nouveau consommateur exigerait de le modifier.

Transformation. Avant : le producteur appelle chaque consommateur en dur.

Chargement de l’éditeur...

Après : le producteur se contente de diffuser un événement ; quiconque veut réagir s'abonne.

Chargement de l’éditeur...

Ce qui a changé : Capteur ne mentionne plus ni la base de données ni l'interface ; il ne connaît que le contrat « un objet avec update ». Ajouter l'alarme s'est fait sans modifier une ligne du producteur, ce qui en fait le test d'acceptation du patron : si votre « observateur » exige de retoucher au sujet, l'application est ratée. Notez l'ordre d'exécution : les observateurs sont notifiés dans l'ordre d'abonnement, en synchrone ; pour des traitements longs, la version réaliste pousse l'événement dans une file et traite hors ligne.

Quand ne pas l'utiliser. Avec un seul consommateur fixe, un appel direct est plus simple et plus traçable. Et si les observateurs doivent renvoyer des valeurs au sujet, ce n'est plus de la diffusion : passez à une stratégie ou à un pipeline.

#Pattern Fabrique

Problème déclencheur. La construction d'un objet exige des décisions (quelle sous-classe selon un paramètre, des valeurs par défaut croisées, une lecture de configuration) qui encombrent tous les points de création. Le client veut « un connecteur », pas « savoir lequel instancier avec quels arguments ».

Transformation. Avant : chaque site de création duplique la logique de choix.

Chargement de l’éditeur...

Après : la décision vit dans une fabrique, les clients demandent et reçoivent.

Chargement de l’éditeur...

Ce qui a changé : le if/elif de sélection n'existe plus qu'à un seul endroit, le dictionnaire LOADERS ; ajouter un format XML se fait en écrivant la classe et en ajoutant une entrée au dictionnaire, les deux fonctions clientes demeurent inchangées ; et un format inconnu produit une erreur explicite au lieu d'un None silencieux. C'est aussi, notons-le, exactement la fabrique fonctionnelle de la page Fonctionnel : une fonction (ou un dict) qui choisit et construit, sans hiérarchie de créateurs abstraits. La version GoF complète (Creator abstrait + factory_method) ne se justifie que lorsque la famille d'objets créés varie à la fois selon le créateur ET le type.

Quand ne pas l'utiliser. Si le client sait déjà précisément quelle classe il instancie, intercaler une fabrique n'ajoute qu'un niveau d'indirection à lire.

#Pattern Décorateur

Problème déclencheur. Il faut ajouter un comportement transversal (mesure du temps, journalisation, retry, cache) autour d'opérations existantes, sans modifier les classes concernées ni multiplier les sous-classes (TimerCSVLoader, LoggedCSVLoader, LoggedTimerCSVLoader : l'explosion combinatoire).

Transformation. Le décorateur enveloppe l'objet et implémente la même interface : le client ne voit pas la différence.

Chargement de l’éditeur...

Ce qui a changé : CSVLoader ignore tout du chronométrage ; le comportement s'ajoute par composition au point d'assemblage, et plusieurs décorateurs s'empilent librement (chrono dans journal, journal dans retry) sans qu'aucune combinaison n'ait été écrite à l'avance. En Python, le décorateur syntaxique @ est exactement cette idée appliquée aux fonctions :

Chargement de l’éditeur...

Les deux écritures sont le même patron : une fonction qui prend la fonctionnalité de base et retourne une version enrichie de la même signature. @functools.wraps préserve nom et documentation de l'enveloppée, détail qui compte dès qu'on inspecte ou sérialise.

Quand ne pas l'utiliser. Si le comportement ajouté concerne une seule classe concrète et ne sera jamais appliqué ailleurs, l'écrire dans la classe reste plus direct ; et si l'empilement devient profond, l'ordre des décorateurs devient une source de bugs : documentez-le explicitement.

#Pattern Adaptateur

Problème déclencheur. Un module que vous ne contrôlez pas (bibliothèque externe, service hérité) expose une interface incompatible avec ce qu'attend votre code : search(query, limit) contre votre trouve(motif, max_resultats), températures en Fahrenheit contre Celsius. Modifier l'externe est impossible, réécrire tout votre code pour lui est disproportionné.

Transformation. L'adaptateur traduit : il implémente l'interface attendue en déléguant à l'interface disponible.

Chargement de l’éditeur...

Ce qui a changé : le reste du programme continue d'appeler lire() en Celsius, l'externe reste intact, et la formule de conversion vit à un seul endroit au lieu d'être saupoudrée. Le test d'acceptation de l'adaptateur : brancher un jour un deuxième fournisseur (capteur en Kelvin) en écrivant seulement un nouvel adaptateur, sans toucher au code métier. Attention à la différence avec le décorateur : l'adaptateur change l'interface, le décorateur la conserve en ajoutant un comportement.

Quand ne pas l'utiliser. Si vous contrôlez les deux côtés, aligner les interfaces directement coûte moins qu'une couche de traduction à maintenir.

#Exercice : Système de Logging

Implémentez un système de logging simple en utilisant le pattern Observateur. Le système doit permettre à différents modules de s'abonner aux messages de log et d'y réagir (affichage dans la console, écriture dans un fichier, etc.).

#Instructions

  1. Créez une classe Logger qui agit comme le sujet.
  2. Créez une interface LogObserver avec une méthode update(message).
  3. Implémentez des observateurs concrets comme ConsoleLogger et FileLogger.
  4. Permettez aux observateurs de s'abonner et de se désabonner du Logger.
  5. Ajoutez une méthode log(message) dans Logger qui notifie tous les observateurs.

#Correction guidée

pythonpython

1from abc import ABC, abstractmethod2 3class Logger:4    def __init__(self):5        self._observers = []6 7    def attach(self, observer):8        self._observers.append(observer)9 10    def detach(self, observer):11        self._observers.remove(observer)12 13    def log(self, message):14        for observer in list(self._observers):   # copie: détachement pendant l'itération

Points à vérifier dans votre solution :

  1. Le détachement marche : detach doit retirer proprement, et Logger.log doit itérer sur une copie de la liste (list(self._observers)), sinon un observateur qui se détache pendant qu'on le notifie provoque une erreur d'itération.
  2. Le FileLogger est injectable et testable : son chemin est un paramètre du constructeur, pas une constante. Un test peut passer un fichier temporaire, vérifier son contenu, le supprimer : aucune dépendance à l'environnement du développeur.
  3. L'erreur classique du détachement : logger.detach(ConsoleLogger()) déclenche une erreur, car cet objet neuf n'a jamais été attaché ; il faut conserver la référence de l'observateur attaché pour pouvoir la détacher.

Méthode de vérification : attachez deux observateurs (un qui compte les appels, un qui stocke les messages), journalisez trois messages, vérifiez que chacun a été notifié trois fois, détachez l'un des deux, journalisez encore, et vérifiez que seul l'autre a bougé.

Chargement de l’éditeur...

Sortie attendue : les deux messages s'affichent côté console, puis seul le second ; le compteur totalise 2 notifications (une par message diffusé, avant et après le détachement de la console). Si le compteur affiche 1 ou 3, votre logique d'attachement ou de détachement est fautive.

#Bénéfices et limites

#Bénéfices

  • Réutilisabilité : les patterns offrent des solutions éprouvées à des problèmes courants, testées sur des milliers de projets.
  • Maintenabilité : le comportement variable est isolé à un endroit (une stratégie, un observateur), le reste du code reste stable quand il change.
  • Communication : « mets un adaptateur devant » transmet en deux mots une conception complète ; le vocabulaire raccourcit les revues de code.

#Limites

  • Sur-conception : appliquer des patterns partout complexifie inutilement le code ; chaque indirection se paie en lecture.
  • Apprentissage : maîtriser les patterns demande du temps et surtout de la pratique sur du vrai code.
  • Adaptation : les patterns doivent parfois être adaptés au contexte du projet ; cataloguer n'est pas appliquer mécaniquement.

Les patrons de conception sont des outils puissants, guidés par le bon sens et les besoins concrets. La question de décision, à se poser avant chacun : quelle variation ce patron isole-t-il, et cette variation existe-t-elle déjà dans mes besoins actuels ?

#Mini-quiz

Le problème déclencheur de la stratégie est :
Le problème déclencheur de la stratégie est :
Décorateur et adaptateur se distinguent parce que :
Décorateur et adaptateur se distinguent parce que :
Un pattern appliqué sans que la variation existe déjà produit :
Un pattern appliqué sans que la variation existe déjà produit :
Dans un logger par observateurs, itérer sur list(self._observers) plutôt que sur self._observers sert à :
Dans un logger par observateurs, itérer sur list(self._observers) plutôt que sur self._observers sert à :