Aller au contenu
Développement medium

Design patterns en Python : les versions pythoniques

20 min de lecture

logo python

En Python, la plupart des design patterns du Gang of Four se réduisent à une fonction, un dictionnaire ou un module. Les fonctions de première classe, le duck typing et les modules importables une seule fois couvrent nativement ce que Java résout avec des hiérarchies de classes. Ce guide reprend six patrons classiques : Singleton, Factory, Strategy et Observer, plus Adapter et Decorator, et montre leur forme réellement pythonique. L'objectif n'est pas de réciter le catalogue GoF, mais de savoir quand un patron devient inutile parce que le langage fait déjà le travail. Les exemples ont été exécutés avec Python 3.12.

Ce guide s'adresse aux développeuses et développeurs qui connaissent déjà la programmation orientée objet et veulent écrire du code idiomatique. Avant d'attaquer, ces briques du langage sont mobilisées en permanence.

  • Implémenter un Singleton avec un module ou __new__, et connaître ses réserves.
  • Remplacer une fabrique par une fonction ou un dictionnaire de constructeurs.
  • Écrire une Strategy en passant une fonction plutôt qu'une hiérarchie de classes.
  • Construire un Observer avec une liste d'abonnés et des callbacks.
  • Reconnaître que Adapter et Decorator sont déjà couverts par functools.
  • Éviter de plaquer les patterns Java sur un langage qui n'en a pas besoin.

Le catalogue du Gang of Four (le livre Design Patterns de 1994) a été pensé pour des langages à typage statique et sans fonctions de première classe, comme le C++ et le Java de l'époque. Beaucoup de patrons y compensent une rigidité du langage : pour passer un comportement en paramètre, il fallait créer une interface, puis une classe concrète qui l'implémente, puis instancier cette classe. Trois artefacts pour transporter un simple bout de logique.

Python n'a pas cette contrainte. Une fonction est un objet que l'on stocke dans une variable, place dans une liste ou passe en argument. Le duck typing accepte n'importe quel objet qui expose les bonnes méthodes, sans héritage déclaré. Un module n'est chargé qu'une fois et se comporte comme un objet global partagé. Résultat : certains patrons deviennent triviaux, d'autres carrément superflus. Peter Norvig l'a résumé dès 1996 : 16 des 23 patrons GoF sont invisibles ou simplifiés dans un langage dynamique.

Le Singleton garantit qu'une classe n'a qu'une seule instance, partagée par tout le programme. C'est utile pour une configuration globale, un pool de connexions ou un cache. En Java, on le construit avec un constructeur privé et une méthode statique. En Python, le besoin réel - un état global unique - est déjà satisfait par un module, puisque l'interpréteur ne l'importe qu'une fois et met le résultat en cache dans sys.modules.

Voici un module reglages.py qui joue le rôle de configuration partagée. N'importe où dans le programme, import reglages renvoie le même objet en mémoire.

# reglages.py : un module est un singleton naturel en Python
region = "eu-west-2"
niveau_log = "INFO"
def resumer():
return f"region={region} niveau_log={niveau_log}"

On modifie l'état à un endroit, on le relit ailleurs, et les deux références pointent vers le même objet. Aucune classe, aucun verrou, aucune méthode statique.

import reglages
import reglages as autre_reference
reglages.niveau_log = "DEBUG"
print("valeur lue ailleurs :", autre_reference.niveau_log)
print("meme objet en memoire :", reglages is autre_reference)
print(reglages.resumer())

L'exécution confirme que les deux noms désignent le même module partagé :

valeur lue ailleurs : DEBUG
meme objet en memoire : True
region=eu-west-2 niveau_log=DEBUG

Si vous tenez absolument à une classe avec unicité d'instance, la surcharge de __new__ fait le travail. La méthode __new__ construit l'objet avant __init__ : on y renvoie toujours la même instance stockée sur la classe.

