Ce qu’un décorateur fait vraiment à votre fonction

Vous voulez mesurer le temps de chaque fonction qui appelle une API externe lente. Vous pouvez coller des appels à time.perf_counter() dans chacune, ou écrire la mesure une seule fois et l’appliquer partout. Un décorateur est la deuxième option : c’est une fonction qui prend une autre fonction, l’enveloppe dans un comportement supplémentaire et renvoie la version enveloppée sous le même nom.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@timed
def fetch_price(ticker):
    time.sleep(0.2)  # à la place d'un appel réseau
    return 142.50

fetch_price("AAPL")  # fetch_price took 0.201s

@timed au-dessus de def fetch_price est le raccourci de fetch_price = timed(fetch_price). Python appelle timed une seule fois, juste après la définition de fetch_price, et réassigne le nom fetch_price à ce que timed renvoie. C’est tout ce que fait le @.

Comment fonctionnent les décorateurs : fonctions comme objets et fermetures

En Python, les fonctions sont des objets. Vous pouvez en passer une en argument et en renvoyer une depuis une autre fonction, comme vous le feriez avec une liste. wrapper est aussi une fermeture (closure) : elle capture func depuis la portée qui l’entoure et continue à l’utiliser à chaque exécution, longtemps après que timed a terminé.

C’est tout le mécanisme. Les décorateurs avec arguments, les décorateurs basés sur des classes et les décorateurs intégrés comme @property ne sont que des variations de ce principe.

Comment functools.wraps conserve le nom et la docstring de la fonction

L’exemple de timed contient un bug que vous ne voyez pas avant d’inspecter le résultat :

1
2
print(fetch_price.__name__)  # wrapper
print(fetch_price.__doc__)   # None

fetch_price a perdu son nom et sa docstring, car c’est wrapper qui est renvoyé. Tout ce qui lit les métadonnées de la fonction voit wrapper à la place : help(), les débogueurs, les frameworks web qui dérivent les noms de route ou d’endpoint à partir de la fonction. Corrigez cela avec functools.wraps :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

wraps copie __name__, __doc__, __module__ et __qualname__ (ainsi que __annotations__) de l’original vers le wrapper, et ajoute un attribut __wrapped__ qui pointe vers lui. Cela coûte une ligne. Mettez @wraps(func) sur chaque wrapper que vous écrivez.

Comment écrire un décorateur avec des arguments

Un décorateur simple prend un seul argument : la fonction. Dès que le décorateur a besoin de sa propre configuration, comme le nombre de tentatives et le délai d’attente, vous ajoutez un niveau : une fonction qui prend la configuration et renvoie un décorateur.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import time
from functools import wraps

