Vous êtes ici : Accueil / 2012 / Avril / Les décorateurs Python

Les décorateurs Python

écrit le 21/04/2012 Par Bruno Dupuis
Les décorateurs, introduits dans la version 2.4 de Python, sont un sucre syntaxique connu et apprécié des développeurs Python. Cependant leur fonctionnement interne est souvent méconnu et pour beaucoup tient de la magie. Un bonne compréhension des mécanismes internes est indispensable pour tirer un maximum de profit des décorateurs et pour en écrire, ils ne sont pas réservés aux frameworks.

Le pattern «decorator»

Le terme décorateur vient du design pattern du même nom. Ce pattern consiste à modifier dynamiquement (au runtime) les caractéristiques d'un objet de code (fonction, classe, instance...). Cependant il si la parenté est évidente, il faut bien faire attention en Python de différencier le pattern decorator de la syntaxe du même nom. Il est en effet possible d'implémenter le pattern sans utiliser la syntaxe @xxxx.

La syntaxe en Python consiste donc à ajouter @mon_decorator avant la définition d'une fonction, d'une méthode, ou d'une classe où mon_decorator est un callable (souvent une fonction, mais pas forcément).

>>> def my_decorator(fn):
...     do_stuff()
...     return a_callable
...
>>> @my_decorator
... def my_func(arg1,arg2):
...     return arg1+arg2
...

À l'import, lorsque my_func est évaluée, l'interpréteur Python appelle en fait my_func = my_decorator(my_func).

D'autres langages utilisent des concepts similaires. Je pense notamment ici aux annotations Java qui ont d'ailleurs inspiré la syntaxe @xxx de Python. Les annotations en Java fonctionnent de manière très différentes. En Java, par exemple les simples variables sont annotables. Les annotations peuvent aussi être évaluées a la compilation plutôt qu'à l'exécution.

Un décorateur simple

Voici l'expression la plus simple d'un décorateur:

>>> def dummy(fn):
...     print  "into dummy(%s)"%(fn)
...     return fn
...
>>> @dummy
... def divide(arg1,arg2):
...     print  "into divide..."
...     return arg1/arg2
...
into dummy(<function divide at 0x7f9edc8b62a8>)
>>> print divide(1.,2.)
into divide...
0.5

Comme nous le voyons, la fonction est décorée une fois pour toutes, mais notre décorateur ne sert pas à grand chose. Faisons un décorateur un peu plus utile. Le décorateur incargs par exemple incrémente de 1 les arguments passés à la fonction décorée :

>>> def incargs(fn):
...     def increased_op(*args):
...         print "into increased_op"
...         args = [arg + 1. for arg in args]
...         return fn(*args)
...     return increased_op
...
>>> @incargs
... def divide(arg1,arg2):
...     print  "into divide..."
...     return arg1/arg2
...
into incargs
>>> print divide(1.,2.)
into increased_op
into divide...
0.666666666667
>>> print divide(2.,3.)
into increased_op
into divide...
0.75

Comme nous le voyons, le fait que le décorateur renvoie une fonction imbriquée permet deux choses :

  • une partie de la décoration est maintenant évaluée à l'exécution et non uniquement à l'import ;
  • nous pouvons maintenant manipuler les arguments passés à la fonction.

Un décorateur avec arguments

Nous avons déjà quelques outils qui nous permettent de décorer notre code, mais nous pouvons aller plus loin. Il est par exemple possible qu'on souhaite passer un argument à notre décorateur pour qu'il se comporte différemment suivant la fonction décorée. Nous allons ré-écrire incargs pour qu'on soit en mesure de lui passer en argument l'incrémentation qu'on désire.

Pour ce faire, il faut se rappeler qu'un décorateur est un callable, c'est à dire qu'il peut être une fonction, ou une instance d'une classe qui implémente la méthode __call__. Voyons d'abord le code puis son cheminement:

