Vous êtes ici : Accueil Tutoriels Les décorateurs

Les décorateurs

Lorsqu'on les croise pour la première fois, les décorateurs revêtent cet aspect magique. On ne sait pas trop comment çà fonctionne, mais on trouve çà classe. C'est facile à utiliser, çà demande peut d'efforts et c'est très puissant. Que demande le peuple ? Par contre, lorsque l'on souhaite en créer, c'est une autre paire de manches. Comme dirait un coach de rugby : "On va tout mettre sur la table et repartir des fondamentaux".

Introduction

Le décorateur est avant tout un motif de conception dont on peut trouver une explication claire sur wikipédia. Voici la phrase clé (je cite) : Un décorateur permet d'attacher dynamiquement de nouvelles responsabilités à un objet. Tout est dit.

Appliqué à Python, le décorateur est une fonction qui prend en paramètre une fonction et qui renvoie une fonction. La fonction renvoyée est la fonction en paramètre qui a reçu ces nouvelles responsabilités. Le fait d'utiliser un décorateur permet donc d'écrire ces responsabilités au sein du décorateur et ainsi de les capitaliser en un seul et unique endroit pour pouvoir rajouter ces responsabilités à plusieurs fonction simplement à l'aide du décorateur (soit en les décorant).

D'après ce qui vient d'être dit, voici un décorateur qui ne fait rien :

def identité(func):
return func

Tout décorateur n'est donc rien d'autre que çà. De plus, en Python, tout est objet. Une fonction est donc un objet et est donc directement modifiable. Il suffit, par conséquent, de modifier la fonction dans ce bloc de code pour apporter une responsabilité :

def exemple_inutile(func):
func.nouvel_attribut = None
return func

La fonction renvoyée vient d'être modifiée. Cependant, modifier directement l'objet fonction n'apporte généralement rien. Ce que l'on souhaite en réalité, c'est apporter quelque chose dans le corps de la fonction, au moment ou elle est appelée, en agissant avant qu'elle soit appelée, ou après qu'elle le soit. Pour cela, il faut faire appel à un Proxy (du nom du motif de conception éponyme, cf wikipédia).

def identité(func):
def returned(*args, **kwargs):
return func(*args, **kwargs)
return returned

Notre décorateur identité prend en paramètre une fonction func, définit une nouvelle fonction returned qui appelle func (ici, sans rien faire d'autre) et la renvoie.

Le décorateur renvoie donc une autre fonction qui réalise l'appel à la fonction d'origine. On peut donc mettre du code avant l'appel (par exemple pour des pré-conditions) ou après l'appel (par exemple pour des post-conditions).

Voici un exemple qui va vérifier que tous les arguments sont des entiers (pré-condition) et que la valeur retournée est un entier (post-condition) :

from itertools import chain

def check_int(func):
def returned(*args, **kwargs):

# PRE CONDITION
if any((lambda a: type(a) != int)(n) for n in chain(args, kwargs.values())):
raise TypeError("Au moins un argument n'est pas un entier")

# APPEL DE LA FONCTION D'ORIGINE
res = func(*args, **kwargs)

# POST CONDITION
if type(res) != int:
raise TypeError("La valeur de retour n'est pas un entier")

# RENVOI DU RESULTAT
return res
return returned

Ce décorateur est utile, mais si l'on voulait en faire un plus généraliste, qui permet de vérifier que les arguments et le résultat soit d'un type particulier, connu au moment de l'appel du décorateur, il faut rajouter un niveau supplémentaire :

from itertools import chain

def check_type(type_checked):
###
def decorator(func):
def returned(*args, **kwargs):

# PRE CONDITION
if any((lambda a: type(a) != type_checked)(n) for n in chain(args, kwargs.values())):
raise TypeError("Au moins un argument n'est pas un entier")

# APPEL DE LA FONCTION D'ORIGINE
res = func(*args, **kwargs)

# POST CONDITION
if type(res) != type_checked:
raise TypeError("La valeur de retour n'est pas un entier")

# RENVOI DU RESULTAT
return res
return returned
###
return decorator

Ce que l'on a fait se résume en quelques principes :

  • créer notre décorateur à l'intérieur d'une fonction qui va le renvoyer ;
  • variabiliser notre décorateur en remplaçant la partie fixe par une partie variable (ici, int est remplacé par type_checked) ;

Et c'est tout. Pour retrouver notre fonction check_int, il suffit alors de faire :

>>> check_int = check_type(int)

Cependant, on peut réaliser cet appel directement au moment de la décoration :

>>> @check_type(int)
... def f(a, b, c):
...     if c == -1:
...             return ''
...     return a + b + c
...

Il est important de s'arrêter ici deux secondes pour comprendre ce qu'il se passe. Au moment de la déclaration de la fonction d'exemple f, le décorateur check_type est appelé. Il va donc déclarer la fonction décorateur (partie entre les deux lignes ###) et la retourner, avec les parties variables fixées (ici, on sait que l'on utilise un int dans la comparaison. A ce stade, l'exécution de la fonction check_type est terminée, l'exécution de la fonction decorator n'a pas eu lieu.

Au moment ou la fonction f sera appelée, la fonction check_type n'entre plus en jeu, son rôle est terminé. Par contre, chaque appel de f entraîne l'appel de la fonction decorator, dont les parties variables ont été fixées.

Exemple complet simple

Voici un exemple d'un décorateur servant à mesurer le temps mis par la fonction décorée:

import logging
from time import time

def mesurer_temps(func):
def returned(*args, **kwargs):
t = time()
res = func(*args, **kwargs)
print('Appel de la fonction %s : %s secondes' % (func.func_name, time() - t))
return res
return returned

Voici comment utiliser un tel décorateur:

>>> from time import sleep
>>> @mesurer_temps
... def f(a):
...     sleep(1)
...     return 42
...
>>> f(5)
Appel de la fonction f : 1.00107216835 secondes
42

le caractère @ permet de simplifier l'utilisation des décorateurs. Ceci est équivalent à:

>>> from time import sleep
>>> def f(a):
...     sleep(1)
...     return 42
...
>>> f = mesurer_temps(f)
>>> f(5)
Appel de la fonction f : 1.00107216835 secondes
42

-

Mots-clés associés : , , ,
Spinner