class ConnexionBase:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# attention : __init__ est rappele a chaque appel
self.uri = "postgresql://localhost/prod"
a = ConnexionBase()
b = ConnexionBase()
print("meme instance :", a is b)
print("uri :", a.uri)

Le résultat prouve l'unicité, mais révèle aussi le piège documenté en commentaire :

meme instance : True
uri : postgresql://localhost/prod

La Factory centralise la création d'objets pour que le code appelant n'ait pas à connaître la classe concrète à instancier. En Java, cela passe par une classe abstraite Factory et des sous-classes. En Python, une fonction suffit dans les cas simples, et un dictionnaire qui associe une clé à un constructeur remplace élégamment toute la hiérarchie dès qu'il y a plusieurs variantes.

L'exemple choisit un algorithme de compression d'après une chaîne. Les fonctions de la bibliothèque standard (gzip.compress, bz2.compress, lzma.compress) sont des objets : on les range directement dans un dictionnaire, sans wrapper.

import gzip
import bz2
import lzma
# Un dictionnaire de constructeurs remplace une hierarchie de fabriques
COMPRESSEURS = {
"gz": gzip.compress,
"bz2": bz2.compress,
"xz": lzma.compress,
}
def compresser(donnees: bytes, algo: str) -> bytes:
try:
fonction = COMPRESSEURS[algo]
except KeyError:
raise ValueError(f"algorithme inconnu : {algo}")
return fonction(donnees)

Le point clé : ajouter un algorithme, c'est ajouter une ligne au dictionnaire, sans toucher à compresser. C'est le principe ouvert/fermé obtenu sans une seule classe. La gestion d'une clé absente lève une erreur explicite au lieu d'un KeyError brut.

texte = b"journal applicatif " * 50
for algo in ("gz", "bz2", "xz"):
resultat = compresser(texte, algo)
print(f"{algo:>3} : {len(texte)} octets -> {len(resultat)} octets")
try:
compresser(texte, "rar")
except ValueError as erreur:
print("erreur attendue :", erreur)

Chaque algorithme comprime réellement les données, et la clé inconnue déclenche le message dédié :

gz : 950 octets -> 48 octets
bz2 : 950 octets -> 75 octets
xz : 950 octets -> 96 octets
erreur attendue : algorithme inconnu : rar

La Strategy : passez une fonction, pas une hiérarchie

Section intitulée « La Strategy : passez une fonction, pas une hiérarchie »

Le patron Strategy rend un algorithme interchangeable à l'exécution. Le cas d'école GoF crée une interface Strategy et une classe par variante, puis injecte l'objet stratégie dans le contexte. En Python, comme une fonction est un objet transportable, on passe directement la fonction en argument. Toute la mécanique de classes disparaît.

Cet exemple calcule le prix d'une commande selon une règle de remise variable. Chaque stratégie est une fonction qui reçoit la commande et renvoie un montant de remise.

from dataclasses import dataclass
from collections.abc import Callable
@dataclass
class Commande:
montant: float
lignes: int
# Chaque strategie est une simple fonction, pas une classe
def remise_zero(c: Commande) -> float:
return 0.0
def remise_fidelite(c: Commande) -> float:
return c.montant * 0.05
def remise_volume(c: Commande) -> float:
return 10.0 if c.lignes >= 10 else 0.0
def prix_final(c: Commande, remise: Callable[[Commande], float]) -> float:
return c.montant - remise(c)

Le type Callable[[Commande], float] documente le contrat de la stratégie : une fonction qui prend une commande et rend un flottant. La fonction prix_final ne connaît aucune classe de remise, elle appelle juste l'objet reçu. On peut même fournir une lambda créée sur place pour une promotion ponctuelle.

commande = Commande(montant=200.0, lignes=12)
print("sans remise :", prix_final(commande, remise_zero))
print("fidelite :", prix_final(commande, remise_fidelite))
print("volume :", prix_final(commande, remise_volume))
# On peut aussi passer une lambda a la volee
print("promo lambda :", prix_final(commande, lambda c: c.montant * 0.20))