>>> # définition du décorateur
>>> class xincargs(object):
...     def __init__(self,inc):
...         print "into Xincargs.__init__(self,%s)"%inc
...         self.inc = inc
...     def __call__(self,fn):
...         print "into %s.__call__(%s)"%(self,fn)
...         def do_incargs(*args,**kwargs):
...             args = [arg + self.inc for arg in args]
...             print "args are now: %s"%(args)
...             return fn(*args)
...         return do_incargs
...
>>> @xincargs(3.)
... def add(arg1,arg2):
...     print "into add"
...     return arg1 + arg2
...
into xincargs.__init__(self,3.0)
into <__main__.xincargs instance at 0x7ff5db987050>.__call__(<function add at 0x7ff5db9792a8>)
>>> @xincargs(2.)
... def multiply(arg1,arg2):
...     print "into multiply"
...     return arg1 * arg2
...
into xincargs.__init__(self,2.0)
into <__main__.xincargs instance at 0x7ff5db987bd8>.__call__(<function multiply at 0x7ff5db980398>)
>>> print add(1,2)
args are now: [4.0, 5.0]
into add
9.0
>>> print multiply(1,2)
args are now: [3.0, 4.0]
into multiply
12.0
  1. À l'évaluation de la fonction add (en temps normal, à l'import) Un objet de type xincargs est instancié. Comme dans toute instanciation en Python, les arguments sont passés à __init__.
  2. Dans la foulé, l'interpréteur appelle l'objet ainsi créé (méthode __call__ ) en lui passant add en argument.
  3. Le résultat de cet appel remplace add durant la suite de l'exécution. À chaque appel de add, la fonction interne do_incargs est évaluée.

Un exemple utile

Tout ça est bien beau, mais ça reste abstrait. Quand est-ce que ça devient intéressant ? Un exemple très connu d'utilisation des décorateurs est la mise en place d'un cache des valeurs de retour d'une fonction. Le principe est d'ajouter à l'objet fonction un dictionnaire qui prend en clef un hachage des arguments de la fonction et en valeur le résultat pour ces arguments. Pour corser le tout, nous allons ajouter une durée de vie à chaque résultat mis en cache.

Cette durée de vie (TTL, d'après le nom anglais) sera variable selon la fonction décorée. Nous allons dons écrire une classe décorateur qui à laquelle nous passons le TTL. Le décorateur cherchera d'abord le résultat dans le cache, si ce résultat est suffisamment récent, il est renvoyé directement. S'il n'existe pas ou est trop vieux il est calculé, mis en cache puis renvoyé.

# -*- coding: utf-8 -*-
import time, functools

class xcache(object):
    def __init__(self,ttl):
        self.ttl = ttl
    def __call__(self,fn):
        # instanciation du cache
        fn.cache = {}
        # décoration de la fonction
        def cache_this(*args,**kwargs):
            # forgeage de la clef de cache
            if kwargs:
                # le frozenset permet de hasher le dictionaire kwargs
                key = args, frozenset(kwargs.argsiteritems())
            else:
                key = args
            # fetch du cache
            cache = fn.cache
            if key in cache:
                result,birth = cache[key]
                if time.time() - birth < self.ttl:
                    return result
            result = fn(*args, **kwargs)
            cache[key] = (result,time.time())
            return result
        return functools.update_wrapper(cache_this, fn)

@xcache(3)
def nasty_heavy_function(arg1):
    print "doing dirt"
    time.sleep(2)
    return "done : %s"%arg1

start = time.time()
print "%%%%%%%%%%%%%%%%%%%%%%%%%%"

print "~2 secondes plus tard"
print nasty_heavy_function(42)
print ("%s"%(time.time() - start))
print "%%%%%%%%%%%%%%%%%%%%%%%%%%"

print nasty_heavy_function(42)
print "instantané"
print ("%s"%(time.time() - start))
print "%%%%%%%%%%%%%%%%%%%%%%%%%%"

print "encore 2 secondes, l'argument a changé..."
print nasty_heavy_function("spam eggs")
print ("%s"%(time.time() - start))
print "%%%%%%%%%%%%%%%%%%%%%%%%%%"

time.sleep(1.5)

print "instantané, le ttl de 'spam eggs' est encore bon "
print nasty_heavy_function("spam eggs")
print ("%s"%(time.time() - start))
print "%%%%%%%%%%%%%%%%%%%%%%%%%%"

print "encore 2 secondes de calcul, le ttl pour 42 est dépassé"
print nasty_heavy_function(42)
print ("%s"%(time.time() - start ))
print "%%%%%%%%%%%%%%%%%%%%%%%%%%"

Conclusion

Nous espérons que cet article vous aura permis de mieux comprendre le fonctionnement interne et les utilisations possibles des décorateurs. Il resterait beaucoup de choses à dire, parler des décorateurs de classe par exemple, mais chaque chose en son temps. Bon code !

Actions sur le document