Contactez-nous

Décorateurs : modifier le comportement des fonctions

Découvrez les décorateurs en Python, un outil puissant pour modifier ou étendre le comportement des fonctions et des classes de manière élégante et réutilisable. Maîtrisez la syntaxe '@' et créez vos propres décorateurs.

Qu'est-ce qu'un décorateur ? Définition et principe

En Python, un décorateur est une fonction qui prend une fonction en argument et retourne une nouvelle fonction qui étend ou modifie le comportement de la fonction d'origine, sans modifier son code source.

Les décorateurs permettent d'ajouter des fonctionnalités à des fonctions existantes de manière élégante et réutilisable. Ils sont souvent utilisés pour :

  • Ajouter des logs (journalisation).
  • Mesurer le temps d'exécution d'une fonction.
  • Valider les arguments d'une fonction.
  • Gérer la mise en cache des résultats d'une fonction (mémoïsation).
  • Synchroniser l'accès à des ressources partagées.
  • Enregistrer des fonctions (par exemple, pour un système de plugins).
  • Et bien d'autres choses...

Les décorateurs sont un exemple de métaprogrammation, c'est-à-dire de code qui manipule du code.

Syntaxe des décorateurs : l'opérateur @

La syntaxe des décorateurs en Python utilise l'opérateur `@`, suivi du nom du décorateur, placé juste avant la définition de la fonction à décorer.

Syntaxe :

@mon_decorateur
def ma_fonction(arguments):
    # Corps de la fonction
    # ...

Exemple (sans décorateur) :

def dire_bonjour():
    print("Bonjour !")

dire_bonjour()

Exemple (avec un décorateur simple qui ne fait rien pour l'instant) :

def mon_decorateur(fonction):
    return fonction  # Pour l'instant, le décorateur retourne la fonction inchangée

@mon_decorateur
def dire_bonjour():
    print("Bonjour !")

dire_bonjour()

Dans cet exemple, `@mon_decorateur` est équivalent à `dire_bonjour = mon_decorateur(dire_bonjour)`. C'est-à-dire que la fonction `dire_bonjour` est passée en argument à la fonction `mon_decorateur`, et le résultat (qui est pour l'instant la fonction `dire_bonjour` elle-même) est réaffecté au nom `dire_bonjour`.

L'opérateur `@` est un sucre syntaxique qui rend le code plus lisible. Sans cet opérateur, il faudrait écrire : `dire_bonjour = mon_decorateur(dire_bonjour)` ce qui est moins clair.

Créer un décorateur simple : envelopper une fonction

Pour créer un décorateur qui modifie réellement le comportement d'une fonction, il faut généralement définir une fonction interne (une "fonction wrapper") à l'intérieur du décorateur. Cette fonction interne appellera la fonction d'origine, mais pourra également effectuer des opérations avant et/ou après l'appel.

Exemple : Un décorateur qui affiche un message avant et après l'exécution de la fonction décorée

def mon_decorateur(fonction):
    def wrapper():  # Fonction interne (wrapper)
        print("Avant l'appel de la fonction.")
        fonction()  # Appel de la fonction d'origine
        print("Après l'appel de la fonction.")
    return wrapper

@mon_decorateur
def dire_bonjour():
    print("Bonjour !")

dire_bonjour()

Résultat de l'exécution :

Avant l'appel de la fonction.
Bonjour !
Après l'appel de la fonction.

Dans cet exemple :

  1. `mon_decorateur` est le décorateur. Il prend une fonction (`fonction`) en argument.
  2. `wrapper` est la fonction interne (le wrapper). Elle appelle la fonction d'origine (`fonction()`) et ajoute des instructions `print` avant et après.
  3. `mon_decorateur` retourne la fonction `wrapper`.
  4. `@mon_decorateur` applique le décorateur à la fonction `dire_bonjour`. C'est équivalent à `dire_bonjour = mon_decorateur(dire_bonjour)`.

Lorsque `dire_bonjour()` est appelée, c'est en fait la fonction `wrapper` qui est exécutée. `wrapper` affiche un message, appelle la fonction `dire_bonjour` originale, puis affiche un autre message.

Décorateurs avec arguments : fonctions imbriquées

Il est possible de créer des décorateurs qui prennent des arguments. Pour cela, il faut imbriquer une fonction supplémentaire.

Exemple : Un décorateur qui répète l'exécution d'une fonction un certain nombre de fois

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

@repeter(3)
def dire_bonjour(nom):
    print("Bonjour,", nom)

dire_bonjour("Alice")

Résultat de l'exécution :

Bonjour, Alice
Bonjour, Alice
Bonjour, Alice

Dans cet exemple:

  1. `repeter` est une fonction qui prend un argument (`n`) et retourne un décorateur.
  2. `decorateur` est le décorateur lui-même. Il prend une fonction en argument et retourne le `wrapper`.
  3. `wrapper` est la fonction interne qui appelle la fonction d'origine `n` fois. Notez l'utilisation de `*args` et `**kwargs` pour accepter n'importe quels arguments (voir section suivante).

