Python : Comment utiliser les décorateurs

 

Un décorateur permet d’ajouter un comportement à une fonction (afficher un log, mesurer sa durée, vérifier des droits…) sans toucher à son code. Si vous avez déjà écrit @staticmethod, @property ou @app.route("/") avec Flask, vous en avez utilisé. Nous allons voir comment ils fonctionnent, puis comment écrire les vôtres.

Dans cet article :

  • Les fonctions sont des objets
  • Un premier décorateur
  • Préserver le nom et la docstring avec functools.wraps
  • Mesurer le temps d’exécution
  • Journaliser les appels
  • Un décorateur avec des paramètres
  • Contrôler l’accès à une fonction
  • Empiler des décorateurs
  • Les décorateurs de la bibliothèque standard
  • Décorer une classe

Pré-requis : être à l’aise avec les fonctions en Python. Les exemples ont été testés avec Python 3.13.

Les fonctions sont des objets

Pour comprendre les décorateurs, il faut d’abord se rappeler qu’en Python, les fonctions sont des objets comme les autres. On peut les affecter à une variable ou les passer en paramètre à une autre fonction :

def saluer(nom):
    return f"Bonjour {nom} !"

# Assigner une fonction à une variable
ma_fonction = saluer
print(ma_fonction("Alice"))  # Bonjour Alice !

# Passer une fonction en paramètre
def executer(func, arg):
    return func(arg)

print(executer(saluer, "Bob"))  # Bonjour Bob !

Une fonction peut aussi en créer une autre et la retourner :

def creer_salutation(formule):
    def saluer(nom):
        return f"{formule} {nom} !"
    return saluer

bonjour = creer_salutation("Bonjour")
coucou = creer_salutation("Coucou")

print(bonjour("Alice"))  # Bonjour Alice !
print(coucou("Bob"))     # Coucou Bob !

La fonction saluer créée à l’intérieur garde accès à formule, même une fois l’appel à creer_salutation terminé (on parle de closure). Les décorateurs reposent sur ce mécanisme.

Un premier décorateur

Un décorateur est une fonction qui prend une fonction en paramètre et retourne une nouvelle fonction, qui appelle en général la première. Voici un décorateur qui affiche un message avant et après l’appel :

def mon_decorateur(func):
    def wrapper(*args, **kwargs):
        print(f"Avant l'appel de {func.__name__}")
        resultat = func(*args, **kwargs)
        print(f"Après l'appel de {func.__name__}")
        return resultat
    return wrapper

Le wrapper accepte *args, **kwargs et les transmet tels quels à func : le décorateur fonctionne ainsi avec n’importe quelle signature. Il ne faut pas oublier non plus de retourner le résultat de func, sinon la fonction décorée renverrait toujours None.

On peut appliquer ce décorateur à la main :

def dire_bonjour(nom):
    print(f"Bonjour {nom} !")

dire_bonjour = mon_decorateur(dire_bonjour)
dire_bonjour("Alice")
# Avant l'appel de dire_bonjour
# Bonjour Alice !
# Après l'appel de dire_bonjour

La syntaxe @ fait la même chose, en plus lisible :

@mon_decorateur
def dire_bonjour(nom):
    print(f"Bonjour {nom} !")

dire_bonjour("Alice")
# Avant l'appel de dire_bonjour
# Bonjour Alice !
# Après l'appel de dire_bonjour

Écrire @mon_decorateur au-dessus de la fonction revient à écrire dire_bonjour = mon_decorateur(dire_bonjour) juste après sa définition.

Préserver le nom et la docstring avec functools.wraps

Notre décorateur a un défaut : la fonction décorée est maintenant le wrapper, elle a donc perdu son nom et sa docstring.

@mon_decorateur
def dire_bonjour(nom):
    """Salue une personne par son nom."""
    print(f"Bonjour {nom} !")

print(dire_bonjour.__name__)  # wrapper (au lieu de dire_bonjour)
print(dire_bonjour.__doc__)   # None (au lieu de la docstring)

Ce n’est pas qu’un détail : help(), les outils de documentation et certains frameworks s’appuient sur ces informations. Avec Flask par exemple, le nom de la fonction sert de nom d’endpoint par défaut. Si deux routes utilisent un décorateur écrit sans wraps, les deux fonctions s’appellent wrapper, et Flask lève une erreur dès l’enregistrement de la seconde route : View function mapping is overwriting an existing endpoint function: wrapper (testé avec Flask 3.1).

Pour corriger ça, on utilise le décorateur functools.wraps, qui recopie sur le wrapper le nom, la docstring, le module et les annotations de la fonction d’origine :

