Les décorateurs : ajouter un comportement à une fonction sans y toucher

Objectifs de la leçon
  • Expliquer ce qu’est un décorateur et comment il est construit à partir d’une fonction d’ordre supérieur et d’une closure
  • Écrire un décorateur avec une fonction wrapper et l’appliquer avec @
  • Écrire un wrapper adapté à n’importe quelle fonction grâce à *args et **kwargs
  • Reconnaître les deux erreurs courantes — un wrapper sans return, et return wrapper() avec des parenthèses

Qu’allons-nous apprendre ?

  • Ce qu’est un décorateur (Decorator) et pourquoi il est utile.
  • Comment construire un décorateur : une fonction qui reçoit une fonction et renvoie un wrapper (une enveloppe).
  • La syntaxe @.
  • Un wrapper adapté à n’importe quelle fonction, avec *args et **kwargs.

Pourquoi en avons-nous besoin ?

Nous voulons afficher un message à chaque appel d’une fonction — pour le débogage :

def add(a, b):
    print("Calling add")
    return a + b

def multiply(a, b):
    print("Calling multiply")
    return a * b

Les problèmes :

  • La même ligne est copiée dans chaque fonction — encore la duplication que DRY interdit.
  • La logique se mélange à du code d’aide qui n’a rien à voir avec elle.
  • Pour supprimer les affichages, il faut parcourir toutes les fonctions et les effacer.

Un décorateur permet d’écrire le comportement supplémentaire une seule fois, et de le poser sur n’importe quelle fonction en une ligne.

Explication à partir des bases

De quoi est fait un décorateur

Un décorateur combine deux choses apprises dans les leçons précédentes :

  • La fonction d’ordre supérieur — il reçoit une fonction et renvoie une fonction.
  • La closure — la fonction qu’il renvoie se souvient de la fonction d’origine.

La structure est toujours la même :

  1. Le décorateur reçoit la fonction d’origine (func).
  2. À l’intérieur, on définit une nouvelle fonction — le wrapper — qui fait quelque chose de plus, et appelle func.
  3. Le décorateur renvoie wrapper — sans parenthèses.

La syntaxe @

@log_call
def add(a, b):
    return a + b

C’est exactement comme écrire après la définition :

add = log_call(add)

Autrement dit : le nom add pointe maintenant vers le wrapper, et le wrapper appelle l’add d’origine.

Analogie : emballer un cadeau

Le cadeau (la fonction) ne change pas. Vous l’enveloppez de papier (le wrapper) — et celui qui le reçoit rencontre d’abord l’emballage, et seulement ensuite le cadeau. On peut envelopper n’importe quel cadeau dans le même type de papier, et on peut retirer l’emballage sans abîmer le cadeau.

Syntaxe

def my_decorator(func):              # 1. receive the original function
    def wrapper(*args, **kwargs):    # 2. a new function that wraps it
        # ... something before ...
        result = func(*args, **kwargs)
        # ... something after ...
        return result                # return the original result
    return wrapper                   # 3. return the wrapper (no parentheses)

@my_decorator                        # same as: my_function = my_decorator(my_function)
def my_function():
    ...

Premier exemple de code

week-2/log_call.py :

def log_call(func):
    def wrapper(a, b):
        print("Calling", func.__name__)
        return func(a, b)
    return wrapper

@log_call
def add(a, b):
    return a + b

@log_call
def multiply(a, b):
    return a * b

print(add(2, 3))
print(multiply(4, 5))

Sortie :

Calling add
5
Calling multiply
20

Analyse du code ligne par ligne

LigneExplication
def log_call(func):Le décorateur reçoit la fonction d’origine.
def wrapper(a, b):L’enveloppe — elle reçoit les mêmes paramètres que l’originale.
print("Calling", func.__name__)Le comportement supplémentaire. __name__ est le nom de la fonction.
return func(a, b)Appelle l’originale, et renvoie son résultat.
return wrapperLe décorateur renvoie le wrapper — sans parenthèses.
@log_calladd = log_call(add). Désormais add(2, 3) exécute le wrapper.

Un autre exemple

Un wrapper adapté à n’importe quelle fonction. Le wrapper précédent ne fonctionne qu’avec des fonctions qui reçoivent exactement a, b. Avec *args et **kwargs, il reçoit n’importe quel paramètre et le transmet. week-2/log_any.py :

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__, "with", args, kwargs)
        return func(*args, **kwargs)
    return wrapper

@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name

@log_call
def total(*prices):
    return sum(prices)

print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))

Sortie :

Calling greet with ('Dana',) {'greeting': 'Hi'}
Hi, Dana
Calling total with (10, 20, 30) {}
60

Un exemple utile : mesurer le temps. week-2/timed.py :

import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        seconds = round(time.time() - start, 2)
        print(func.__name__, "took", seconds, "seconds")
        return result
    return wrapper

@timed
def slow_sum(n):
    time.sleep(0.5)
    return sum(range(n))

print(slow_sum(1000))

Sortie :

slow_sum took 0.5 seconds
499500

(Le temps exact varie un peu d’une exécution à l’autre.) Le même @timed peut être posé sur n’importe quelle fonction du programme — sans changer une seule ligne à l’intérieur.

Erreur courante