def retry(times=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    last_error = e
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(times=5, delay=2)
def fetch_price(ticker):
    ...

Lisez-le de l’extérieur vers l’intérieur. retry(times=5, delay=2) s’exécute en premier et renvoie decorator. decorator(fetch_price) s’exécute ensuite et renvoie wrapper. wrapper est ce qui s’exécute à chaque appel de fetch_price(...).

Trois niveaux d’imbrication sont plus difficiles à lire qu’un seul. Si times et delay ne changent jamais dans votre code, fixez-les et revenez à la forme à un seul argument.

Comment écrire un décorateur basé sur une classe

Tout objet avec une méthode __call__ peut servir de décorateur. Passez à une classe quand le décorateur doit conserver un état entre les appels, pas seulement une configuration :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class CountCalls:
    def __init__(self, func):
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def process_order(order_id):
    ...

process_order(101)
process_order(102)
print(process_order.calls)  # 2

@CountCalls remplace process_order par une instance de CountCalls, qui conserve calls entre les appels. Une fermeture avec un compteur nonlocal pourrait faire la même chose, mais la classe rend l’état explicite et vous donne un endroit où placer une méthode reset().

Deux pièges à connaître. wraps ne s’applique pas directement ici : appelez functools.update_wrapper(self, func) dans __init__ si vous voulez les métadonnées. Et cette version échoue sur les méthodes : l’instance n’a pas de __get__, donc le self de la méthode décorée n’est jamais transmis. Pour décorer des méthodes, un décorateur basé sur une fonction est le choix le plus simple.

Décorateurs intégrés : @staticmethod, @property et @lru_cache

Les décorateurs fournis par Python utilisent le même mécanisme, c’est pourquoi ils cessent de paraître obscurs une fois que vous en avez écrit un vous-même :

  • @staticmethod et @classmethod changent ce qu’une méthode reçoit comme premier argument : rien de plus, ou la classe à la place de l’instance.
  • @property transforme un appel de méthode en accès à un attribut, obj.value au lieu de obj.value(), tout en continuant à exécuter du code en dessous.
  • @functools.lru_cache(maxsize=128) renvoie un résultat mis en cache quand la fonction est appelée à nouveau avec les mêmes arguments. @functools.cache (Python 3.9+) fait la même chose sans limite de taille. Utilisez cache quand l’ensemble des entrées possibles est petit et borné, lru_cache sinon.
1
2
3
4
5
6
7
from functools import lru_cache

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

Sans le cache, fibonacci(35) recalcule les mêmes résultats intermédiaires des millions de fois. Avec lui, chaque n n’est calculé qu’une fois.

Dans quel ordre s’exécutent les décorateurs empilés ?

Les décorateurs s’appliquent de bas en haut et s’exécutent de haut en bas :

1
2
3
4
@retry(times=3)
@timed
def fetch_price(ticker):
    ...

timed enveloppe fetch_price en premier, et retry enveloppe le résultat. À l’appel, le wrapper de retry s’exécute en premier et appelle la version chronométrée à chaque tentative. Ainsi, timed affiche un chiffre par tentative et ne compte jamais l’attente entre les tentatives. Inversez les deux lignes et timed se retrouve à l’extérieur de retry, rapportant tout le cycle de nouvelles tentatives, attentes incluses, en un seul chiffre.

Quand un décorateur est-il le mauvais outil ?

Les décorateurs masquent le flux de contrôle. Une fonction dont le corps ne fait qu’une ligne pourrait ouvrir une transaction en base de données et vérifier une permission avant que cette ligne ne s’exécute, sans que rien au point d’appel ne vous le dise. Ce compromis en vaut la peine pour des responsabilités transversales à de nombreuses fonctions, qui doivent se comporter de façon identique partout : journalisation, chronométrage, cache, authentification. Cela devient un problème quand le comportement n’appartient qu’à une seule fonction. Écrivez-le en ligne, là où le prochain lecteur le verra.

Les traces de pile (stack traces) deviennent aussi plus difficiles à lire. Trois décorateurs empilés ajoutent trois frames supplémentaires entre l’appelant et votre code. Si un décorateur absorbe des exceptions (comme retry qui intercepte ConnectionError) ou modifie la valeur de retour, indiquez-le dans la docstring de la fonction décorée, que wraps garde visible. La prochaine personne qui lira le traceback a besoin de cet indice.

Quand extraire un décorateur dans votre propre code ?

Le signal, c’est la répétition : les mêmes cinq lignes de préparation ou de nettoyage en tête d’une troisième fonction. Extrayez-les dans un décorateur avant d’écrire la quatrième copie. Si vous expérimentez, faites-le dans un environnement isolé comme décrit dans Environnements Virtuels Python : venv, pip et uv. Quand vous ajoutez des tests pour le décorateur, testez-le d’abord contre une fonction factice minimale : la stratification décrite dans Tests Unitaires vs Tests d’Intégration vs Tests End-to-End s’applique aussi ici, et un décorateur est à peu près ce qu’il y a de plus testable au niveau unitaire.

Un cas où vous devriez vous arrêter : si ce que vous voulez injecter est une dépendance, comme une connexion à une base de données ou un client HTTP configuré, ce n’est pas un comportement transversal. Passez-la par le constructeur, comme montré dans Qu’est-ce que la Dependency Injection (avec Exemples).

Articles associés