backend / python
Décorateurs
Explication
Ce que vous allez apprendre
- Comprendre qu'un décorateur n'est qu'une fonction qui en prend une autre et en retourne une nouvelle
- Décrypter le sucre syntaxique
@decorateuren son équivalentf = decorateur(f) - Ne jamais oublier
functools.wrapspour préserver le nom et la documentation de la fonction décorée - Empiler plusieurs décorateurs et prédire l'ordre dans lequel ils s'appliquent
- Utiliser
functools.lru_cachepour mémoïser une fonction sans écrire de cache à la main
Dans quel contexte ?
Un développeur veut mesurer le temps d'exécution de plusieurs fonctions d'un module de traitement de données sans modifier chacune d'elles pour y ajouter un chronomètre manuel. Écrire un décorateur @chronometrer une seule fois, puis l'appliquer avec une simple ligne @chronometrer au-dessus de chaque fonction concernée, évite de dupliquer ce code de mesure partout — et permet de le retirer d'un coup en supprimant les lignes @chronometrer le jour où ce n'est plus nécessaire.
Ajouter un comportement sans toucher au code existant
Un décorateur est une fonction qui prend une autre fonction en entrée et en retourne une version modifiée, généralement enrichie d'un comportement additionnel (chronométrage, journalisation, mise en cache...). L'intérêt majeur, c'est de séparer la logique métier de préoccupations transversales : la fonction calcul_lourd n'a pas besoin de savoir qu'elle est chronométrée, tout ce code vit ailleurs, dans le décorateur.
Le sucre syntaxique @ n'est qu'une façade
@chronometrer au-dessus d'une fonction est strictement équivalent à écrire calcul_lourd = chronometrer(calcul_lourd) juste après sa définition. Comprendre cette équivalence dissipe toute la magie apparente : un décorateur n'est qu'une fonction ordinaire appliquée automatiquement au moment de la définition.
Piège fréquent
Sans @functools.wraps(fonction) dans le wrapper, calcul_lourd.__name__ renvoie "wrapper" au lieu de "calcul_lourd", et help(calcul_lourd) perd sa documentation d'origine. Cela casse silencieusement des outils comme les frameworks de test ou la génération automatique de documentation.
Pourquoi functools.wraps n'est jamais optionnel
Sans @functools.wraps, la fonction décorée perd son nom, sa documentation et ses métadonnées d'origine, remplacées par celles du wrapper interne. C'est une source de bugs subtils lors du débogage ou de l'introspection. C'est un réflexe à prendre dès la première utilisation d'un décorateur.
Empiler, paramétrer, ou même utiliser une classe
Plusieurs décorateurs peuvent s'empiler sur une même fonction, appliqués de bas en haut. Un décorateur peut aussi accepter ses propres arguments (@repeter(fois=3)), ce qui demande un niveau d'imbrication supplémentaire. Et rien n'oblige un décorateur à être une fonction : une classe qui implémente __call__ fonctionne tout aussi bien, avec l'avantage de pouvoir stocker un état plus riche (comme un compteur d'appels).
| Forme du décorateur | Syntaxe | Cas d'usage |
|---|---|---|
| Fonction simple | @chronometrer | Comportement sans paramètre |
| Fonction paramétrée | @repeter(fois=3) | Comportement configurable |
Classe avec __call__ | @CompteurAppels | Besoin de stocker un état riche |
Cette leçon s'appuie directement sur les closures et le *args/**kwargs vus précédemment : un décorateur est leur combinaison la plus emblématique.
Commandes & code
Décorateurs
Modifier ou enrichir le comportement d'une fonction sans changer son code.
import functools
import time
# Decorateur de base : une fonction qui prend une fonction et en retourne une autre
def chronometrer(fonction):
@functools.wraps(fonction) # preserve __name__, __doc__ de la fonction originale
def wrapper(*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 wrapper
@chronometrer
def calcul_lourd(n):
return sum(i ** 2 for i in range(n))
calcul_lourd(1_000_000)
# Sans le sucre syntaxique @, c'est equivalent a :
# calcul_lourd = chronometrer(calcul_lourd)
# Decorateur avec arguments : une usine a decorateurs (3 niveaux de fonctions)
def repeter(fois: int):
def decorateur(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
resultats = []
for _ in range(fois):
resultats.append(fonction(*args, **kwargs))
return resultats
return wrapper
return decorateur
@repeter(fois=3)
def lancer_de(nombre_faces=6):
import random
return random.randint(1, nombre_faces)
print(lancer_de())
# Empiler plusieurs decorateurs : s'appliquent de bas en haut
def en_majuscules(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
return fonction(*args, **kwargs).upper()
return wrapper
def entre_crochets(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
return f"[{fonction(*args, **kwargs)}]"
return wrapper
@entre_crochets # applique en dernier (exterieur)
@en_majuscules # applique en premier (interieur)
def saluer(nom):
return f"bonjour {nom}"
print(saluer("alice")) # [BONJOUR ALICE]
# Decorateur avec validation / logging
def valider_positif(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Argument negatif refuse : {arg}")
return fonction(*args, **kwargs)
return wrapper
@valider_positif
def racine_carree(x):
return x ** 0.5
# Decorateurs pour la memoisation : functools.lru_cache / cache (natifs, tres utilises)
@functools.lru_cache(maxsize=None)
def fibonacci(n):
return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(35)) # instantane grace au cache
print(fibonacci.cache_info()) # statistiques hits/misses
# Decorateurs de classe (pas seulement de fonction)
def singleton(cls):
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Configuration:
def __init__(self):
self.parametres = {}
c1 = Configuration()
c2 = Configuration()
print(c1 is c2) # True : meme instance
# Decorateur base sur une classe (avec __call__)
class CompteurAppels:
def __init__(self, fonction):
functools.update_wrapper(self, fonction)
self.fonction = fonction
self.appels = 0
def __call__(self, *args, **kwargs):
self.appels += 1
print(f"Appel n°{self.appels} de {self.fonction.__name__}")
return self.fonction(*args, **kwargs)
@CompteurAppels
def dire_bonjour():
return "Bonjour !"
dire_bonjour()
dire_bonjour()Résumé
- Un décorateur est une fonction d'ordre supérieur qui enveloppe une autre fonction.
- Toujours utiliser
functools.wrapspour préserver métadonnées (__name__,__doc__). - Les décorateurs s'empilent de bas en haut ;
functools.lru_cacheest le décorateur de mémoïsation natif. - Un décorateur peut aussi être une classe qui implémente
__call__.
Exercices pratiques
Mission : réparer un décorateur qui casse l'introspection de l'API
Objectif : Corriger un décorateur mal écrit qui casse les métadonnées d'une fonction, puis prédire l'ordre d'exécution de décorateurs empilés.
Contexte
Un décorateur @journaliser ajouté à plusieurs endpoints d'une API interne fait perdre leur documentation automatique : help(mon_endpoint) affiche désormais "wrapper" au lieu du vrai nom de la fonction, et l'outil de génération de documentation de l'équipe ne trouve plus aucune docstring. Le décorateur n'utilise pas functools.wraps.
Tu dois corriger ce décorateur, prédire l'ordre exact d'exécution de deux décorateurs empilés, puis expliquer un piège lié à functools.lru_cache.