Deux erreurs qui arrivent à presque tous ceux qui écrivent un premier décorateur :

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        func(*args, **kwargs)
    return wrapper()
  1. Pas de return dans le wrapper. La fonction d’origine s’exécute, mais son résultat est perdu — et la fonction décorée renvoie toujours None. La correction : return func(*args, **kwargs).
  2. return wrapper() avec des parenthèses. Cela appelle le wrapper dès la définition, et renvoie son résultat au lieu de la fonction. La correction : return wrapper.

La règle : le wrapper renvoie le résultat, et le décorateur renvoie le wrapper.

Exercices

Les exercices n’utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d’abord seul, et n’ouvrez la solution qu’ensuite.

Exercice 1: Un premier décorateur

Écrivez un décorateur nommé log_call qui affiche Calling <name> avant chaque appel à la fonction, et appliquez-le avec @ à add et à multiply.

print(add(2, 3))
print(multiply(4, 5))

Résultat attendu :

Calling add
5
Calling multiply
20
Afficher la solution
def log_call(func):
    def wrapper(a, b):
        print("Calling", func.__name__)
        return func(a, b)
    return wrapper


@log_call
def add(a, b):
    return a + b


@log_call
def multiply(a, b):
    return a * b


print(add(2, 3))
print(multiply(4, 5))
Calling add
5
Calling multiply
20

@log_call au-dessus de def add équivaut à add = log_call(add). Désormais add pointe vers le wrapper.

Exercice 2: Un wrapper adapté à n’importe quelle fonction

Le wrapper précédent ne fonctionne qu’avec des fonctions qui reçoivent exactement deux paramètres. Réécrivez log_call pour qu’il fonctionne avec n’importe quelle fonction.

@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name

@log_call
def total(*prices):
    return sum(prices)

print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))

Résultat attendu :

Calling greet
Hi, Dana
Calling total
60
Afficher la solution
def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper


@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name


@log_call
def total(*prices):
    return sum(prices)


print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))
Calling greet
Hi, Dana
Calling total
60

*args et **kwargs rassemblent tous les arguments, et func(*args, **kwargs) les transmet tels quels.

Exercice 3: Vérifier l’entrée

Écrivez un décorateur nommé positive_only pour des fonctions qui reçoivent un seul nombre : si le nombre est négatif, il lance une ValueError et n’appelle pas du tout la fonction. Le message contient le nom de la fonction (func.__name__) et le nombre reçu, comme dans le résultat attendu.

@positive_only
def square_root(n):
    return n ** 0.5

print(square_root(16))
try:
    square_root(-4)
except ValueError as error:
    print("error:", error)

Résultat attendu :

4.0
error: square_root needs a positive number, got -4
Afficher la solution
def positive_only(func):
    def wrapper(n):
        if n < 0:
            raise ValueError(func.__name__ + " needs a positive number, got " + str(n))
        return func(n)
    return wrapper


@positive_only
def square_root(n):
    return n ** 0.5


print(square_root(16))
try:
    square_root(-4)
except ValueError as error:
    print("error:", error)
4.0
error: square_root needs a positive number, got -4

La vérification a lieu avant l’appel à func — si l’entrée est invalide, la fonction d’origine ne s’exécute pas du tout.

Exercice 4: Compter les appels

Écrivez un décorateur nommé count_calls, qui compte combien de fois la fonction a été appelée. À chaque appel, avant l’exécution de la fonction, il affiche le nom de la fonction et le numéro de l’appel — comme dans le résultat attendu.

@count_calls
def say_hi():
    print("Hi")

say_hi()
say_hi()

Résultat attendu :

say_hi call #1
Hi
say_hi call #2
Hi
Afficher la solution
def count_calls(func):
    count = 0

    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(func.__name__, "call #" + str(count))
        return func(*args, **kwargs)

    return wrapper


@count_calls
def say_hi():
    print("Hi")


say_hi()
say_hi()
say_hi call #1
Hi
say_hi call #2
Hi

count appartient à count_calls, et le wrapper s’en souvient (une closure). Comme il modifie count, il a besoin de nonlocal.

Exercice 5: Deux erreurs

def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        result.upper()
    return wrapper()

Le décorateur est censé renvoyer le résultat de la fonction en majuscules. Trouvez deux erreurs et corrigez-les.

@shout
def greet(name):
    return "hello " + name

print(greet("dana"))

Résultat attendu :

HELLO DANA
Afficher la solution
  1. return wrapper() avec des parenthèses — appelle le wrapper dès la définition, au lieu de le renvoyer. Il faut return wrapper.
  2. Le wrapper ne renvoie rien — result.upper() crée une nouvelle chaîne, mais elle n’est pas renvoyée. Il faut return result.upper().
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper


@shout
def greet(name):
    return "hello " + name


print(greet("dana"))
HELLO DANA

La règle : le wrapper renvoie le résultat, et le décorateur renvoie le wrapper.

Résumé

  • Décorateur = une fonction qui reçoit une fonction, l’enveloppe dans un wrapper avec un comportement supplémentaire, et renvoie le wrapper.
  • Il est construit à partir d’une fonction d’ordre supérieur et d’une closure — les deux sujets des leçons précédentes.
  • @decorator au-dessus d’un def = func = decorator(func).
  • Avec *args et **kwargs, le wrapper s’adapte à n’importe quelle fonction.
  • Le wrapper renvoie le résultat (return func(...)), et le décorateur renvoie le wrapper (return wrapper, sans parenthèses).

Documentation officielle