Retour au cours

backend / python

Décorateurs

Leçon 151 exercice

Explication

Ce que vous allez apprendre

  • Comprendre qu'un décorateur n'est qu'une fonction qui en prend une autre et en retourne une nouvelle
  • Décrypter le sucre syntaxique @decorateur en son équivalent f = decorateur(f)
  • Ne jamais oublier functools.wraps pour préserver le nom et la documentation de la fonction décorée
  • Empiler plusieurs décorateurs et prédire l'ordre dans lequel ils s'appliquent
  • Utiliser functools.lru_cache pour mémoïser une fonction sans écrire de cache à la main

Dans quel contexte ?

Un développeur veut mesurer le temps d'exécution de plusieurs fonctions d'un module de traitement de données sans modifier chacune d'elles pour y ajouter un chronomètre manuel. Écrire un décorateur @chronometrer une seule fois, puis l'appliquer avec une simple ligne @chronometrer au-dessus de chaque fonction concernée, évite de dupliquer ce code de mesure partout — et permet de le retirer d'un coup en supprimant les lignes @chronometrer le jour où ce n'est plus nécessaire.

Ajouter un comportement sans toucher au code existant

Un décorateur est une fonction qui prend une autre fonction en entrée et en retourne une version modifiée, généralement enrichie d'un comportement additionnel (chronométrage, journalisation, mise en cache...). L'intérêt majeur, c'est de séparer la logique métier de préoccupations transversales : la fonction calcul_lourd n'a pas besoin de savoir qu'elle est chronométrée, tout ce code vit ailleurs, dans le décorateur.

Le sucre syntaxique @ n'est qu'une façade

@chronometrer au-dessus d'une fonction est strictement équivalent à écrire calcul_lourd = chronometrer(calcul_lourd) juste après sa définition. Comprendre cette équivalence dissipe toute la magie apparente : un décorateur n'est qu'une fonction ordinaire appliquée automatiquement au moment de la définition.

Piège fréquent

Sans @functools.wraps(fonction) dans le wrapper, calcul_lourd.__name__ renvoie "wrapper" au lieu de "calcul_lourd", et help(calcul_lourd) perd sa documentation d'origine. Cela casse silencieusement des outils comme les frameworks de test ou la génération automatique de documentation.

Pourquoi functools.wraps n'est jamais optionnel

Sans @functools.wraps, la fonction décorée perd son nom, sa documentation et ses métadonnées d'origine, remplacées par celles du wrapper interne. C'est une source de bugs subtils lors du débogage ou de l'introspection. C'est un réflexe à prendre dès la première utilisation d'un décorateur.

Empiler, paramétrer, ou même utiliser une classe

Plusieurs décorateurs peuvent s'empiler sur une même fonction, appliqués de bas en haut. Un décorateur peut aussi accepter ses propres arguments (@repeter(fois=3)), ce qui demande un niveau d'imbrication supplémentaire. Et rien n'oblige un décorateur à être une fonction : une classe qui implémente __call__ fonctionne tout aussi bien, avec l'avantage de pouvoir stocker un état plus riche (comme un compteur d'appels).

Forme du décorateurSyntaxeCas d'usage
Fonction simple@chronometrerComportement sans paramètre
Fonction paramétrée@repeter(fois=3)Comportement configurable
Classe avec __call__@CompteurAppelsBesoin de stocker un état riche

Cette leçon s'appuie directement sur les closures et le *args/**kwargs vus précédemment : un décorateur est leur combinaison la plus emblématique.

Commandes & code

Décorateurs

Modifier ou enrichir le comportement d'une fonction sans changer son code.

python
import functools
import time

# Decorateur de base : une fonction qui prend une fonction et en retourne une autre
def chronometrer(fonction):
    @functools.wraps(fonction)     # preserve __name__, __doc__ de la fonction originale
    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 ** 2 for i in range(n))

calcul_lourd(1_000_000)

# Sans le sucre syntaxique @, c'est equivalent a :
# calcul_lourd = chronometrer(calcul_lourd)

# Decorateur avec arguments : une usine a decorateurs (3 niveaux de fonctions)
def repeter(fois: int):
    def decorateur(fonction):
        @functools.wraps(fonction)
        def wrapper(*args, **kwargs):
            resultats = []
            for _ in range(fois):
                resultats.append(fonction(*args, **kwargs))
            return resultats
        return wrapper
    return decorateur

@repeter(fois=3)
def lancer_de(nombre_faces=6):
    import random
    return random.randint(1, nombre_faces)

print(lancer_de())

# Empiler plusieurs decorateurs : s'appliquent de bas en haut
def en_majuscules(fonction):
    @functools.wraps(fonction)
    def wrapper(*args, **kwargs):
        return fonction(*args, **kwargs).upper()
    return wrapper

def entre_crochets(fonction):
    @functools.wraps(fonction)
    def wrapper(*args, **kwargs):
        return f"[{fonction(*args, **kwargs)}]"
    return wrapper

@entre_crochets     # applique en dernier (exterieur)
@en_majuscules       # applique en premier (interieur)
def saluer(nom):
    return f"bonjour {nom}"

print(saluer("alice"))    # [BONJOUR ALICE]

# Decorateur avec validation / logging
def valider_positif(fonction):
    @functools.wraps(fonction)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg < 0:
                raise ValueError(f"Argument negatif refuse : {arg}")
        return fonction(*args, **kwargs)
    return wrapper

@valider_positif
def racine_carree(x):
    return x ** 0.5

# Decorateurs pour la memoisation : functools.lru_cache / cache (natifs, tres utilises)
@functools.lru_cache(maxsize=None)
def fibonacci(n):
    return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(35))          # instantane grace au cache
print(fibonacci.cache_info())  # statistiques hits/misses

# Decorateurs de classe (pas seulement de fonction)
def singleton(cls):
    instances = {}
    @functools.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.parametres = {}

c1 = Configuration()
c2 = Configuration()
print(c1 is c2)     # True : meme instance

# Decorateur base sur une classe (avec __call__)
class CompteurAppels:
    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} de {self.fonction.__name__}")
        return self.fonction(*args, **kwargs)

@CompteurAppels
def dire_bonjour():
    return "Bonjour !"

dire_bonjour()
dire_bonjour()

Résumé

  • Un décorateur est une fonction d'ordre supérieur qui enveloppe une autre fonction.
  • Toujours utiliser functools.wraps pour préserver métadonnées (__name__, __doc__).
  • Les décorateurs s'empilent de bas en haut ; functools.lru_cache est le décorateur de mémoïsation natif.
  • Un décorateur peut aussi être une classe qui implémente __call__.

Exercices pratiques

1 disponible
1

Mission : réparer un décorateur qui casse l'introspection de l'API

Objectif : Corriger un décorateur mal écrit qui casse les métadonnées d'une fonction, puis prédire l'ordre d'exécution de décorateurs empilés.

Contexte

Un décorateur @journaliser ajouté à plusieurs endpoints d'une API interne fait perdre leur documentation automatique : help(mon_endpoint) affiche désormais "wrapper" au lieu du vrai nom de la fonction, et l'outil de génération de documentation de l'équipe ne trouve plus aucune docstring. Le décorateur n'utilise pas functools.wraps.

Tu dois corriger ce décorateur, prédire l'ordre exact d'exécution de deux décorateurs empilés, puis expliquer un piège lié à functools.lru_cache.

Résoudre l’exercice →