Chaque règle produit un prix distinct sans qu'on ait écrit la moindre sous-classe :

sans remise : 200.0
fidelite : 190.0
volume : 190.0
promo lambda : 160.0

C'est l'un des cas où le patron GoF s'évapore presque entièrement. La « hiérarchie de stratégies » se résume à un paramètre de type fonction. Ajouter une politique de remise, c'est écrire une nouvelle fonction, jamais une classe et son interface.

L'Observer : une liste d'abonnés et des callbacks

Section intitulée « L'Observer : une liste d'abonnés et des callbacks »

Le patron Observer notifie plusieurs objets quand un sujet change d'état : c'est le cœur des systèmes événementiels. La version GoF impose une interface Observer avec une méthode update, et chaque abonné est une classe. En Python, un abonné est simplement une fonction (un callback), et le sujet garde une liste de fonctions à rappeler. Le duck typing rend l'interface implicite : tout objet appelable convient.

Ici, un capteur de température notifie ses abonnés à chaque mesure. Les abonnés sont deux fonctions aux rôles distincts : journaliser et alerter.

from collections.abc import Callable
class Capteur:
def __init__(self) -> None:
self._abonnes: list[Callable[[float], None]] = []
def abonner(self, rappel: Callable[[float], None]) -> None:
self._abonnes.append(rappel)
def mesurer(self, temperature: float) -> None:
for rappel in self._abonnes:
rappel(temperature)
def journaliser(temp: float) -> None:
print(f"[journal] temperature = {temp} C")
def alerter(temp: float) -> None:
if temp > 30:
print(f"[alerte] seuil depasse : {temp} C")

La méthode abonner empile un callback dans la liste, et mesurer les rappelle tous dans l'ordre. Aucun abonné n'hérite d'une classe de base : ce sont des fonctions ordinaires, ce qui les rend triviales à tester isolément.

capteur = Capteur()
capteur.abonner(journaliser)
capteur.abonner(alerter)
capteur.mesurer(22.5)
capteur.mesurer(34.0)

Les deux abonnés réagissent, mais alerter ne parle qu'au-dessus du seuil :

[journal] temperature = 22.5 C
[journal] temperature = 34.0 C
[alerte] seuil depasse : 34.0 C

Adapter et Decorator : déjà natifs dans functools

Section intitulée « Adapter et Decorator : déjà natifs dans functools »

Deux patrons GoF sont si bien intégrés au langage qu'on les écrit sans y penser. L'Adapter convertit une interface vers celle attendue par le code appelant. Le Decorator ajoute un comportement autour d'un objet existant sans le modifier. Python fournit une syntaxe dédiée pour le second (@) et le module functools pour les deux.

Un adaptateur se réduit souvent à une fonction de conversion ou à functools.partial, qui fige des arguments d'une fonction existante pour lui donner une nouvelle signature. Voici un adaptateur qui normalise des lignes brutes d'une API tierce vers des flottants exploitables.

from collections.abc import Iterator
# Une API tierce renvoie des lignes brutes
def lire_capteur_externe() -> list[str]:
return ["21.4;ok", "29.8;ok", "35.1;alerte"]
# L'adaptateur convertit vers le format attendu par notre code
def temperatures(source) -> Iterator[float]:
for ligne in source():
valeur, _statut = ligne.split(";")
yield float(valeur)
import functools
def envoyer(canal: str, message: str) -> str:
return f"[{canal}] {message}"
envoyer_slack = functools.partial(envoyer, "slack")
for valeur in temperatures(lire_capteur_externe):
print(f"temperature normalisee : {valeur}")
print(envoyer_slack("deploiement termine"))

La fonction temperatures adapte un format texte;statut vers un flux de nombres, et partial adapte la signature de envoyer en une fonction à un seul argument. Le résultat parle de lui-même :

temperature normalisee : 21.4
temperature normalisee : 29.8
temperature normalisee : 35.1
[slack] deploiement termine

