Bases

Décorateurs en Python : ce que c'est, comment ils fonctionnent et comment en écrire un

Mis à jour: · Skillaria.Top

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 :

  1. *args, **kwargs — le wrapper accepte n'importe quels arguments et les transmet tels quels.
  2. return resultat — ne pas oublier de renvoyer le résultat, sinon la fonction décorée renverra toujours None.
  3. @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écorateurCe qu'il fait
@propertytransforme une méthode en attribut en lecture seule ; avec @x.setter, en attribut contrôlé
@staticmethodméthode de classe sans self, simple fonction rangée dans l'espace de noms de la classe
@classmethodla 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_cachemettent en cache les résultats selon les arguments
@functools.wrapsrecopie les métadonnées de la fonction sur le wrapper
@dataclasses.dataclassgénère __init__, __repr__, __eq__ pour une classe
@contextlib.contextmanagertransforme 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.cache pour 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 — TypeError lors 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 — @repeter au lieu de @repeter(3) : la fonction est passée au paramètre fois.
  • 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.

Apprenez ce sujet en vidéo

Ce sujet est traité en détail avec de la pratique dans le cours « Python 3.15: от новичка до профи ».

Voir le cours