// Python

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.

À lire ensuite