Définition
Dix fonctions chargent des données, et vous voulez savoir combien de temps chacune y passe. La solution évidente consiste à coller un chronomètre au début et à la fin des dix, puis à tout reprendre le jour où l'affichage change.
Un décorateur existe pour cet ennui précis. C'est une fonction qui en reçoit une autre, l'enveloppe dans un comportement supplémentaire et renvoie la version enveloppée. Le chronomètre ne s'écrit alors qu'une fois, et s'applique sans toucher au code existant.
Tout tient dans un arobase posé juste au-dessus du def. Voici ce chronomètre une fois rangé dans un décorateur appelé mesurer_le_temps.
@mesurer_le_temps
def charger_les_clients():
...
charger_les_clients()
# charger_les_clients a pris 0.42 sLa fonction décorée garde son nom et s'appelle comme avant : rien ne change pour le code qui l'utilise déjà. Poser le même chronomètre sur une onzième fonction demande une ligne.
Ce que l'arobase remplace
L'arobase ressemble à une construction magique du langage, et ce sentiment bloque dès que quelque chose résiste. C'est pourtant un raccourci d'écriture, que Python remplace par un appel très ordinaire : les deux blocs ci-dessous font rigoureusement la même chose.
# Avec l'arobase
@mesurer_le_temps
def charger():
...
# Sans l'arobase, strictement équivalent
def charger():
...
charger = mesurer_le_temps(charger) # remplacée par sa version enveloppéeLa dernière ligne dit tout : mesurer_le_temps reçoit charger, en fabrique une version enveloppée et la range sous le même nom. En Python, une fonction est un objet comme un autre, qui se passe en argument et se renvoie avec un return.
Sur un décorateur qui n'attend aucun réglage, écrire @mesurer_le_temps() avec des parenthèses l'appelle sans lui donner de fonction à envelopper. Python lève un TypeError qui parle d'un argument manquant, jamais de l'arobase, et l'erreur se cherche alors au mauvais endroit.
Écrire le sien
Le squelette a trois étages : une fonction extérieure qui reçoit la fonction à décorer, une fonction intérieure qui l'appelle en ajoutant quelque chose autour, et le renvoi de cette fonction intérieure.
import functools
import time
def mesurer_le_temps(fonction): # fonction : celle qui est décorée
@functools.wraps(fonction) # recopie son nom et sa documentation
def enveloppe(*args, **kwargs):
depart = time.perf_counter()
resultat = fonction(*args, **kwargs) # l'appel d'origine, inchangé
duree = time.perf_counter() - depart
print(f"{fonction.__name__} a pris {duree:.2f} s")
return resultat
return enveloppe # elle prend la place de la fonctionComment l'enveloppe parle-t-elle encore de fonction alors que mesurer_le_temps a fini de s'exécuter ? Parce qu'elle est une closure : elle emporte avec elle les variables de l'endroit où elle est née.
Les args et kwargs ne sont pas décoratifs : sans eux, l'enveloppe n'accepterait aucun paramètre et casserait la première fonction qui en attend un. Oublier le return coûte aussi cher, puisque l'enveloppe renvoie alors None et que la panne surgit ailleurs.
L'arobase agit une seule fois, quand Python lit la définition, donc à l'import du module. Ce qui tourne à chaque appel, c'est l'enveloppe. Un décorateur qui lit une configuration au passage le fait donc au démarrage, même si la fonction décorée n'est jamais appelée.
Le piège de l'identité perdue
Ce qui est renvoyé, c'est l'enveloppe : le reste du programme ne voit plus la fonction d'origine, mais son remplaçant. Sans functools.wraps, le __name__ de la fonction décorée devient "enveloppe", sa docstring disparaît et l'aide intégrée ne raconte plus rien d'utile.
Le tableau compare ce que voit le reste du programme dans les deux cas.
| Sans functools.wraps | Avec functools.wraps |
|---|---|
charger.__name__ vaut "enveloppe" | Il vaut bien "charger" |
| La documentation d'origine est perdue | Elle est recopiée sur l'enveloppe |
| Les rapports de test nomment tout pareil | Chaque fonction garde son nom |
Le symptôme arrive plus tard, et rarement là où on le cherche : un framework qui enregistre ses routes par nom de fonction refuse la deuxième, puisque toutes portent désormais le même nom. Le message d'erreur, lui, parle d'une route en double.
Ceux qu'on utilise sans les écrire
Avant d'écrire le vôtre, regardez ceux qui existent déjà : la plupart des décorateurs croisés au quotidien viennent de la bibliothèque standard ou d'un framework comme Flask ou pytest.
Voici les cinq que vous rencontrerez en premier, avec ce que chacun change là où il apparaît.
| Décorateur | Ce qu'il change |
|---|---|
property | Une méthode s'utilise comme un attribut |
staticmethod | La méthode n'attend plus self |
classmethod | Elle reçoit la classe plutôt que l'instance |
dataclass | Il écrit l'initialisation et la comparaison |
functools.lru_cache | Il garde en mémoire les résultats déjà calculés |
Un décorateur maison se justifie quand la même précaution se répète sur dix fonctions différentes. En dessous, une simple fonction appelée à la main reste plus lisible, et se débogue sans démêler deux niveaux d'appel.
Questions fréquentes
Peut-on empiler plusieurs décorateurs sur une même fonction ?
Oui, et l'ordre compte vraiment. Ils s'appliquent de bas en haut : celui écrit juste au-dessus du def enveloppe la fonction en premier, le suivant enveloppe le résultat. Inverser deux lignes peut donc placer un contrôle d'accès derrière une mise en cache, et servir à tout le monde une réponse calculée pour une seule personne.
Comment passer un paramètre à un décorateur ?
En ajoutant un étage : une fonction qui reçoit le paramètre et renvoie le décorateur, lequel renvoie l'enveloppe. C'est cet étage qui explique l'écriture @repeter(3), où la parenthèse appelle réellement repeter pour fabriquer le décorateur. Des parenthèses quand il y a un réglage à transmettre, aucune sinon.
Un décorateur ralentit-il le programme ?
Il ajoute un appel de fonction, dont le coût se compte en fractions de microseconde et reste invisible hors des boucles très serrées. Le vrai risque est ailleurs : un décorateur qui avale une erreur ou modifie un résultat rend le débogage bien plus difficile, parce que le comportement observé ne correspond plus au code lu. Notre formation Python les introduit une fois les fonctions maîtrisées.