from functools import wraps

def mon_decorateur(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Avant l'appel de {func.__name__}")
        resultat = func(*args, **kwargs)
        print(f"Après l'appel de {func.__name__}")
        return resultat
    return wrapper

@mon_decorateur
def dire_bonjour(nom):
    """Salue une personne par son nom."""
    print(f"Bonjour {nom} !")

print(dire_bonjour.__name__)  # dire_bonjour
print(dire_bonjour.__doc__)   # Salue une personne par son nom.

wraps ajoute aussi un attribut __wrapped__ qui pointe vers la fonction d’origine : dire_bonjour.__wrapped__("Alice") l’appelle sans passer par le décorateur. Prenez l’habitude de mettre @wraps(func) dans tous vos décorateurs, comme dans les exemples suivants.

Mesurer le temps d’exécution

Premier exemple concret, un décorateur qui affiche le temps d’exécution d’une fonction :

import time
from functools import wraps

def timer(func):
    @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:.4f}s")
        return resultat
    return wrapper

@timer
def traiter_donnees(n):
    total = sum(range(n))
    return total

traiter_donnees(10_000_000)

La durée dépend évidemment de la machine, on obtient par exemple :

traiter_donnees a pris 0.0974s

Journaliser les appels

Sur le même modèle, ce décorateur écrit dans les logs chaque appel de la fonction, avec ses arguments et la valeur retournée :