Le Decorator, lui, a carrément donné son nom à une syntaxe du langage. Un décorateur enveloppe une fonction pour lui ajouter un comportement transverse - chronométrage, cache, journalisation - sans toucher son code. La bonne pratique impose functools.wraps, qui préserve le nom et la docstring de la fonction d'origine.

import functools
import time
def chronometrer(fonction):
@functools.wraps(fonction)
def enveloppe(*args, **kwargs):
debut = time.perf_counter()
resultat = fonction(*args, **kwargs)
duree = time.perf_counter() - debut
print(f"{fonction.__name__} a pris {duree:.4f} s")
return resultat
return enveloppe
@chronometrer
def somme_carres(n: int) -> int:
"""Additionne les carres de 0 a n-1."""
return sum(i * i for i in range(n))
print("resultat :", somme_carres(1_000_000))
print("nom conserve :", somme_carres.__name__)
print("docstring conservee :", somme_carres.__doc__)

La sortie montre le comportement ajouté et l'identité préservée grâce à wraps (la durée varie selon la machine) :

somme_carres a pris 0.0347 s
resultat : 333332833333500000
nom conserve : somme_carres
docstring conservee : Additionne les carres de 0 a n-1.

Autrement dit, quand vous écrivez @functools.lru_cache ou @functools.wraps, vous utilisez le patron Decorator industrialisé par la bibliothèque standard. Inutile de réimplémenter le catalogue : le langage l'a absorbé.

La leçon transverse de ce guide tient en une phrase : un design pattern est une réponse à une contrainte de langage, pas une vérité universelle. Quand la contrainte disparaît, le patron disparaît avec elle. Vouloir reproduire en Python la structure de classes d'un exemple Java aboutit à du code verbeux, plus lent et paradoxalement moins lisible pour un Pythoniste.

Le tableau suivant résume la traduction pythonique de chaque patron abordé. La colonne de droite est presque toujours plus courte et plus directe.

Patron GoFRéflexe JavaVersion pythonique
SingletonConstructeur privé, méthode statiqueUn module importable
FactoryClasse abstraite + sous-classesDict de constructeurs ou fonction
StrategyInterface + une classe par varianteFonction passée en argument
ObserverInterface Observer par abonnéListe de callbacks
AdapterClasse d'adaptation dédiéeFonction ou functools.partial
DecoratorClasse wrapper implémentant l'interfaceSyntaxe @ + functools.wraps

Cela ne veut pas dire que les classes sont mauvaises. Elles restent le bon outil quand vous manipulez un état riche, un invariant à protéger ou une famille d'objets aux comportements proches. Le message est de partir du besoin, pas du catalogue : écrivez la solution la plus simple qui marche, et laissez le patron émerger seulement s'il clarifie l'intention.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Un design pattern compense une contrainte de langage ; Python en supprime beaucoup avec les fonctions de première classe et les modules.
  • Le Singleton se fait le plus proprement avec un module ; la version __new__ rejoue __init__ et crée un état global difficile à tester.
  • La Factory devient un dictionnaire de constructeurs : ajouter une variante, c'est ajouter une ligne, pas une classe.
  • La Strategy se réduit à passer une fonction (ou une lambda) en argument, typée Callable.
  • L'Observer est une liste de callbacks rappelés en boucle ; c'est le socle des signaux Django et Flask.
  • Adapter et Decorator sont couverts par functools.partial, functools.wraps et la syntaxe @.
  • Ne plaquez pas une hiérarchie de classes Java sur Python : partez du besoin, laissez le patron émerger.
  • itertools et functools : la boîte à outils fonctionnelle qui rend beaucoup de patrons superflus.
  • Dataclasses Python : structurer des objets de données sans code répétitif, comme la Commande de la Strategy.
  • Type hints en Python : annoter proprement les signatures Callable de vos stratégies et observateurs.
  • Modules et packages : comprendre pourquoi un module importé une seule fois fait un Singleton fiable.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn