Décorateurs en Python : ce que c'est, comment ils fonctionnent et comment en écrire un
Un décorateur en Python est une fonction qui reçoit une autre fonction et renvoie une nouvelle fonction, en général un wrapper qui ajoute un comportement avant ou après l'appel : journalisation, mesure du temps, cache, contrôle des droits. La syntaxe @decorator placée au-dessus du def équivaut à func = decorator(func) ; pour que le wrapper garde le nom et la docstring de la fonction d'origine, on utilise functools.wraps.
Un décorateur en Python est une fonction qui reçoit une fonction et renvoie une autre fonction, généralement un wrapper qui entoure la première. C'est ainsi qu'on ajoute un comportement à une fonction — journalisation, mesure du temps, cache, nouvelles tentatives — sans modifier son code. Les décorateurs s'écrivent avec la syntaxe @nom au-dessus de la déclaration de la fonction, et on les rencontre partout : dans Flask et FastAPI, dans pytest, dans la bibliothèque standard.
Ce qu'est un décorateur, en termes simples
Pensez à un cadeau et à son papier d'emballage. Le cadeau (la fonction) reste le même, mais une couche extérieure entre en action en premier. Un décorateur est exactement cet emballage : à l'appel, le code du wrapper s'exécute d'abord, appelle la fonction d'origine, et peut ensuite traiter le résultat.
Cela fonctionne parce qu'en Python les fonctions sont des objets ordinaires. On peut les passer à d'autres fonctions, les renvoyer depuis une fonction, et les déclarer à l'intérieur d'une fonction. Les décorateurs reposent sur ces trois propriétés.
Comment fonctionne un décorateur : @ est un raccourci de syntaxe
Prenons le décorateur le plus simple : il affiche un message avant et après l'appel.
def journaliser(func):
def wrapper():
print(f"J'appelle {func.__name__}")
resultat = func()
print(f"{func.__name__} est terminée")
return resultat
return wrapper
@journaliser
def bonjour():
print("Salut !")
bonjour()
J'appelle bonjour
Salut !
bonjour est terminée
L'écriture @journaliser placée au-dessus de def est strictement équivalente à la ligne suivante, écrite après la déclaration :
def bonjour():
print("Salut !")
bonjour = journaliser(bonjour)
Le nom bonjour désigne désormais wrapper, et la fonction d'origine vit à l'intérieur de la fermeture : wrapper conserve la variable func de la fonction externe. Point important : le décorateur n'est appliqué qu'une fois, au moment où def s'exécute, c'est-à-dire à l'import du module ; le wrapper, lui, se déclenche à chaque appel.
Fermetures et décorateurs
Une fermeture (closure) est une fonction imbriquée qui mémorise les variables de la fonction externe après la fin de celle-ci. Sans fermetures, il n'y aurait pas de décorateurs : c'est grâce à elles que wrapper accède à func. Une fermeture peut aussi conserver un état, par exemple un compteur d'appels :
def compter_appels(func):
appels = 0
def wrapper(*args, **kwargs):
nonlocal appels
appels += 1
print(f"{func.__name__} : appel n°{appels}")
return func(*args, **kwargs)
return wrapper
Écrire un décorateur en Python : modèle universel
Le premier exemple a deux défauts : le wrapper n'accepte pas d'arguments, et il « avale » le nom de la fonction d'origine. Voici le modèle de départ, valable pour presque tous les décorateurs :
import functools
def mon_decorateur(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# code avant l'appel
resultat = func(*args, **kwargs)
# code après l'appel
return resultat
return wrapper
Trois règles pour ce modèle :
*args, **kwargs— le wrapper accepte n'importe quels arguments et les transmet tels quels.return resultat— ne pas oublier de renvoyer le résultat, sinon la fonction décorée renverra toujoursNone.@functools.wraps(func)— recopier les métadonnées de la fonction d'origine sur le wrapper.
Exemple de décorateur qui mesure le temps d'exécution :
import functools
import time
def chronometre(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
debut = time.perf_counter()
resultat = func(*args, **kwargs)
duree = time.perf_counter() - debut
print(f"{func.__name__} a pris {duree:.3f} s")
return resultat
return wrapper
@chronometre
def somme_lente(n):
return sum(range(n))
somme_lente(10_000_000) # somme_lente a pris 0.142 s
À quoi sert functools.wraps
Sans wraps, le nom, la docstring et d'autres attributs de la fonction décorée sont remplacés : de l'extérieur, on voit wrapper :
def simple(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@simple
def additionner(a, b):
"""Additionne deux nombres."""
return a + b
print(additionner.__name__, additionner.__doc__) # wrapper None
Avec @functools.wraps(func), le wrapper reçoit __name__, __doc__, __module__, __qualname__ et __annotations__ de la fonction d'origine, et l'attribut __wrapped__ conserve une référence vers l'original. C'est important pour le débogage et les traces d'erreur, pour help(), pour les frameworks qui enregistrent les fonctions par leur nom, et pour inspect.signature, qui affiche la vraie signature.
Décorateur avec paramètres (avec arguments)
Parfois, un décorateur a besoin de réglages : @retry(times=3), @app.route("/"). On ajoute alors un niveau d'imbrication. La fonction externe reçoit les paramètres et renvoie le véritable décorateur : c'est ce qu'on appelle une fabrique de décorateurs.
import functools
def repeter(fois):
def decorateur(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
resultat = None
for _ in range(fois):
resultat = func(*args, **kwargs)
return resultat
return wrapper
return decorateur
@repeter(fois=3)
def ping():
print("ping")
ping() # ping, ping, ping — trois lignes
Ordre d'évaluation : repeter(fois=3) renvoie decorateur, qui est appliqué à ping et renvoie wrapper. Autrement dit, @repeter(fois=3) revient à écrire ping = repeter(fois=3)(ping).
Exemple pratique : relancer l'appel en cas d'erreur.
import functools
import time
def reessayer(fois=3, delai=1.0, exceptions=(Exception,)):
def decorateur(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for tentative in range(1, fois + 1):
try:
return func(*args, **kwargs)
except exceptions:
if tentative == fois:
raise
time.sleep(delai)
return wrapper
return decorateur
@reessayer(fois=5, delai=0.5, exceptions=(ConnectionError,))
def obtenir_taux():
...
Plusieurs décorateurs sur une même fonction
On peut empiler les décorateurs. Ils s'appliquent de bas en haut : celui qui est le plus proche du def agit en premier.
@chronometre
@journaliser
def rapport():
...
# équivalent à :
rapport = chronometre(journaliser(rapport))
À l'appel, le décorateur externe (chronometre) se déclenche en premier, il mesure donc aussi le temps passé dans journaliser. L'ordre compte : si l'un vérifie les droits d'accès et l'autre met le résultat en cache, leur ordre change le comportement.
Décorateurs standard et intégrés de Python
Beaucoup de décorateurs utiles existent déjà dans le langage et dans la bibliothèque standard :
| Décorateur | Ce qu'il fait |
|---|---|
@property | transforme une méthode en attribut en lecture seule ; avec @x.setter, en attribut contrôlé |
@staticmethod | méthode de classe sans self, simple fonction rangée dans l'espace de noms de la classe |
@classmethod | la méthode reçoit la classe cls au lieu de l'instance — c'est ainsi qu'on écrit des constructeurs alternatifs |
@functools.cache, @functools.lru_cache | mettent en cache les résultats selon les arguments |
@functools.wraps | recopie les métadonnées de la fonction sur le wrapper |
@dataclasses.dataclass | génère __init__, __repr__, __eq__ pour une classe |
@contextlib.contextmanager | transforme un générateur en gestionnaire de contexte pour with |
Mise en cache : functools.cache et lru_cache
Un décorateur de cache mémorise le résultat pour chaque ensemble d'arguments et, à l'appel suivant, le renvoie sans recalcul. L'exemple classique est la suite de Fibonacci récursive, qui, sans cache, demande un temps exponentiel :
import functools
@functools.cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
functools.cache (disponible depuis Python 3.9) conserve tous les résultats sans limite. @functools.lru_cache(maxsize=128) ne garde que les derniers résultats et évince les plus anciens : c'est plus sûr pour les services qui tournent longtemps. On consulte les statistiques avec fib.cache_info() et on vide le cache avec fib.cache_clear(). Les arguments de ces fonctions doivent être hachables : une liste passée en argument provoque TypeError: unhashable type: 'list'. Et il ne faut mettre en cache que des fonctions pures, c'est-à-dire qui renvoient toujours la même chose pour les mêmes arguments et n'ont pas d'effets de bord.
Ce que fait le décorateur property
@property permet d'accéder à une méthode comme à un attribut, sans parenthèses, et de contrôler l'affectation si nécessaire :
class Compte:
def __init__(self, solde):
self._solde = solde
@property
def solde(self):
return self._solde
@solde.setter
def solde(self, valeur):
if valeur < 0:
raise ValueError("Le solde ne peut pas être négatif")
self._solde = valeur
compte = Compte(100)
print(compte.solde) # 100
compte.solde = -5 # ValueError
staticmethod et classmethod
class Montant:
def __init__(self, centimes):
self.centimes = centimes
@classmethod
def depuis_euros(cls, euros):
return cls(round(euros * 100))
@staticmethod
def est_valide(valeur):
return valeur >= 0
m = Montant.depuis_euros(12.5)
print(m.centimes, Montant.est_valide(-1)) # 1250 False
C'est @classmethod qui permet à une méthode de travailler avec la classe elle-même et non avec ses instances : depuis_euros crée correctement un objet de sous-classe.
Décorateurs de classes et classes utilisées comme décorateurs
Un décorateur peut s'appliquer à une classe : il reçoit la classe et renvoie une classe, identique ou modifiée. C'est ainsi que fonctionne @dataclass. Exemple simple : un registre de classes.
registre = {}
def enregistrer(cls):
registre[cls.__name__] = cls
return cls
@enregistrer
class ExportateurCsv:
pass
print(registre) # {'ExportateurCsv': <class '__main__.ExportateurCsv'>}
Le cas inverse, où une classe joue le rôle de décorateur, est aussi possible. Il suffit d'un objet doté de la méthode __call__ ; l'état est commodément stocké dans les attributs de l'instance :
import functools
class CompteurAppels:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.appels = 0
def __call__(self, *args, **kwargs):
self.appels += 1
return self.func(*args, **kwargs)
@CompteurAppels
def tic():
pass
tic(); tic()
print(tic.appels) # 2
Décorateur asynchrone
Pour les fonctions async def, le wrapper doit lui aussi être une coroutine et attendre le résultat avec await, sinon on récupère un objet coroutine non exécuté :
import functools
def journaliser_async(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
print(f"Début de {func.__name__}")
resultat = await func(*args, **kwargs)
print(f"Fin de {func.__name__}")
return resultat
return wrapper
À quoi servent les décorateurs en Python
- Logique transversale — journalisation, métriques, mesure du temps sans copier le code dans chaque fonction.
- Mise en cache —
@functools.cachepour les calculs coûteux et purs. - Contrôles — droits d'accès, validation des arguments, autorisation dans les gestionnaires web.
- Enregistrement — routes dans Flask et FastAPI (
@app.get("/")), fixtures pytest. - Fiabilité — nouvelles tentatives, délais d'attente, gestion des erreurs.
Le principal avantage est la séparation des responsabilités : la fonction s'occupe de sa tâche, et le comportement annexe s'ajoute en une ligne, tout aussi facile à retirer.
Erreurs typiques
- Le wrapper ne renvoie pas le résultat — après décoration, la fonction renvoie
None. - Pas de
*args, **kwargs—TypeErrorlors d'un appel avec des arguments. - Oubli de
wraps—__name__et docstring erronés, confusion dans les journaux. - Parenthèses oubliées sur une fabrique —
@repeterau lieu de@repeter(3): la fonction est passée au paramètrefois. - Code lourd dans le corps du décorateur — il s'exécute à l'import du module, et non à l'appel.
En bref
Les décorateurs en Python sont des fonctions d'ordre supérieur : elles reçoivent une fonction, renvoient un wrapper, et le @ n'est qu'une écriture courte d'affectation. Commencez par le modèle avec *args, **kwargs, return resultat et functools.wraps, puis ajoutez un niveau de fabrique pour les réglages. Les fonctions, les fermetures et les annotations sur lesquelles reposent les décorateurs sont mises en pratique dans le cours sur Python.
Questions fréquentes
Peut-on retirer un décorateur d'une fonction ?
Si le décorateur utilise functools.wraps, l'original reste accessible par l'attribut __wrapped__ : func.__wrapped__(...) appelle la fonction sans le wrapper. Sinon, il faut supprimer la ligne avec @ dans le code.
Quand s'exécute le code du décorateur ?
Le corps du décorateur lui-même s'exécute une seule fois, au moment de la déclaration de la fonction, en général lors de l'import du module. Le code situé dans wrapper s'exécute à chaque appel de la fonction décorée.
Peut-on décorer des méthodes de classe ?
Oui, un décorateur ordinaire avec *args, **kwargs fonctionne aussi pour les méthodes : self se retrouve simplement en premier élément de args.
Quelle est la différence entre un décorateur et l'héritage ?
L'héritage modifie le comportement d'une classe par une hiérarchie, alors qu'un décorateur enveloppe de l'extérieur une fonction ou une classe précise, et se combine facilement avec d'autres décorateurs sans toucher au code.
Ce sujet est traité en détail avec de la pratique dans le cours « Python 3.15: от новичка до профи ».
Voir le cours