Vous les avez sans doute déjà vus quelque part, vous les avez peut-être même déjà utilisés sans trop savoir ce que c’est, ni comment cela fonctionne : je parle ici des décorateurs. Il s’agit de ces « mots-clés » préfixés par un @
qui précèdent certaines fonctions ou méthodes en Python.
Je vais tenter dans cet article d’expliquer le fonctionnement des décorateurs. Nous verrons ensuite comment créer nos propres décorateurs, et les possibilités qui nous sont offertes par cette fonctionnalité.
Quelques exemples pour démarrer
Si vous êtes habitué à la programmation orientée objet en Python (ce qui un plus pour la compréhension de cet article, mais pas indispensable), alors vous avez déjà certainement utilisé l’un des décorateurs suivants : @classmethod
ou @staticmethod
, permettant respectivement de déclarer une méthode de classe et une méthode statique (je ne détaillerai pas la différence entre les deux).
Ces décorateurs sont fournis par le langage, et s’utilisent comment ceci :
class MaJolieClasse:
@classmethod
def ma_methode_de_classe(cls):
print("Je suis une méthode de la classe", cls.__name__)
@staticmethod
def ma_methode_statique():
print("Je suis une méthode statique")
Il en existe d’autres bien entendu, et pour mieux comprendre comment ils fonctionnent, l’idéal serait de créer notre propre décorateur.
Les décorateurs décryptés
Je vais lever très rapidement le voile sur les décorateurs : il ne s’agit ni plus ni moins que de simples fonctions.
Cela signifie que lorsque vous voyez ceci dans un programme …
@mon_decorateur
def ma_fonction():
pass
… quelque part dans le code il y a quelque chose qui ressemble à cela (pas exactement mais on y vient) :
def mon_decorateur(...):
...
Pour être plus précis, un décorateur est une fonction qui se doit de respecter deux critères pour être considéré comme tel :
- la fonction doit prendre une fonction en paramètre ;
- la fonction doit retourner une fonction.
Ok, dit comme ça cela peut paraître un peu bizarre. Mais nous allons détailler cela.
Pour reprendre l’exemple précédent, la fonction que mon_decorateur
va prendre en paramètre ici est ma_fonction
. Et celle qu’il va retourner est la fonction qui va remplacer ma_fonction
.
Prenez le temps de lire et relire cela tranquillement.
On y vient donc …
Un décorateur permet de remplacer une fonction.
Appliquer un décorateur à une fonction, cela revient donc à faire cela :
def ma_fonction():
pass
ma_fonction = mon_decorateur(ma_fonction)
Cette écriture équivaut à celle-ci, qui est un sucre syntaxique :
@mon_decorateur
def ma_fonction():
pass
Les fonctions : des objets comme les autres
Pour mieux comprendre ce que nous avons fait jusqu’ici, il est important de poser une base importante : les fonctions sont des objets. Et quand je dis objet, j’entends par là programmation orientée objet bien sûr.
Si j’affirme qu’il n’y a aucune différence structurelle entre une list
, un str
, une MaJolieClasse
, un int
et une fonction en Python ai-je tort ? Pas vraiment. Vous le savez peut-être, mais en Python, absolument tout est objet. Et les fonctions n’y échappent pas : ce sont des instances d’un type function
.
Revenons à nos moutons. Dans le point précédent, nous avions défini qu’un décorateur devait prendre une fonction en paramètre. Ça, c’est la partie « facile » des décorateurs : étant donné que les paramètres ne sont pas typés en Python, il suffit simplement de déclarer un paramètre au décorateur, que l’on nommerait fonction
par exemple. Ou bien ancienne_fonction
, je pense que c’est mieux. Oui, on va l’appeler comme ça : ancienne_fonction
. On reste là-dessus.
def mon_decorateur(ancienne_fonction):
...
On a déjà coché la première case ✅
À présent, mon_decorateur
doit retourner une fonction. Là, ça se complique un petit chouïa. Mais pas de panique. Rappelez-vous : les fonctions ne sont rien de plus que des objets. Donc au lieu de retourner un int
ou une list
, on va retourner une fonction.
On pourrait retourner une lambda :
def mon_decorateur(ancienne_fonction):
return lambda x: x ** 2
Et là on coche toutes les cases. Mais cet exemple n’a pas vraiment de sens. L’idéal serait d’avoir une vraie fonction, qui fasse plus d’une ligne.
De plus, le but d’un décorateur n’est pas vraiment de remplacer la fonction décorée, mais plutôt de la modifier. De ce fait, la fonction qui sera retournée par le décorateur, et qui vise à remplacer la fonction décorée (pour rappel, ancienne_fonction
), devrait se comporter au moins de la même manière que la fonction qu’elle remplace, plus lui ajouter des comportements supplémentaires : d’où la notion de décoration. On garde la base, et on lui ajoute des fonctionnalités supplémentaires.
Pour ce faire, nous allons réaliser une opération peu habituelle : déclarer une fonction dans une fonction. Oui, en Python on peut. Étant donné qu’elle remplacera l’ancienne fonction, on l’appellera nouvelle_fonction
.
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction():
...
return nouvelle_fonction
On n’y est pas encore mais on s’en rapproche. On déclare nouvelle_fonction
dans mon_decorateur
: remarquez bien le niveau d’indentation. On s’occupera du contenu de nouvelle_fonction
plus tard, mais j’aimerais que l’on se concentre sur la dernière instruction.
C’est là que ça devient intéressant : mon_decorateur
retourne l’objet nouvelle_fonction
. Pas le retour de nouvelle_fonction
, non. La nouvelle_fonction
en tant qu’objet de type function
. Remarquez bien qu’il n’y a pas de parenthèse après nouvelle_fonction
, car on ne souhaite pas retourner le résultat de son exécution, on ne souhaite même pas l’exécuter tout court (pas maintenant du moins).
Pour rappel, comme on l’a vu dans le point précédent, voici ce qui va se passer ensuite :
@mon_decorateur
def ma_fonction():
print("Je suis une fonction sympa")
# Équivaut à :
# ma_fonction = mon_decorateur(ma_fonction)
On affecte à ma_fonction
le retour de mon_decorateur
. Donc, en d’autres termes, l’objet fonction nouvelle_fonction
. Donc, ma_fonction
va se comporter désormais exactement comme nouvelle_fonction
, et perdre son comportement initial. Il nous suffit de faire un sorte que nouvelle_fonction
exécute ancienne_fonction
afin de conserver le comportement initial. Donc dans cet exemple, le comportement de ma_fonction
. Vous suivez ?
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction():
return ancienne_fonction()
return nouvelle_fonction
Et voilà. Pour le moment, on a un décorateur qui n’applique aucune modification, mais ne pas avoir de régression est déjà une réussite en soi. Désormais, si on exécute ma_fonction
(pour rappel, qui est la fonction décorée), il ne se passera rien de plus (ni de moins) que si on ne l’avait pas décorée.
Prendre en compte tous les scénarios
L’idée d’un décorateur, sauf cas particulier, est d’être le plus générique possible. Créer un décorateur qui sera appliqué à une ou deux fonctions tout au plus n’a aucun intérêt : les décorateurs sont utilisés dans un but de factorisation de code (mais pas que), et non pas pour le complexifier. Nous verrons à la fin de l’article quelques cas d’utilisations concrets de décorateurs, mais de manière générale, ils s’avèrent pratiques afin d’éviter des redondances de code dans plusieurs fonctions.
Reprenons l’exemple précédent. Que se passe-t-il si l’on applique mon_decorateur
à une_autre_fonction
, qui attend deux paramètres ?
@mon_decorateur
def ma_fonction():
print("Je suis une fonction sympa")
@mon_decorateur
def une_autre_fonction(a, b):
return a ** b
une_autre_fonction(5, 3)
Nous avons une erreur lorsqu’on appelle une_autre_fonction
:
TypeError: nouvelle_fonction() takes 0 positional arguments but 2 were given
C’est normal : le comportement de une_autre_fonction
a été remplacé par celui de nouvelle_fonction
. Et nouvelle_fonction
attend 0 paramètre. D’où l’erreur. L’astuce pourrait être de faire en sorte que nouvelle_fonction
attende 2 paramètres, mais le problème surviendrait alors lorsqu’on appelle ma_fonction
.
Le décorateur devant être générique, nous allons faire en sorte que nouvelle_fonction
puisse être appelée avec un nombre indéterminé d’arguments :
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction(*args):
return ancienne_fonction(*args)
return nouvelle_fonction
@mon_decorateur
def ma_fonction():
print("Je suis une fonction sympa")
@mon_decorateur
def une_autre_fonction(a, b):
return a ** b
ma_fonction()
une_autre_fonction(5, 3)
Voilà qui est mieux.
La nouvelle_fonction
peut recevoir n’importe quel nombre d’arguments, puis les transmet en entrée de ancienne_fonction
. Ici, on indique ancienne_fonction(*args)
, en précisant bien l’astérisque devant *args
, car on ne souhaite pas transmettre les arguments sous la forme d’un tuple
, mais en tant qu’arguments individuels. Je ne rentrerai pas plus que ça dans le détail sur ce point.
Si on souhaite vraiment couvrir tous les cas de figure, il faudrait également gérer le cas des paramètres nommés :
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction(*args, **kwargs):
return ancienne_fonction(*args, **kwargs)
return nouvelle_fonction
@mon_decorateur
def ma_fonction():
print("Je suis une fonction sympa")
@mon_decorateur
def une_autre_fonction(a, b):
return a ** b
ma_fonction()
une_autre_fonction(b=3, a=5)
Et maintenant, on fait quoi ?
À présent, le champ des possibles s’ouvre devant nous. Commençons par quelque chose de simple : faire un print
des arguments de la fonction décorée avant de l’exécuter.
Et voilà :
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments : {args} {kwargs}")
return ancienne_fonction(*args, **kwargs)
return nouvelle_fonction
On peut faire de même avec le retour de la fonction. Cependant, étant donné qu’on ne peut rien écrire après un return
, il faudra stocker le résultat dans une variable avant de le retourner.
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments : {args} {kwargs}")
resultat = ancienne_fonction(*args, **kwargs)
print(f"Résultat : {resultat}")
return resultat
return nouvelle_fonction
L’avantage du décorateur, en tant que fonction englobante, est qu’il a accès à toutes les informations à la fois de ancienne_fonction
, et à la fois de nouvelle_fonction
.
On peut par exemple afficher le nom de la fonction dans les print
précédents :
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
resultat = ancienne_fonction(*args, **kwargs)
print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
return resultat
return nouvelle_fonction
Si on appelle ma_fonction
et une_autre_fonction
, on aurait un résultat comme celui-ci :
Arguments de ma_fonction : ()
Résultat de ma_fonction : None
Arguments de une_autre_fonction : (5, 3)
Résultat de une_autre_fonction : 125
Il est important de préciser que les instructions que nous avons ajouté seront exécutées lors de l’appel aux fonctions décorées (donc en l’occurence ici, ma_fonction
et une_autre_fonction
). Il y aura autant de print
des arguments de une_autre_fonction
que d’appels à une_autre_fonction
dans le programme.
Que se passe-t-il à présent si l’on déclare des instructions en dehors de nouvelle_fonction
(directement dans mon_decorateur
) ?
def mon_decorateur(ancienne_fonction):
print("Une instruction déclarée dans mon_decorateur")
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
resultat = ancienne_fonction(*args, **kwargs)
print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
return resultat
return nouvelle_fonction
Cette instruction-là serait exécutée lors de l’appel à mon_decorateur
. Et à quel moment mon_decorateur
est appelé ? Tout simplement à la déclaration de ma_fonction
et une_autre_fonction
, c’est-à-dire lorsqu’on applique ce décorateur à ces fonctions.
# Le décorateur est exécuté ici
@mon_decorateur
def ma_fonction():
print("Je suis une fonction sympa")
# Le décorateur est exécuté ici
@mon_decorateur
def une_autre_fonction(a, b):
return a ** b
On pourrait écrire le print
après la déclaration de nouvelle_fonction, ça revient quasiment au même :
def mon_decorateur(ancienne_fonction):
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
resultat = ancienne_fonction(*args, **kwargs)
print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
return resultat
print("Une instruction déclarée dans mon_decorateur")
return nouvelle_fonction
Ajouter une couche dans le sandwich
On va encore un peu plus pousser le truc. Si vous êtes vraiment habitué à Python, vous avez alors peut-être déjà vu quelque chose comme cela :
@mon_decorateur(10)
def ma_fonction():
print("Je suis une fonction sympa")
Un décorateur qui prend un ou plusieurs arguments. Cela peut être des int
, des str
, ou même si on veut aller encore plus loin … une autre fonction (on verra un exemple dans le point suivant).
Avant de mettre cela en place, voyons à quoi ça correspond. L’exemple précédent équivaut à celui-ci :
def ma_fonction():
print("Je suis une fonction sympa")
ma_fonction = mon_decorateur(10)(ma_fonction)
Je conçois que ce n’est pas une écriture très habituelle. Mais analysons cela un peu plus en détail. mon_decorateur
est une fonction qui attend (a priori) un int
en paramètre, et retourne une fonction. La fonction retournée attend une fonction en paramètre, et retourne également une fonction. C’est cette fonction retournée que l’on affecte à ma_fonction
.
Si on décompose cette instruction en deux instructions, peut-être que ce sera un peu plus lisible :
def ma_fonction():
print("Je suis une fonction sympa")
traitement = mon_decorateur(10)
ma_fonction = traitement(ma_fonction)
Ce que l’on peut souligner ici, c’est que dans ce scénario, le décorateur n’est pas mon_decorateur
… mais bien traitement
. En effet, c’est cette fonction qui prend une fonction en paramètre et retourne une fonction. Afin d’éviter la confusion, je vais renommer mon_decorateur
en afficher_args
, cela sera un peu plus explicite.
Désormais, ce que l’on va devoir déclarer, c’est une fonction (afficher_args
) qui attend un booléen en paramètre (il déterminera si l’on doit afficher ou non le résultat). afficher_args
va déclarer une fonction qui est un décorateur (on va l’appeler traitement_deco
). traitement_deco
aura le comportement classique du décorateur comme vu précédemment (prendre une fonction en paramètre, déclarer une nouvelle fonction et retourner cette nouvelle fonction). Et enfin, afficher_args
va retourner traitement_deco
. D’où la couche supplémentaire dans le « sandwich », qui va prendre tout son sens à travers cet exemple :
def afficher_args(print_result):
def traitement_deco(ancienne_fonction):
@wraps(ancienne_fonctions)
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
resultat = ancienne_fonction(*args, **kwargs)
if print_result:
print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
return resultat
return nouvelle_fonction
return traitement_deco
@afficher_args(False)
def ma_fonction():
print("Je suis une fonction sympa")
@afficher_args(True)
def une_autre_fonction(a, b):
return a ** b
Gardez simplement en tête que désormais, le décorateur n’est pas afficher_args
, mais le retour de afficher_args
.
Emballer le sandwich
Nous avions attaché une grande importance au fait que notre décorateur soit le plus générique possible, et n’entraîne pas de régression. Pour autant, nous avons laissé un petit trou dans la raquette. Rien de dramatique, mais autant faire les choses bien jusqu’au bout.
Actuellement, si on tente d’afficher le nom des fonctions décorées dans la console, on aurait quelque chose comme cela :
print(ma_fonction.__name__)
print(une_autre_fonction.__name__)
nouvelle_fonction
nouvelle_fonction
C’est vraiment un détail, mais ce qu’il faut comprendre, c’est que non seulement le comportement des fonctions décorées a été remplacé par celui de nouvelle_fonction
, mais également le nom (ainsi que tous les autres attributs spéciaux, que je ne détaillerai pas).
Deux possibilités pour corriger cela :
- soit le faire manuellement, mais cela nécessite de connaître tous les attributs spéciaux de la fonction ;
- soit utiliser … un décorateur.
Alors rassurez-vous, ce décorateur est déjà fourni par Python, il faut simplement l’importer depuis le module functools
.
from functools import wraps
def afficher_args(print_result):
def traitement_deco(ancienne_fonction):
@wraps(ancienne_fonctions)
def nouvelle_fonction(*args, **kwargs):
print(f"Arguments de {ancienne_fonction.__name__} : {args} {kwargs}")
resultat = ancienne_fonction(*args, **kwargs)
if print_result:
print(f"Résultat de {ancienne_fonction.__name__} : {resultat}")
return resultat
return nouvelle_fonction
return traitement_deco
Le décorateur wraps
(qui, si vous avez bien suivi, n’en est pas vraiment un), copie simplement tous les attributs spéciaux de ancienne_fonction
dans nouvelle_fonction
. Ainsi on conserve l’emballage d’origine, notamment le nom des fonctions décorées.
Quelques idées d’utilisation
On a fait le tour. Les décorateurs respectent globalement toujours la même structure : il s’agit d’un design pattern, une solution éprouvée à un problème donné.
À l’aide des décorateurs, vous pouvez mettre en place, par exemple :
- un système de logs, à l’aide du module
logging
; - compter le nombre d’appels effectués sur des fonctions ;
- calculer le temps d’exécution de certaines fonctions, à l’aide du module
time
; - mettre en place un type strict ;
- et bien plus encore.
L’important est que les décorateurs apportent une solution, et ne soient pas une partie du problème.
Aller plus loin
Je n’ai pas abordé tous les aspects des décorateurs dans cet article. Il vous faudra pas mal d’entraînement et d’imagination pour en maîtriser tous les rouages.
Les décorateurs étant des fonctions englobantes, on aurait pu également parler de la notion de closure, permettant d’étendre les possibilités offertes par les décorateurs, en faisant conserver des états aux fonctions décorées.
On aurait pu également évoquer les annotations (type hints), ou bien encore l’utilisation conjointe de décorateurs et de context managers.
Laisser un commentaire