Les décorateurs en Python : comprendre et les utiliser
Un décorateur Python est une fonction qui prend une autre fonction en entrée, lui ajoute un comportement, puis renvoie une nouvelle fonction. C’est l’un des outils les plus élégants du langage : il permet d’enrichir du code (journalisation, chronométrage, mise en cache, contrôle d’accès) sans toucher à la fonction d’origine. Ce guide couvre le sujet de façon exhaustive : les fonctions comme objets, les closures, la syntaxe @, les décorateurs avec et sans arguments, functools.wraps, les cas d’usage concrets et les décorateurs de classe.
Les fonctions sont des objets de première classe
Avant de comprendre les décorateurs, il faut saisir un principe fondamental : en Python, une fonction est un objet comme un autre. On peut la stocker dans une variable, la passer en argument ou la renvoyer.
def crier(texte):
return texte.upper() + " !"
# On assigne la fonction à une variable (sans les parenthèses)
f = crier
print(f("bonjour")) # BONJOUR !
# On la passe en argument
def appliquer(fonction, valeur):
return fonction(valeur)
print(appliquer(crier, "salut")) # SALUT !
Notez la différence : crier désigne l’objet fonction, tandis que crier("bonjour") l’appelle. Cette distinction est la clé de tout ce qui suit.
Les closures : des fonctions qui se souviennent
Une closure est une fonction interne qui capture les variables de la fonction qui l’entoure, même après que celle-ci a terminé son exécution.
def multiplicateur(facteur):
def multiplier(nombre):
return nombre * facteur # 'facteur' est capturé
return multiplier
double = multiplicateur(2)
triple = multiplicateur(3)
print(double(10)) # 20
print(triple(10)) # 30
multiplier se « souvient » de la valeur de facteur qui lui a été fournie. Un décorateur n’est rien d’autre qu’une closure appliquée à une fonction.
Écrire son premier décorateur
Le schéma canonique : une fonction externe reçoit fonction, définit une fonction interne wrapper qui ajoute du comportement, puis renvoie wrapper.
def journaliser(fonction):
def wrapper():
print(f"Appel de {fonction.__name__}")
resultat = fonction()
print("Terminé")
return resultat
return wrapper
def dire_bonjour():
print("Bonjour !")
dire_bonjour = journaliser(dire_bonjour)
dire_bonjour()
Sortie :
Appel de dire_bonjour
Bonjour !
Terminé
La ligne dire_bonjour = journaliser(dire_bonjour) remplace la fonction d’origine par sa version décorée.
La syntaxe @ : le sucre syntaxique
Écrire ma_fonction = decorateur(ma_fonction) est répétitif. Python fournit l’opérateur @, placé juste au-dessus de la définition, qui fait exactement la même chose.
def journaliser(fonction):
def wrapper():
print(f"Appel de {fonction.__name__}")
return fonction()
return wrapper
@journaliser
def dire_bonjour():
print("Bonjour !")
dire_bonjour()
@journaliser au-dessus de dire_bonjour équivaut strictement à dire_bonjour = journaliser(dire_bonjour). C’est purement une commodité de lecture.
Décorer des fonctions avec arguments
Le wrapper de l’exemple précédent ne prend aucun argument. Pour décorer n’importe quelle fonction, on utilise *args et **kwargs, qui capturent tous les arguments positionnels et nommés. Si le sujet vous échappe, notre guide sur les fonctions en Python détaille ce mécanisme.
def journaliser(fonction):
def wrapper(*args, **kwargs):
print(f"Appel de {fonction.__name__} avec {args} {kwargs}")
resultat = fonction(*args, **kwargs)
print(f"Retour : {resultat}")
return resultat
return wrapper
@journaliser
def additionner(a, b):
return a + b
additionner(3, 5)
Sortie :
Appel de additionner avec (3, 5) {}
Retour : 8
Le wrapper transmet fidèlement les arguments à la fonction d’origine, quelle que soit sa signature.
Préserver les métadonnées avec functools.wraps
Un problème subtil apparaît : après décoration, la fonction perd son nom et sa docstring, remplacés par ceux du wrapper.
@journaliser
def additionner(a, b):
"""Additionne deux nombres."""
return a + b
print(additionner.__name__) # wrapper (au lieu de additionner !)
print(additionner.__doc__) # None
La solution est le décorateur functools.wraps, appliqué au wrapper. Il copie les métadonnées de la fonction d’origine.
import functools
def journaliser(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
return fonction(*args, **kwargs)
return wrapper
@journaliser
def additionner(a, b):
"""Additionne deux nombres."""
return a + b
print(additionner.__name__) # additionner
print(additionner.__doc__) # Additionne deux nombres.
Utilisez toujours functools.wraps dans vos décorateurs : c’est une bonne pratique indispensable pour le débogage et l’introspection.
Les décorateurs avec arguments
Parfois on veut paramétrer le décorateur lui-même, par exemple @repeter(3). Cela demande un niveau d’imbrication supplémentaire : une fonction qui reçoit les arguments et renvoie un décorateur.
import functools
def repeter(nombre):
def decorateur(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
for _ in range(nombre):
resultat = fonction(*args, **kwargs)
return resultat
return wrapper
return decorateur
@repeter(3)
def saluer(nom):
print(f"Bonjour {nom}")
saluer("Léa")
Sortie :
Bonjour Léa
Bonjour Léa
Bonjour Léa
La logique se lit de l’extérieur vers l’intérieur : repeter(3) renvoie decorateur, qui décore saluer. Trois couches de fonctions, mais un motif toujours identique.
Cas d’usage n°1 : chronométrer une fonction (timing)
Mesurer le temps d’exécution est le cas d’école du décorateur.
import functools
import time
def chronometrer(fonction):
@functools.wraps(fonction)
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 * i for i in range(n))
calcul_lourd(1_000_000)
Cas d’usage n°2 : journaliser les appels (log)
Tracer les entrées et sorties d’une fonction aide au débogage sans polluer sa logique.
import functools
def tracer(fonction):
@functools.wraps(fonction)
def wrapper(*args, **kwargs):
signature = ", ".join([repr(a) for a in args] +
[f"{k}={v!r}" for k, v in kwargs.items()])
print(f"→ {fonction.__name__}({signature})")
resultat = fonction(*args, **kwargs)
print(f"← {resultat!r}")
return resultat
return wrapper
@tracer
def diviser(a, b):
return a / b
diviser(10, 4)
Cas d’usage n°3 : mise en cache (cache)
Éviter de recalculer un résultat déjà connu. Python fournit directement functools.lru_cache, un décorateur prêt à l’emploi.
import functools
@functools.lru_cache(maxsize=None)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # instantané grâce au cache
Sans le cache, fibonacci(50) déclencherait des milliards d’appels. Avec lru_cache, chaque valeur n’est calculée qu’une fois.
Empiler plusieurs décorateurs
On peut appliquer plusieurs décorateurs à une même fonction. Ils s’appliquent de bas en haut.
@tracer
@chronometrer
def travailler(n):
return sum(range(n))
travailler(100)
Ici travailler est d’abord enveloppée par chronometrer, puis le résultat par tracer. L’ordre a son importance.
Les décorateurs de classe
Un décorateur peut aussi cibler une classe entière, pour lui ajouter des attributs ou modifier son comportement.
def ajouter_identite(cls):
cls.categorie = "outil"
return cls
@ajouter_identite
class Marteau:
pass
print(Marteau.categorie) # outil
À l’inverse, on peut utiliser une classe comme décorateur en implémentant la méthode __call__, ce qui permet de stocker un état entre les appels.
import functools
class Compteur:
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}")
return self.fonction(*args, **kwargs)
@Compteur
def bonjour():
print("Salut")
bonjour() # Appel n°1
bonjour() # Appel n°2
Conclusion
Les décorateurs reposent sur trois idées simples : les fonctions sont des objets, les closures capturent leur contexte, et la syntaxe @ n’est qu’un raccourci pour f = decorateur(f). Maîtrisez le motif wrapper(*args, **kwargs) avec functools.wraps, et vous pourrez ajouter proprement du logging, du timing ou du cache à n’importe quelle fonction. C’est un outil incontournable de tout code Python professionnel.