`@repeter(3)` applique le décorateur `repeter` avec l'argument `3` à la fonction `dire_bonjour`. C'est équivalent à `dire_bonjour = repeter(3)(dire_bonjour)`.

Gérer les arguments de la fonction décorée : *args et **kwargs

Lorsque vous créez un décorateur, vous ne savez pas toujours quels arguments la fonction décorée prendra. Pour rendre votre décorateur générique, vous pouvez utiliser `*args` et `**kwargs` dans la définition de la fonction `wrapper`.

  • `*args` permet de capturer tous les arguments positionnels dans un tuple.
  • `**kwargs` permet de capturer tous les arguments nommés dans un dictionnaire.

En utilisant `*args` et `**kwargs`, votre décorateur peut fonctionner avec des fonctions qui prennent n'importe quels arguments.

Exemple (reprise du décorateur `mon_decorateur` précédent, mais avec `*args` et `**kwargs`) :

def mon_decorateur(fonction):
    def wrapper(*args, **kwargs):
        print("Avant l'appel de la fonction.")
        resultat = fonction(*args, **kwargs)  # Appel de la fonction d'origine avec tous ses arguments
        print("Après l'appel de la fonction.")
        return resultat
    return wrapper

@mon_decorateur
def dire_bonjour(nom):
    print("Bonjour,", nom)

@mon_decorateur
def additionner(a, b):
    return a + b

dire_bonjour("Alice")
resultat = additionner(5, 3)
print("Résultat de l'addition :", resultat)

Dans cet exemple, le décorateur `mon_decorateur` peut maintenant être utilisé avec des fonctions qui prennent n'importe quels arguments (positionnels et/ou nommés).

Préserver les métadonnées de la fonction décorée : functools.wraps

Lorsque vous utilisez un décorateur, la fonction décorée perd son nom, sa docstring, et d'autres métadonnées. Ces métadonnées sont remplacées par celles de la fonction `wrapper`.

Pour préserver les métadonnées de la fonction d'origine, vous pouvez utiliser le décorateur `wraps` du module `functools`.

Exemple (sans `wraps`) :

def mon_decorateur(fonction):
    def wrapper(*args, **kwargs):
        """Docstring du wrapper."""
        return fonction(*args, **kwargs)
    return wrapper

@mon_decorateur
def ma_fonction():
    """Docstring de ma_fonction."""
    pass

print(ma_fonction.__name__)      # Affiche 'wrapper' (et non 'ma_fonction')
print(ma_fonction.__doc__)       # Affiche 'Docstring du wrapper.' (et non 'Docstring de ma_fonction.')

Exemple (avec `wraps`) :

from functools import wraps

def mon_decorateur(fonction):
    @wraps(fonction)  # Applique le décorateur wraps à la fonction wrapper
    def wrapper(*args, **kwargs):
        """Docstring du wrapper."""
        return fonction(*args, **kwargs)
    return wrapper

@mon_decorateur
def ma_fonction():
    """Docstring de ma_fonction."""
    pass

print(ma_fonction.__name__)      # Affiche 'ma_fonction' (correct)
print(ma_fonction.__doc__)       # Affiche 'Docstring de ma_fonction.' (correct)

Le décorateur `wraps` copie les métadonnées de la fonction d'origine (`fonction`) vers la fonction `wrapper`. Il est recommandé d'utiliser `wraps` dans tous vos décorateurs pour préserver les informations sur la fonction d'origine.

Il est important d'utiliser `@wraps` pour que les outils d'introspection (comme `help()`, les IDE, les débogueurs) fonctionnent correctement avec les fonctions décorées.

Exemples de décorateurs utiles

Voici quelques exemples de décorateurs qui peuvent être utiles dans différents contextes :

Décorateur pour mesurer le temps d'exécution d'une fonction :

import time
from functools import wraps

def timer(fonction):
    """Mesure le temps d'exécution d'une fonction."""
    @wraps(fonction)
    def wrapper(*args, **kwargs):
        debut = time.time()
        resultat = fonction(*args, **kwargs)
        fin = time.time()
        print(f"Temps d'exécution de {fonction.__name__}: {fin - debut:.4f} secondes")
        return resultat
    return wrapper

Décorateur pour logger les appels d'une fonction (avec ses arguments et sa valeur de retour) :

from functools import wraps

def logger(fonction):
    """Log les appels d'une fonction."""
    @wraps(fonction)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Appel de {fonction.__name__}({signature})")
        resultat = fonction(*args, **kwargs)
        print(f"{fonction.__name__} a retourné {resultat!r}")
        return resultat
    return wrapper

Décorateur pour memoïzer (mettre en cache) les résultats d'une fonction :

from functools import wraps

def memoize(fonction):
 """Memoize une fonction (met en cache ses résultats)."""
 cache = {}

 @wraps(fonction)
 def wrapper(*args):
  if args not in cache:
   cache[args] = fonction(*args)
  return cache[args]
 return wrapper

Ces exemples montrent comment les décorateurs peuvent être utilisés pour ajouter des fonctionnalités à des fonctions existantes de manière propre et réutilisable.