Décorateurs Python : augmenter fonctions et classes sans toucher leur code

Qu'est-ce qu'un décorateur ?

En Python, un décorateur est une fonction qui prend une autre fonction ou une classe en entrée et renvoie un objet enrichi. Il permet d'ajouter un comportement avant ou après l'exécution de la cible, ou de la remplacer, sans modifier sa définition originale. Syntaxiquement, il s'utilise avec le symbole @ placé juste avant la déclaration :

def etiqueter(func):
    def interne(*args, **kwargs):
        print(f"-- appel de {func.__name__} --")
        return func(*args, **kwargs)
    return interne

@etiqueter
def saluer(nom):
    print(f"Bonjour, {nom}!")

saluer("Alice")

Ici, etiqueter encapsule saluer et affiche le nom de la fonction avant de l'executer. Cet exemple illustre le patron de conception wrapper.

Décorateurs paramétrés

Un décorateur peut lui-même accepter des arguments. Pour cela, il faut ajouter une niveau d'imbrication : une fonction extérieure reçoit les paramètres et retourne le vrai décorateur.

def annoncer(message_avant, message_apres):
    def decorateur(func):
        def enveloppe(*args, **kwargs):
            print(message_avant)
            valeur = func(*args, **kwargs)
            print(message_apres)
            return valeur
        return enveloppe
    return decorateur

@annoncer("Début du calcul", "Calcul terminé")
def addition(a, b):
    return a + b

addition(3, 4)

annoncer n'est pas le décorateur final : c'est une usine à décorateurs qui retourne la fonction enveloppante adaptée aux messages choisis.

Fonctions de première classe : le fondement des décorateurs

Le mécanisme repose sur le fait que les fonctions Python sont des objets comme les autres. Elles peuvent être passées comme argument, retournées par une autre fonction ou assignées à des variables. Un décorateur n'est rien d'autre qu'une fonction qui reçoit une fonction et retourne une fonction.

def encadrer(func):
    def nouvelle(*args, **kwargs):
        print("Avant l'opération")
        resultat = func(*args, **kwargs)
        print("Après l'opération")
        return resultat
    return nouvelle

def multiplier(x, y):
    return x * y

operation = encadrer(multiplier)
print(operation(2, 5))

Lors de l'écriture @encadrer, Python exécute en réalité multiplier = encadrer(multiplier). C'est cette substitution qui permet d'intercaler un traitement supplémentaire.

Préserver les métadonnées avec functools.wraps

Par défaut, le décorateur remplace le nom et la docstring de la fonction par ceux de l'enveloppe. Pour conserver l'identité de la fonction originale, on utilise functools.wraps.

import functools

def verifier(func):
    @functools.wraps(func)
    def enveloppe(*args, **kwargs):
        print(f"Vérification avant {func.__name__}")
        return func(*args, **kwargs)
    return enveloppe

@verifier
def soustraire(x, y):
    """Retourne la différence entre x et y."""
    return x - y

print(soustraire.__name__)  # soustraire
print(soustraire.__doc__)   # Retourne la différence entre x et y.

Sans @functools.wraps, ces deux attributs afficheraient ceux de la fonction interne enveloppe.

Applications concrètes

Journalisation des appels

Le décorateur suivant enregistre les entrées et sorties d'une fonction dans le journal applicatif :

import logging
import functools

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mon_application")

def tracer(func):
    @functools.wraps(func)
    def enveloppe(*args, **kwargs):
        logger.info(f"Entrée dans {func.__name__} - args={args}, kwargs={kwargs}")
        valeur = func(*args, **kwargs)
        logger.info(f"Sortie de {func.__name__} -> {valeur}")
        return valeur
    return enveloppe

@tracer
def totaliser(prix, quantite):
    return prix * quantite

totaliser(10, 3)

Mesure du temps d'exécution

On peut aussi créer un décorateur pour chronométrer une fonction :

import time
import functools

def chronometrer(func):
    @functools.wraps(func)
    def enveloppe(*args, **kwargs):
        debut = time.perf_counter()
        valeur = func(*args, **kwargs)
        duree = time.perf_counter() - debut
        print(f"{func.__name__} exécutée en {duree:.6f} seconde(s)")
        return valeur
    return enveloppe

@chronometrer
def attente():
    time.sleep(0.5)

attente()

Chaînage de décorateurs

Python autorise l'application de plusieurs décorateurs sur une même fonction. L'ordre d'application est important : le décorateur le plus proche de la fonciton est appliqué en premier.

@tracer
@chronometrer
def calculer(a, b):
    time.sleep(0.2)
    return a ** b

calculer(2, 10)

Dans cet exemple, chronometrer encapsule d'abord calculer, puis tracer encapsule le résultat. L'équivalent explicite est calculer = tracer(chronometrer(calculer)).

Exemple avancé : enregistrement automatique

Un décorateur peut servir à enregistrer automatiquement des fonctions dans un registre. Cette technique est utile pour construire des systèmes de commandes, de routes ou de plugins.

def inscrire(registre, nom=None):
    def decorateur(func):
        cle = nom or func.__name__
        registre[cle] = func
        return func
    return decorateur

commandes = {}

@inscrire(commandes, "somme")
def ajouter(x, y):
    return x + y

@inscrire(commandes)
def produit(x, y):
    return x * y

print(commandes)
print(commandes["somme"](2, 3))

Chaque fonction décorée est ajoutée au dictionnaire commandes sous le nom choisi ou son propre nom. Cela évite de maintenir manuellement une liste d'opérations disponibles.

Conseils d'utilisation

  • Utilisez systématiquement functools.wraps pour ne pas perdre les métadonnées.
  • Évitez d'introduire des effets de bord inattendus dans les décorateurs (chargement de configuration, connexions réseau, etc.).
  • Lorsque le nombre de décorateurs augmente, envisagez des classes décorateurs ou des fonctions plus explicites pour maintenir la lisibilité.

Étiquettes: Décorateurs Python functools metaprogrammation Logging Python Chronométrage Python

Publié le 17 août à 04h59