import logging
from functools import wraps

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def log_appel(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logger.info(f"Appel de {func.__name__}({args}, {kwargs})")
        resultat = func(*args, **kwargs)
        logger.info(f"{func.__name__} a retourné {resultat}")
        return resultat
    return wrapper

@log_appel
def calculer_total(prix, quantite, remise=0):
    return prix * quantite * (1 - remise)

calculer_total(10, 5, remise=0.1)
# INFO:__main__:Appel de calculer_total((10, 5), {'remise': 0.1})
# INFO:__main__:calculer_total a retourné 45.0

On peut ainsi suivre les appels de plusieurs fonctions sans ajouter de logger.info dans chacune d’elles.

Un décorateur avec des paramètres

Pour configurer un décorateur, il faut un niveau d’imbrication de plus : une fonction qui reçoit les paramètres et retourne le décorateur.

from functools import wraps

def repeter(n):
    def decorateur(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                resultat = func(*args, **kwargs)
            return resultat
        return wrapper
    return decorateur

@repeter(3)
def dire_bonjour(nom):
    print(f"Bonjour {nom} !")

dire_bonjour("Alice")
# Bonjour Alice !
# Bonjour Alice !
# Bonjour Alice !

Quand Python rencontre @repeter(3), il appelle d’abord repeter(3), qui retourne le décorateur, puis il applique ce décorateur à la fonction.

Un exemple plus utile : réessayer une fonction quand elle lève une exception, typiquement un appel réseau qui échoue de temps en temps.

import time
from functools import wraps

def retry(max_tentatives=3, delai=1):
    def decorateur(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for tentative in range(1, max_tentatives + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if tentative == max_tentatives:
                        raise
                    print(f"Tentative {tentative} échouée : {e}. "
                          f"Nouvel essai dans {delai}s...")
                    time.sleep(delai)
        return wrapper
    return decorateur

@retry(max_tentatives=3, delai=2)
def appeler_api():
    # peut lever une exception
    ...

Après la dernière tentative, le raise sans argument relance l’exception d’origine : l’appelant voit l’erreur, au lieu de recevoir un None sans explication.

Contrôler l’accès à une fonction

Les paramètres servent aussi à écrire des décorateurs de vérification. Celui-ci refuse d’exécuter la fonction si l’utilisateur n’a pas le rôle demandé :

from functools import wraps

def require_role(role):
    def decorateur(func):
        @wraps(func)
        def wrapper(utilisateur, *args, **kwargs):
            if utilisateur.get("role") != role:
                raise PermissionError(
                    f"Rôle '{role}' requis, "
                    f"rôle actuel : '{utilisateur.get('role')}'"
                )
            return func(utilisateur, *args, **kwargs)
        return wrapper
    return decorateur

@require_role("admin")
def supprimer_utilisateur(utilisateur, user_id):
    print(f"Utilisateur {user_id} supprimé")

admin = {"nom": "Alice", "role": "admin"}
viewer = {"nom": "Bob", "role": "viewer"}

supprimer_utilisateur(admin, 42)    # Utilisateur 42 supprimé
supprimer_utilisateur(viewer, 42)   # PermissionError: Rôle 'admin' requis, rôle actuel : 'viewer'

Ici, le wrapper attend l’utilisateur en premier argument : ce décorateur ne s’applique qu’aux fonctions qui respectent cette convention.

Empiler des décorateurs

On peut empiler plusieurs décorateurs sur une même fonction. Ils sont appliqués de bas en haut, en commençant par le plus proche de la fonction :

@timer
@retry(max_tentatives=3, delai=1)
def appeler_api():
    ...

C’est équivalent à :

appeler_api = timer(retry(max_tentatives=3, delai=1)(appeler_api))

L’ordre compte. Ici, timer enveloppe la fonction produite par retry : il mesure donc la durée totale, nouvelles tentatives et temps d’attente compris. Dans l’ordre inverse, timer n’afficherait que la durée de la tentative qui a réussi, car une tentative qui échoue interrompt son wrapper avant le print.

Les décorateurs de la bibliothèque standard

Avant d’écrire votre propre décorateur, vérifiez que la bibliothèque standard n’en fournit pas déjà un. Voici ceux qu’on croise le plus souvent.

@property

@property transforme une méthode en attribut calculé, qu’on lit sans parenthèses. Avec un setter, on peut aussi contrôler les valeurs affectées :

import math

class Cercle:
    def __init__(self, rayon):
        self.rayon = rayon  # passe par le setter

    @property
    def rayon(self):
        return self._rayon

    @rayon.setter
    def rayon(self, valeur):
        if valeur < 0:
            raise ValueError("Le rayon doit être positif")
        self._rayon = valeur

    @property
    def aire(self):
        return math.pi * self._rayon ** 2

c = Cercle(5)
print(c.rayon)  # 5 (pas de parenthèses)
print(c.aire)   # 78.53981633974483
c.rayon = 10    # passe par le setter
# c.rayon = -1  # ValueError

@staticmethod et @classmethod

class Date:
    def __init__(self, jour, mois, annee):
        self.jour = jour
        self.mois = mois
        self.annee = annee

    @classmethod
    def depuis_string(cls, date_string):
        jour, mois, annee = map(int, date_string.split("/"))
        return cls(jour, mois, annee)

    @staticmethod
    def est_bissextile(annee):
        return annee % 4 == 0 and (annee % 100 != 0 or annee % 400 == 0)

d = Date.depuis_string("15/06/2026")
print(d.jour, d.mois, d.annee)    # 15 6 2026
print(Date.est_bissextile(2024))  # True

Une méthode @classmethod reçoit la classe (cls) en premier paramètre au lieu de l’instance. On s’en sert surtout pour écrire des constructeurs alternatifs, comme depuis_string. Une méthode @staticmethod ne reçoit ni l’instance ni la classe : c’est une simple fonction rangée dans la classe.

@functools.lru_cache

@lru_cache garde en mémoire le résultat de la fonction pour chaque combinaison d’arguments :

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # 354224848179261915075 (instantané grâce au cache)

Sans le cache, fibonacci(100) ne se terminerait pas en un temps raisonnable, car les mêmes valeurs seraient recalculées un nombre exponentiel de fois. Avec @lru_cache, chaque valeur n’est calculée qu’une fois. Depuis Python 3.9, @functools.cache fait la même chose sans limite de taille, comme @lru_cache(maxsize=None). Le sujet est détaillé dans l’article sur lru_cache.

@dataclasses.dataclass

@dataclass génère __init__, __repr__ et __eq__ à partir des annotations de la classe :

from dataclasses import dataclass

@dataclass
class Produit:
    nom: str
    prix: float
    stock: int = 0

p = Produit("Clavier", 49.99, 10)
print(p)  # Produit(nom='Clavier', prix=49.99, stock=10)

Décorer une classe

Un décorateur peut aussi s’appliquer à une classe : il reçoit la classe et retourne ce qu’il veut à la place. @dataclass retourne la même classe, complétée de nouvelles méthodes. Le décorateur suivant, lui, remplace la classe par une fonction qui renvoie toujours la même instance, un singleton :

from functools import wraps

def singleton(cls):
    instances = {}

    @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.debug = False
        self.version = "1.0"

config1 = Configuration()
config2 = Configuration()
print(config1 is config2)  # True (même instance)

Attention, après @singleton, Configuration n’est plus une classe mais une fonction : isinstance(config1, Configuration) lève une TypeError, et on ne peut plus en hériter. Si vous avez besoin de garder une vraie classe, il vaut mieux redéfinir __new__ ou passer par une métaclasse.

Voir aussi