Dans ce chapitre nous allons découvrir les décorateurs et leur utilisation.
Le nom de décorateur provient du patron de conception décorateur (pattern decorator). Un décorateur permet de se soustraire à un objet pour en modifier le comportement.
- D&CO, une semaine pour tout changer
- Décorateurs paramétrés
- Envelopper une fonction
- TP : Arguments positionnels
D&CO, une semaine pour tout changer
En Python, vous en avez peut-être déjà croisé, les décorateurs se repèrent au caractère @
.
Le principe de la décoration en Python est d’appliquer un décorateur à une fonction, afin de retourner un nouvel objet (généralement une fonction). On peut donc voir le décorateur comme une fonction prenant une fonction en paramètre, et retournant une nouvelle fonction1
1 2 3 | def decorator(f): # decorator est un décorateur print(f.__name__) return f |
Pour appliquer un décorateur, on précède la ligne de définition de la fonction à décorer par une ligne comportant un @
puis le nom du décorateur à appliquer, par exemple :
1 2 3 4 5 | >>> @decorator ... def addition(a, b): ... return a + b ... addition |
Cela a pour effet de remplacer addition
par le retour de la fonction decorator
appelée avec addition
en paramètre, ce qui est strictement équivalent à :
1 2 3 4 | def addition(a, b): return a + b addition = decorator(addition) |
On voit donc bien que le décorateur est appliqué au moment de la définition de la fonction, et non lors de ses appels. Nous utilisons ici un décorateur très simple qui retourne la même fonction, mais il se pourrait très bien qu’il en retourne une autre, qui serait par exemple créée à la volée.
Disons que nous aimerions modifier notre fonction addition
pour afficher les opérandes puis le résultat, sans toucher au corps de notre fonction. Nous pouvons réaliser un décorateur qui retournera une nouvelle fonction se chargeant d’afficher les paramètres, d’appeler notre fonction originale, puis d’afficher le retour et de le retourner (afin de conserver le comportement original).
Ainsi, notre décorateur devient :
1 2 3 4 5 6 7 | def print_decorator(function): def new_function(a, b): # Nouvelle fonction se comportant comme la fonction à décorer print('Addition des nombres {} et {}'.format(a, b)) ret = function(a, b) # Appel de la fonction originale print('Retour: {}'.format(ret)) return ret return new_function # Ne pas oublier de retourner notre nouvelle fonction |
Si on applique maintenant ce décorateur à notre fonction d’addition :
1 2 3 4 5 6 7 8 | >>> @print_decorator ... def addition(a, b): ... return a + b ... >>> addition(1, 2) Addition des nombres 1 et 2 Retour: 3 3 |
Mais notre décorateur est ici très spécialisé, il ne fonctionne qu’avec les fonctions prenant deux paramètres, et affichera « Addition » dans tous les cas. Nous pouvons le modifier pour le rendre plus générique (souvenez vous d’*args
et **kwargs
).
1 2 3 4 5 6 7 8 | def print_decorator(function): def new_function(*args, **kwargs): print('Appel de la fonction {} avec args={} et kwargs={}'.format( function.__name__, args, kwargs)) ret = function(*args, **kwargs) print('Retour: {}'.format(ret)) return ret return new_function |
Je vous laisse l’essayer sur des fonctions différentes pour vous rendre compte de sa généricité.
Les définitions de fonctions ne sont pas limitées à un seul décorateur : il est possible d’en spécifier autant que vous le souhaitez, en les plaçant les uns à la suite des autres.
1 2 3 4 | @decorator @print_decoration def useless(): pass |
L’ordre dans lequel ils sont spécifiés est important, le code précédent équivaut à :
1 2 3 | def useless(): pass useless = decorator(print_decorator(useless)) |
On voit donc que les décorateurs spécifiés en premiers sont ceux qui seront appliqués en derniers.
J’ai dit plus haut que les décorateurs s’appliquaient aux fonctions. C’est aussi valable pour les fonctions définies à l’intérieur de classes (les méthodes, donc). Mais sachez enfin que les décorateurs s’étendent aux déclarations de classes.
1 2 3 4 5 | @print_decorator class MyClass: @decorator def method(self): pass |
-
Bien que la définition soit plus large que cela. Le décorateur est un callable prenant un callable en paramètre, et pouvant retourner tout type d’objet. ↩
Décorateurs paramétrés
Nous avons vu comment appliquer un décorateur à une fonction, nous pourrions cependant vouloir paramétrer le comportement de ce décorateur.
Dans notre exemple précédent (print_decorator
), nous affichons du texte avant et après l’appel de fonction. Mais que faire si nous souhaitons modifier ce texte (pour en changer la langue, utiliser un autre terme que « fonction ») ?
Nous ne voulons pas avoir à créer un décorateur différent pour chaque phrase possible et imaginable. Nous aimerions pouvoir passer nos chaînes de caractères à notre décorateur pour qu’il s’occupe de les afficher au moment opportun.
En fait, @
ne doit pas nécessairement être suivi d’un nom d’objet, des arguments peuvent aussi s’y ajouter à l’aide de parenthèses (comme on le ferait pour un appel).
Mais le comportement peut vous sembler étrange au premier abord.
Par exemple, pour utiliser un tel décorateur paramétré :
1 2 3 | @param_print_decorator('call {} with args({}) and kwargs({})', 'ret={}') def test_func(x): return x |
Il nous faudra posséder un callable param_print_decorator
qui, quand il sera appelé, retournera un décorateur qui pourra ensuite être appliqué à notre fonction.
Un décorateur paramétré n’est ainsi qu’un callable retournant un décorateur simple.
Le code de param_print_decorator
ressemblerait ainsi à :
1 2 3 4 5 6 7 8 9 | def param_print_decorator(before, after): # Décorateur paramétré def decorator(function): # Décorateur def new_function(*args, **kwargs): # Fonction qui remplacera notre fonction décorée print(before.format(function.__name__, args, kwargs)) ret = function(*args, **kwargs) print(after.format(ret)) return ret return new_function return decorator |
Envelopper une fonction
Une fonction n’est pas seulement un bout de code avec des paramètres. C’est aussi un nom (des noms, avec ceux des paramètres), une documentation (docstring), des annotations, etc. Quand nous décorons une fonction à l’heure actuelle (dans les cas où nous en retournons une nouvelle), nous perdons toutes ces informations annexes.
Un exemple pour nous en rendre compte :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | >>> def decorator(f): ... def decorated(*args, **kwargs): ... return f(*args, **kwargs) ... return decorated ... >>> @decorator ... def addition(a: int, b: int) -> int: ... "Cette fonction réalise l'addition des paramètres `a` et `b`" ... return a + b ... >>> help(addition) Help on function decorated in module __main__: decorated(*args, **kwargs) |
Alors, que voit-on ? Pas grand chose.
Le nom qui apparaît est celui de decorated
, les paramètres sont *args
et **kwargs
(sans annotations), et nous avons aussi perdu notre docstring.
Autant dire qu’il ne reste rien pour comprendre ce que fait la fonction.
Envelopper des fonctions
Plus tôt dans ce cours, je vous parlais du module functools
.
Il ne nous a pas encore révélé tous ses mystères.
Nous allons ici nous intéresser aux fonctions update_wrapper
et wraps
.
Ces fonctions vont nous permettre de copier les informations d’une fonction vers une nouvelle.
update_wrapper
prend en premier paramètre la fonction à laquelle ajouter les informations et celle dans laquelle les puiser en second. Pour reprendre notre exemple précédent, il nous faudrait faire :
1 2 3 4 5 6 7 | import functools def decorator(f): def decorated(*args, **kwargs): return f(*args, **kwargs) functools.update_wrappers(decorated, f) # Nous copions les informations de `f` dans `decorated` return decorated |
Mais une autre fonction nous sera bien plus utile car plus concise, et recommandée par la documentation Python pour ce cas.
Il s’agit de wraps
, qui retourne un décorateur lorsqu’appelé avec une fonction.
La fonction décorée par wraps
prendra les informations de la fonction passée à l’appel de wraps
.
Ainsi, nous n’aurons qu’à précéder toutes nos fonctions decorées par @functools.wraps(fonction_a_decorer)
. Dans notre exemple :
1 2 3 4 5 6 7 | import functools def decorator(f): @functools.wraps(f) def decorated(*args, **kwargs): return f(*args, **kwargs) return decorated |
Vous pouvez maintenant redéfinir la fonction addition
, et tester à nouveau l’appel à help
pour constater les différences.
TP : Arguments positionnels
Nous avons vu avec les signatures qu’il existait en Python des paramètres positional-only, c’est-à-dire qui ne peuvent recevoir que des arguments positionnels.
Mais il n’existe à ce jour aucune syntaxe pour écrire en Python une fonction avec des paramètres positional-only.
Il est seulement possible, comme nous l’avons fait au TP précédent, de récupérer les arguments positionnels avec *args
et d’en extraire les valeurs qui nous intéressent.
Nous allons alors développer un décorateur pour pallier à ce manque.
Ce décorateur modifiera la signature de la fonction reçue pour transformer en positional-only ses n
premiers paramètres.
n
sera un paramètre du décorateur.
Python nous permet de redéfinir la signature d’une fonction, en assignant la nouvelle signature à son attribut __signature__
.
Mais cette redéfinition n’est que cosmétique (elle apparaît par exemple dans l’aide de la fonction).
Ici, nous voulons que la modification ait un effet.
Nous créerons donc une fonction wrapper, qui se chargera de vérifier la conformité des arguments avec la nouvelle signature.
Nous allons diviser le travail en deux parties :
- Dans un premier temps, nous réaliserons une fonction pour réécrire une signature ;
- Puis dans un second temps, nous écrirons le code du décorateur.
Réécriture de la signature
La première fonction, que nous appellerons signature_set_positional
, recevra en paramètres une signature et un nombre n
de paramètres à passer en positional-only.
La fonction retournera une signature réécrite.
Nous utiliserons donc les méthodes replace
de la signature et des paramètres, pour changer le positionnement des paramètres ciblés, et mettre à jour la liste des paramètres de la signature.
La fonction itérera sur les n
premiers paramètres, pour les convertir en positional-only.
On distinguera trois cas :
- Le paramètre est déjà positional-only, il n’y a alors rien à faire ;
- Le paramètre est positional-or-keyword, il peut être transformé ;
- Le paramètre est d’un autre type, il ne peut pas être transformé, on lèvera alors une erreur.
Puis une nouvelle signature sera créée et retournée avec cette nouvelle liste de paramètres.
1 2 3 4 5 6 7 8 9 10 11 12 | def signature_set_positional(sig, n): params = list(sig.parameters.values()) # Liste des paramètres if len(params) < n: raise TypeError('Signature does not have enough parameters') for i, param in zip(range(n), params): # Itère sur les n premiers paramètres if param.kind == param.POSITIONAL_ONLY: continue elif param.kind == param.POSITIONAL_OR_KEYWORD: params[i] = param.replace(kind=param.POSITIONAL_ONLY) else: raise TypeError('{} parameter cannot be converted to POSITIONAL_ONLY'.format(param.kind)) return sig.replace(parameters=params) |
Décorateur paramétré
Passons maintenant à positional_only
, notre décorateur paramétré.
Pour rappel, un décorateur paramétré est une fonction qui retourne un décorateur.
Et un décorateur est une fonction qui reçoit une fonction et retourne une fonction.
Le décorateur proprement dit se chargera de calculer la nouvelle signature et de l’appliquer à la fonction décorée. Il créera aussi un wrapper à la fonction, lequel se chargera de vérifier la correspondance des arguments avec la signature.
Nous n’oublierons pas d’appliquer functools.wraps
à notre wrapper pour récupérer les informations de la fonction initiale.
1 2 3 4 5 6 7 8 9 10 11 12 13 | import functools import inspect def positional_only(n): def decorator(f): sig = signature_set_positional(inspect.signature(f), n) @functools.wraps(f) def decorated(*args, **kwargs): bound = sig.bind(*args, **kwargs) return f(*bound.args, **bound.kwargs) decorated.__signature__ = sig return decorated return decorator |
Voyons maintenant l’utilisation.
1 2 3 4 5 6 7 8 9 10 11 12 | >>> @positional_only(2) ... def addition(a, b): ... return a + b ... >>> print(inspect.signature(addition)) (a, b, /) >>> addition(3, 5) 8 >>> addition(3, b=5) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'b' parameter is positional only, but was passed as a keyword |
1 2 3 4 5 6 7 8 9 10 11 12 | >>> @positional_only(1) ... def addition(a, b): ... return a + b ... >>> addition(3, 5) 8 >>> addition(3, b=5) 8 >>> addition(a=3, b=5) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'a' parameter is positional only, but was passed as a keyword |
Les ressources sur les décorateurs sont peu nombreuses dans la documentation, car il ne s’agit au final que d’un sucre syntaxique. Je vous indique tout de même les deux principales pages qui les évoquent.
- Définition du terme décorateur : https://docs.python.org/3/glossary.html#term-decorator
- Syntaxe de déclaration d’une fonction : https://docs.python.org/3/reference/compound_stmts.html#function