Notes de cours Python Scientifique

Constructions avancées en Python

Cette section traite de certaines fonctionnalités du langage Python qui peuvent être considérées comme avancées. Ces fonctionnalités sont avancées dans le sens où elles ne sont pas disponibles dans tous les langages et où elles sont plus utiles dans des programmes ou des bibliothèques plus complexes, mais cela ne veut pas dire qu'elles soient particulièrement spécialisées ou particulièrement compliquées.

Il est important de souligner que ce chapitre concerne uniquement le langage lui-même - les fonctionnalités prises en charge grâce à une syntaxe spéciale complétée par les fonctionnalités de la bibliothèque stdlib de Python, qui ne peuvent pas être implémentées par des modules externes inventifs.

Le processus de développement du langage de programmation Python et de sa syntaxe est très transparent. Les changements proposés sont évalués sous différents angles et discutés via les Python Enhancement Proposals - ou PEP. En conséquence, les fonctionnalités décrites dans ce chapitre ont été ajoutées après qu'il a été démontré qu'elles résolvent effectivement des problèmes réels et que leur utilisation est aussi simple que possible.

Commentez Donner une note à l'article (5)

Article lu   fois.

Les trois auteur et traducteurs

Traducteur : Profil Pro

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Itérateurs, expressions génératrices et générateurs

I-A. Itérateurs

Un itérateur est un objet adhérant au protocole itératif - essentiellement cela signifie qu'il a une méthode next, qui, lorsqu'elle est appelée, renvoie l'élément suivant dans la séquence et, lorsqu'il n'y a rien à renvoyer, déclenche l'exception StopIteration.

Un objet itérateur permet de boucler une seule fois sur une séquence. Il connaît l'état (la position) d'une seule itération, et, inversement, chaque boucle sur une séquence nécessite un seul objet itérateur. Cela signifie que nous pouvons itérer sur la même séquence plus d'une fois simultanément. Faire la distinction entre la logique d'itération et la séquence nous permet d'avoir plus d'une manière de faire l'itération.

Simplicité

Multiplier l'effort est un gaspillage, et le remplacement des différentes approches « maison » par une caractéristique standard généralement rend les choses plus lisibles, mais aussi interopérables.

Guido van RossumAdding Optional Static Typing to Python

Appeler la méthode __iter__ sur un conteneur pour créer un objet itérateur est le moyen le plus simple pour obtenir un itérateur. La fonction iter le fait pour nous et nous permet d'économiser un peu de frappes au clavier.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
>>> nums = [1, 2, 3]      # notez que ... varie : Ce sont des objets différents 
>>> iter(nums)                           
<...iterator object at ...>
>>> nums.__iter__()                      
<...iterator object at ...>
>>> nums.__reversed__()                  
<...reverseiterator object at ...>

>>> it = iter(nums)
>>> next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

Lorsqu'il est utilisé en boucle, l'exception StopIteration est masquée et entraîne silencieusement l'arrêt de la boucle. Mais avec une invocation explicite, on peut voir qu'une fois que l'itérateur a épuisé la séquence, l'accès à celui-ci entraîne une exception.

L'utilisation de la boucle for..in utilise également la méthode __iter__. Cela nous permet de démarrer de façon transparente l'itération sur une séquence. Mais si nous avons déjà l'itérateur, nous voulons pouvoir l'utiliser dans une boucle for de la même manière. Pour ce faire, les itérateurs, en plus next, sont également tenus d'avoir une méthode appelée __iter__ qui renvoie l'itérateur (self).

Le support pour l'itération est omniprésent dans Python : toutes les séquences et les conteneurs non ordonnés dans la bibliothèque standard permettent l'itération. Le concept est également étendu à d'autres choses : par ex. les objets file supportent l'itération sur les lignes des fichiers.

 
Sélectionnez
>>> f = open('/etc/fstab')
>>> f is f.__iter__()
True

file est lui-même un itérateur et sa méthode __iter__ ne crée pas d'objet distinct : il ne permet qu'un simple processus d'accès séquentiel.

I-B. Les expressions génératrices

Une deuxième manière de créer des objets itérateurs est via les expressions génératrices, la base des listes en compréhension. Pour augmenter la clarté, une expression génératrice doit toujours être entre parenthèses ou faire partie d'une expression. Si des parenthèses ordinaires (rondes) sont utilisées, un itérateur générateur est créé. Avec des crochets, la procédure est shuntée et nous obtenons un objet de type list.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
>>> (i for i in nums)                    
<generator object <genexpr> at 0x...>
>>> [i for i in nums]
[1, 2, 3]
>>> list(i for i in nums)
[1, 2, 3]

La syntaxe de compréhension s'étend également aux dictionnaires et aux sets en compréhension. Un set (ensemble) est créé lorsque l'expression du générateur est entourée d'accolades. Un dict (dictionnaire) est créé lorsque l'expression du générateur contient des « paires » de la forme clef:valeur :

 
Sélectionnez
1.
2.
3.
4.
>>> {i for i in range(3)}  
set([0, 1, 2])
>>> {i:i**2 for i in range(3)}   
{0: 0, 1: 1, 2: 4}

Il faut signaler un petit problème spécifique : dans les anciennes versions de Python, il y aurait eu une fuite mémoire avec la variable d'indexation (i), mais c'est corrigé en Python 3.

I-C. Générateurs

Une troisième façon de créer des objets itératifs est d'appeler une fonction génératrice. Un générateur est une fonction contenant le mot clé yield. Il faut noter que la simple présence de ce mot-clé modifie complètement la nature de la fonction : il n'y a pas besoin que l'instruction yield soit appelée, ou même simplement accessible ; le fait que l'instruction soit présente suffit pour que la fonction soit considérée comme un générateur. Lorsqu'une fonction normale est appelée, les instructions contenues dans le corps de cette fonction sont exécutées. Lorsqu'un générateur est appelé, l'exécution s'arrête avant la première instruction dans le corps. Une invocation d'une fonction génératrice crée un objet générateur, adhérant au protocole itérateur. Comme pour les appels de fonctions normaux, les appels concurrents et récursifs de générateurs sont autorisés.

Lorsque next est appelé, la fonction est exécutée jusqu'à la première instruction yield. Chaque instruction yield rencontrée donne une valeur qui devient la valeur de retour de next. Après l'exécution de l'instruction de yield, l'exécution de cette fonction est suspendue.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
>>> def f():
...   yield 1
...   yield 2
>>> f() 
<generator object f at 0x...>
>>> gen = f()
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration

Examinons le déroulement d'un simple appel de la fonction de générateur.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
>>> def f():
...   print("-- start --")
...   yield 3
...   print("-- middle --")
...   yield 4
...   print("-- finished --")
>>> gen = f()
>>> next(gen)
-- start --
3
>>> next(gen)
-- middle --
4
>>> next(gen)                            
-- finished --
Traceback (most recent call last):
 ...
StopIteration

Contrairement à une fonction habituelle, dans laquelle l'exécution de f() entraînerait immédiatement l'exécution de la première instruction print, gen fait l'objet d'une affectation sans que le corps de la fonction soit exécuté. C'est seulement quand gen.next() est invoqué par next que les instructions sont exécutées jusqu'au premier yield. La deuxième instruction de next affiche -- middle -- et l'exécution s'arrête sur le second yield. La troisième instruction de next imprime -- finished -- et marque la fin de la fonction. Comme aucun yield n'a été atteint, une exception est signalée.

Que se passe-t-il avec la fonction après un yield, lorsque le contrôle passe à l'appelant ? L'état de chaque générateur est stocké dans l'objet générateur. Du point de vue de la fonction de générateur, on dirait presque qu'il s'agit d'un thread distinct, mais ce n'est qu'une illusion : il n'y a qu'un seul thread d'exécution, mais l'interpréteur conserve et restaure l'état entre les demandes de la valeur suivante.

Pourquoi les générateurs sont-ils utiles ? Comme indiqué dans les parties sur les itérateurs, une fonction génératrice est simplement une autre manière de créer un objet itérateur. Tout ce qui peut être fait avec des instructions yield, pourrait également être fait avec les méthodes next. Néanmoins, utiliser une fonction et laisser l'interpréteur s'occuper de créer un itérateur présentent des avantages. Une fonction peut être beaucoup plus courte que la définition d'une classe avec les méthodes next et __iter__ requises. Le plus important est qu'il est plus facile pour l'auteur du générateur de comprendre l'état qui est conservé dans les variables locales, par opposition aux attributs d'instance qui doivent être utilisés pour transmettre des données entre les invocations consécutives d'un objet itératif next.

Une question plus fréquente est de savoir pourquoi les itérateurs sont utiles ? Lorsqu'un itérateur est utilisé pour alimenter une boucle, la boucle devient très simple. Le code pour initialiser l'état, pour décider si la boucle est terminée, et pour trouver la valeur suivante est situé ailleurs. Cela met en évidence le corps de la boucle - la partie intéressante. En outre, il est possible de réutiliser le code itérateur ailleurs.

I-D. Communication bidirectionnelle

Chaque instruction yield entraîne la transmission d'une valeur à l'appelant. C'est la raison pour laquelle la PEP 255 a introduit la notion de générateur (implémentée en Python 2.2). Mais la communication en sens inverse est également utile. Une façon évidente serait un état externe, soit une variable globale, soit un objet mutable partagé. La communication directe est possible grâce à la PEP 342 (implémentée en 2.5). Elle est obtenue en transformant l'instruction yield précédemment fastidieuse en une expression. Lorsque le générateur reprend l'exécution après une instruction yield, l'appelant peut invoquer une méthode sur l'objet générateur pour passer une valeur dans le générateur, qui est ensuite renvoyée par l'instruction yield ou une autre méthode pour injecter une exception dans le générateur.

La première des nouvelles méthodes est send(value), qui est similaire à next(), mais transmet la valeur en paramètre dans le générateur qui sera utilisée comme valeur de l'expression yield. En fait, g.next() et g.send(None) sont équivalents.

La seconde des nouvelles méthodes est throw (type, value = None, traceback = None) qui est équivalent à :

raise type, value, tracebackau point de l'instruction yield.

Contrairement à raise (qui ajoute immédiatement une exception au point d'exécution actuel), throw() reprend le générateur et ne traite qu'ensuite l'exception. Le mot throw (jeter) a été choisi, car il suggère l'idée de placer l'exception dans un autre emplacement et est associé à des exceptions dans d'autres langages.

Que se passe-t-il lorsqu'une exception est déclenchée à l'intérieur du générateur ? Elle peut être déclenchée explicitement ou lors de l'exécution de certaines instructions, ou elle peut être injectée au point d'une instruction yield au moyen de la méthode throw(). Dans les deux cas, une telle exception se propage de manière standard : elle peut être interceptée par une clause except ou finally, sinon elle entraîne l'interruption de l'exécution de la fonction génératrice et se propage dans l'appelant.

Pour être complet, il convient de mentionner que les itérateurs générateurs ont également une méthode close(), qui peut être utilisée pour forcer un générateur à s'arrêter immédiatement, même s'il était en mesure de fournir plus de valeurs. Il permet à la méthode __del__ du générateur de détruire les objets contenant l'état du générateur.

Définissons un générateur qui imprime les paramètres passés via send et throw :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
>>> import itertools
>>> def g():
...     print('--start--')
...     for i in itertools.count():
...         print('--yielding %i--' % i)
...         try:
...             ans = yield i
...         except GeneratorExit:
...             print('--closing--')
...             raise
...         except Exception as e:
...             print('--yield raised %r--' % e)
...         else:
...             print('--yield returned %s--' % ans)

>>> it = g()
>>> next(it)
--start--
--yielding 0--
0
>>> it.send(11)
--yield returned 11--
--yielding 1--
1
>>> it.throw(IndexError)
--yield raised IndexError()--
--yielding 2--
2
>>> it.close()
--closing--

next ou __next__ ?

Dans les versions Python 2.x, la méthode itérative pour récupérer la valeur suivante s'appelle next. Elle est invoquée implicitement par la fonction globale next, ce qui signifie qu'elle devrait s'appeler __next__. Tout comme la fonction globale iter appelle __iter__. Cette incohérence est corrigée dans les versions Python 3.x, où it.next devient it.__ next__. Pour les autres méthodes du générateur, send et throw, la situation est plus compliquée, car elle n'est pas appelée implicitement par l'interpréteur. Néanmoins, il existe une proposition d'extension de syntaxe pour permettre à continue de prendre un argument qui sera transmis à l'instruction send de l'itérateur de la boucle. Si cette extension est acceptée, il est probable que gen.send devienne gen .__ send__. La dernière des méthodes du générateur, close, est bien évidemment mal nommée, car il est déjà invoqué implicitement.

I-E. Générateurs chaînés

Il s'agit d'un aperçu de la PEP 380 (introduite en Python 3.3).

Supposons que nous écrivions un générateur et que nous voulions fournir un certain nombre de valeurs générées par un deuxième générateur, un sous-générateur. Si notre seule préoccupation est de fournir des valeurs, cela peut se faire sans difficulté à l'aide d'une boucle telle que :

 
Sélectionnez
1.
2.
3.
subgen = some_other_generator()
for v in subgen:
    yield v

Cependant, si le sous-générateur doit interagir correctement avec l'appelant dans le cas des appels de send(), throw() et close(), les choses deviennent beaucoup plus difficiles. L'instruction yield doit être protégée par une structure try..except..finally similaire à celle définie dans la section précédente pour « déboguer » la fonction du générateur. Un tel code est fourni dans PEP 380 # id13 ; qu'il suffise de dire ici que la nouvelle syntaxe de yield à partir d'un sous-générateur a été introduite dans Python 3.3 (en 2013) :

 
Sélectionnez
yield from some_other_generator()

Cela fonctionne comme la boucle explicite ci-dessus, renvoyant à plusieurs reprises des valeurs fournies par some_other_generator jusqu'à ce que celui-ci soit épuisé, mais aussi envoie send, throw et close du sous-générateur.

II. Décorateurs

Étant donné que les fonctions et les classes sont des objets, elles peuvent être passées en paramètre. Et comme elles sont des objets mutables, elles peuvent être modifiées. Le fait de modifier une fonction ou un objet de classe après l'avoir construit, mais avant de le lier à son nom s'appelle décoration.

Deux choses se cachent derrière le mot « décorateur » : l'une est la fonction qui fait le travail de décoration, c'est-à-dire le travail réel ; et l'autre est l'expression qui adhère à la syntaxe du décorateur, c'est-à-dire un symbole et le nom de la fonction de décoration.

Une fonction peut être décorée en utilisant la syntaxe du décorateur pour les fonctions :

 
Sélectionnez
1.
2.
3.
@decorator             
def function():        
    pass

La fonction est définie de manière standard (ligne 2).

L'expression commençant par @ placée avant la définition de la fonction est le décorateur (ligne 1). La partie après @ doit être une expression simple, généralement ce n'est que le nom d'une fonction ou d'une classe. Cette partie est évaluée en premier, et, après que la fonction définie ci-dessous est prête, le décorateur est appelé avec l'objet de fonction nouvellement défini comme argument unique. La valeur renvoyée par le décorateur est attachée au nom original de la fonction.

Les décorateurs peuvent être appliqués aux fonctions et aux classes. Pour les classes, la sémantique est identique - la définition de classe d'origine est utilisée comme argument pour appeler le décorateur et tout ce qui est renvoyé est affecté au nom d'origine.

Avant la mise en œuvre de la syntaxe du décorateur (PEP 318), il était possible d'obtenir le même résultat en affectant la fonction ou l'objet de classe à une variable temporaire, puis en invoquant explicitement le décorateur et enfin en affectant la valeur de retour au nom de la fonction. Cela suggère qu'il faut écrire plus de code, et c'est bien le cas. Et le nom de la fonction décorée dupliquée comme variable temporaire doit être utilisé au moins trois fois, ce qui peut être une source d'erreurs. Néanmoins, l'exemple ci-dessus équivaut à ceci :

 
Sélectionnez
def function():                  # 
    pass
function = decorator(function)   # 

Les décorateurs peuvent être empilés - l'ordre d'application est de bas en haut ou à l'envers. La sémantique est telle que la fonction définie à l'origine est utilisée comme argument pour le premier décorateur, tout ce qui est retourné par le premier décorateur est utilisé comme argument pour le second décorateur, et ainsi de suite, et tout ce qui est retourné par le dernier décorateur est lié au nom de la fonction originale.

La syntaxe du décorateur a été choisie pour sa lisibilité. Puisque le décorateur est spécifié avant l'en-tête de la fonction, il est évident qu'il ne fait pas partie du corps de la fonction et il est clair qu'il ne peut fonctionner que sur l'ensemble de la fonction. Comme l'expression préfixée avec @ est visible et est difficile à manquer (« dans votre visage », selon le PEP :)). Lorsqu'on utilise plus d'un décorateur, chacun est placé sur une ligne distincte de manière à rendre plus facile la lecture.

II-A. Remplacement ou modification de l'objet d'origine

Les décorateurs peuvent soit retourner la même fonction ou classe, soit renvoyer un objet complètement différent. Dans le premier cas, le décorateur peut exploiter le fait que les objets de fonction et de classe sont mutables et ajouter des attributs, par ex. ajouter un docstring à une classe. Un décorateur pourrait faire quelque chose d'utile même sans modifier l'objet, par exemple enregistrer la classe décorée dans un registre global. Dans le deuxième cas, pratiquement tout est possible : lorsque la fonction ou la classe d'origine est remplacée par quelque chose de différent, le nouvel objet peut être complètement différent. Néanmoins, un tel comportement n'est pas le but des décorateurs : ils sont destinés à modifier l'objet décoré, et non à faire quelque chose d'imprévisible. Par conséquent, lorsqu'une fonction est « décorée » et remplacée par une fonction différente, la nouvelle fonction appelle généralement la fonction d'origine, après avoir effectué des opérations préparatoires. De même, lorsqu'une classe est « décorée » en la remplaçant par une nouvelle classe, la nouvelle classe est généralement dérivée de la classe d'origine. Lorsque le but du décorateur est de faire quelque chose « à chaque fois », par exemple tracer chaque appel à une fonction décorée, seul le second type de décorateurs peut être utilisé. Si en revanche le premier type est suffisant, il est préférable de l'utiliser, car il est plus simple.

II-B. Les décorateurs implémentés comme classes et comme fonctions

La seule exigence sur les décorateurs est qu'ils peuvent être appelés avec un seul argument. Cela signifie que les décorateurs peuvent être implémentés comme des fonctions normales, ou comme des classes avec une méthode __call__, ou, en théorie, comme une fonction lambda.

Comparons les approches de fonction et de classe. L'expression du décorateur (la partie après @) peut être soit un nom, soit un appel. L'approche du nom nu est attrayante (moins de code, semble plus propre, etc.), mais n'est possible que si aucun argument n'est nécessaire pour personnaliser le décorateur. Les décorateurs écrits comme fonctions peuvent être utilisés dans ces deux cas :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
>>> def simple_decorator(function):
...   print("doing decoration")
...   return function
>>> @simple_decorator
... def function():
...   print("inside function")
doing decoration
>>> function()
inside function

>>> def decorator_with_arguments(arg):
...   print("defining the decorator")
...   def _decorator(function):
...       # Dans cette fonction interne, arg est également disponible
...       print("doing decoration, %r" % arg)
...       return function
...   return _decorator
>>> @decorator_with_arguments("abc")
... def function():
...   print("inside function")
defining the decorator
doing decoration, 'abc'
>>> function()
inside function

Les deux décorateurs triviaux ci-dessus tombent dans la catégorie des décorateurs qui renvoient la fonction originale. S'ils devaient renvoyer une nouvelle fonction, un niveau supplémentaire d'imbrication serait nécessaire. Dans le pire des cas, on aura trois niveaux de fonctions imbriquées.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
>>> def replacing_decorator_with_args(arg):
...   print("defining the decorator")
...   def _decorator(function):
...       # Dans cette fonction interne, arg est également disponible
...       print("doing decoration, %r" % arg)
...       def _wrapper(*args, **kwargs):
...           print("inside wrapper, %r %r" % (args, kwargs))
...           return function(*args, **kwargs)
...       return _wrapper
...   return _decorator
>>> @replacing_decorator_with_args("abc")
... def function(*args, **kwargs):
...     print("inside function, %r %r" % (args, kwargs))
...     return 14
defining the decorator
doing decoration, 'abc'
>>> function(11, 12)
inside wrapper, (11, 12) {}
inside function, (11, 12) {}
14

La fonction _wrapper est définie de façon à accepter tous les arguments positionnels ou nommés. En général, nous ne pouvons pas savoir quels arguments la fonction décorée est censée accepter, de sorte que la fonction _wrapper passe tout à la fonction encapsulée. Une conséquence malheureuse est que la liste apparente des arguments est trompeuse.

Par rapport aux décorateurs définis comme des fonctions, les décorateurs complexes définis comme des classes sont plus simples. Lorsqu'un objet est créé, la méthode __init__ est seulement autorisée à renvoyer None et le type de l'objet créé ne peut pas être modifié. Cela signifie que lorsqu'un décorateur est défini comme une classe, utiliser la forme sans argument n'aurait pas beaucoup de sens : l'objet finalement décoré serait juste une instance de la classe de décoration, renvoyée par l'appel du constructeur, ce qui ne serait pas très utile. Par conséquent, il suffit de discuter des décorateurs basés sur les classes où les arguments sont donnés dans l'expression du décorateur et la méthode __init__ du décorateur est utilisée pour la construction du décorateur.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
>>> class decorator_class(object):
...   def __init__(self, arg):
...       # Cette méthode est appelée dans l'expression du décorateur
...       print("in decorator init, %s" % arg)
...       self.arg = arg
...   def __call__(self, function):
...       # Cette méthode est appelée pour faire le travail
...       print("in decorator call, %s" % self.arg)
...       return function
>>> deco_instance = decorator_class('foo')
in decorator init, foo
>>> @deco_instance
... def function(*args, **kwargs):
...   print("in function, %s %s" % (args, kwargs))
in decorator call, foo
>>> function()
in function, () {}

Contrairement aux règles habituelles (PEP 8), les décorateurs écrits comme classes se comportent plus comme des fonctions et, par conséquent, leurs noms commencent souvent par une lettre minuscule.

En réalité, il n'est pas utile de créer une nouvelle classe simplement pour avoir un décorateur qui renvoie la fonction originale. Les objets sont censés conserver leur état, et de tels décorateurs sont plus utiles lorsque le décorateur renvoie un nouvel objet.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
>>> class replacing_decorator_class(object):
...   def __init__(self, arg):
...       # cette méthode est appelée dans l'expression du décorateur
...       print("in decorator init, %s" % arg)
...       self.arg = arg
...   def __call__(self, function):
...       # Cette méthode est appelée pour faire le travail
...       print("in decorator call, %s" % self.arg)
...       self.function = function
...       return self._wrapper
...   def _wrapper(self, *args, **kwargs):
...       print("in the wrapper, %s %s" % (args, kwargs))
...       return self.function(*args, **kwargs)
>>> deco_instance = replacing_decorator_class('foo')
in decorator init, foo
>>> @deco_instance
... def function(*args, **kwargs):
...   print("in function, %s %s" % (args, kwargs))
in decorator call, foo
>>> function(11, 12)
in the wrapper, (11, 12) {}
in function, (11, 12) {}

Un décorateur comme celui-ci peut à peu près tout faire, car il peut modifier l'objet de la fonction d'origine et modifier ou même défigurer les arguments, appeler la fonction d'origine ou non, et ensuite modifier la valeur de retour.

II-C. Copier la docstring et d'autres attributs de la fonction d'origine

Lorsqu'une nouvelle fonction est renvoyée par le décorateur pour remplacer la fonction d'origine, une conséquence fâcheuse est que le nom de la fonction d'origine, la docstring d'origine, la liste des arguments d'origine sont perdus. Ces attributs de la fonction d'origine peuvent être partiellement « transplantés » à la nouvelle fonction en définissant __doc__ (la docstring), __module__ et __name__ (nom complet de la fonction) et __annotations__ (les informations supplémentaires sur les arguments et la valeur de retour de la fonction disponible dans Python 3). Cela peut se faire automatiquement en utilisant functools.update_wrapper.

functools.update_wrapper(wrapper, wrapped)
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
>>> import functools
>>> def replacing_decorator_with_args(arg):
...   print("defining the decorator")
...   def _decorator(function):
...       print("doing decoration, %r" % arg)
...       def _wrapper(*args, **kwargs):
...           print("inside wrapper, %r %r" % (args, kwargs))
...           return function(*args, **kwargs)
...       return functools.update_wrapper(_wrapper, function)
...   return _decorator
>>> @replacing_decorator_with_args("abc")
... def function():
...     "extensive documentation"
...     print("inside function")
...     return 14
defining the decorator
doing decoration, 'abc'
>>> function                           
<function function at 0x...>
>>> print(function.__doc__)
extensive documentation

Une chose importante manque dans la liste des attributs qui peuvent être copiés dans la fonction de remplacement : la liste des arguments. Les valeurs par défaut pour les arguments peuvent être modifiées à l'aide des attributs __defaults__, __kwdefaults__, mais malheureusement, la liste d'arguments elle-même ne peut pas être définie comme un attribut. Cela signifie que help(function) affichera une liste d'arguments inutiles qui perturbera l'utilisateur de la fonction. Une manière efficace, mais laide, de résoudre ce problème, c'est de créer dynamiquement un wrapper, en utilisant eval. Cela peut être automatisé en utilisant le module externe decorator. Il fournit un support decorator pour le décorateur, qui prend un wrapper et le transforme en un décorateur qui préserve la signature de la fonction.

Pour résumer, les décorateurs doivent toujours utiliser functools.update_wrapper ou d'autres moyens pour copier les attributs de la fonction.

II-D. Exemples dans la bibliothèque standard

Tout d'abord, il convient de noter qu'il existe un certain nombre de décorateurs utiles disponibles dans la bibliothèque standard. Il y a trois décorateurs qui font partie du langage :

  • avec classmethod, une méthode devient une « méthode de classe », ce qui signifie qu'elle peut être invoquée sans devoir instancier un objet de cette classe. Lorsqu'une méthode normale est invoquée, l'interpréteur insère l'objet d'instance comme premier paramètre de position, self. Lorsqu'une méthode de classe est invoquée, la classe elle-même est fournie comme le premier paramètre, souvent appelé cls.

Les méthodes de classe sont toujours accessibles via l'espace de noms de la classe, de sorte qu'elles ne polluent pas l'espace de noms du module. Les méthodes de classe peuvent être utilisées pour fournir des constructeurs de remplacement :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
class Array(object):
    def __init__(self, data):
        self.data = data

    @classmethod
    def fromfile(cls, file):
        data = numpy.load(file)
        return cls(data)

C'est plus propre que d'utiliser une multitude d'indicateurs à __init__ ;

  • staticmethod s'applique à des méthodes pour les rendre « statiques », c'est-à-dire en faire essentiellement des fonctions normales, mais accessibles par l'espace de noms de la classe. Cela peut être utile lorsque la fonction n'est nécessaire que dans cette classe (son nom serait alors préfixé avec _), ou lorsque l'on veut que l'utilisateur pense que la méthode est connectée à la classe, malgré une implémentation qui n'exige pas cela ;
  • property est la réponse pythonique au problème des accesseurs (getters) et des mutateurs (setters). Une méthode décorée avec property devient un accesseur qui est automatiquement appelé pour l'accès aux attributs.
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
>>> class A(object):
...   @property
...   def a(self):
...     "an important attribute"
...     return "a value"
>>> A.a                                   
<property object at 0x...>
>>> A().a
'a value'

Dans cet exemple, A.a est un attribut en lecture seule. Il est également documenté : help (A) intègre la docstring pour l'attribut a tiré de l'accesseur. Définir a comme une propriété lui permet d'être calculé à la volée et par conséquent le rend accessible en lecture seule, car aucun mutateur n'est défini.

Pour avoir un accesseur et un mutateur, deux méthodes sont évidemment nécessaires. Depuis Python 2.6, la syntaxe suivante est adoptée :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
class Rectangle(object):
    def __init__(self, edge):
        self.edge = edge

    @property
    def area(self):
        """Aire calculée.

        Ceci met à jour la longueur du côté à la valeur appropriée.
        """
        return self.edge**2

    @area.setter
    def area(self, area):
        self.edge = area ** 0.5

Cela fonctionne de sorte que le décorateur property remplace l'accesseur par un objet propriété. Cet objet à son tour comporte trois méthodes, getter, setter et deleter, qui peuvent être utilisées comme décorateurs. Leur travail est de définir l'accesseur, le mutateur et l'effaceur de l'objet propriété (stocké comme attributs fget, fset et fdel). L'accesseur peut être configuré comme dans l'exemple ci-dessus, lors de la création de l'objet. Lors de la définition du mutateur, nous avons déjà l'objet de propriété sous area, et nous ajoutons le mutateur à celui-ci en utilisant la méthode setter. Tout cela se produit lorsque nous créons la classe.

Par la suite, lorsqu'une instance de la classe est créée, l'objet de propriété est spécial. Lorsque l'interpréteur exécute l'accès, l'affectation ou la suppression des attributs, le travail est délégué aux méthodes de l'objet de propriété.

Pour que tout soit clair, définissons un exemple de « débogage » :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
>>> class D(object):
...    @property
...    def a(self):
...      print("getting 1")
...      return 1
...    @a.setter
...    def a(self, value):
...      print("setting %r" % value)
...    @a.deleter
...    def a(self):
...      print("deleting")
>>> D.a                                    
<property object at 0x...>
>>> D.a.fget                               
<function ...>
>>> D.a.fset                               
<function ...>
>>> D.a.fdel                               
<function ...>
>>> d = D()               # ...varie, ce n'est pas la même fonction `a` 
>>> d.a
getting 1
1
>>> d.a = 2
setting 2
>>> del d.a
deleting
>>> d.a
getting 1
1

Les propriétés sont une sorte d'extension de la syntaxe des décorateurs. L'une des prémisses de la syntaxe du décorateur - la non-duplication du nom - est violée, mais rien de mieux n'a été inventé jusqu'à présent. Utiliser le même nom pour les méthodes d'accès, de mutation et d'effacement est une bonne pratique stylistique.

Voici quelques exemples plus récents :

  • functools.lru_cache mémoïse une fonction arbitraire en maintenant un cache limité de paires argument-réponse (Python 3.2)
  • functools.total_ordering est un décorateur de classe qui remplit les méthodes de comparaison d'ordre manquantes (__lt__, __gt__, __le__……) sur la base du seul qui soit disponible (Python 2.7).

II-E. Dépréciation de fonctions

Supposons que nous voulions afficher un avertissement de dépréciation sur stderr lors de la première invocation d'une fonction que nous n'aimons plus. Si nous ne voulons pas modifier la fonction, nous pouvons utiliser un décorateur :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
class deprecated(object):
    """Imprime un avertissement de dépréciation lors de la première utilisation de la fonction. 

    >>> @deprecated()                    # doctest: +SKIP
    ... def f():
    ...     pass
    >>> f()                              # doctest: +SKIP
    f is deprecated
    """
    def __call__(self, func):
        self.func = func
        self.count = 0
        return self._wrapper
    def _wrapper(self, *args, **kwargs):
        self.count += 1
        if self.count == 1:
            print self.func.__name__, 'is deprecated'
        return self.func(*args, **kwargs)

Il est aussi possible de mettre en place ce genre d'avertissement sous la forme d'une fonction :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
def deprecated(func):
    """Imprime un avertissement de dépréciation lors de la première utilisation de la fonction. 

    >>> @deprecated                      # doctest: +SKIP
    ... def f():
    ...     pass
    >>> f()                              # doctest: +SKIP
    f is deprecated
    """
    count = [0]
    def wrapper(*args, **kwargs):
        count[0] += 1
        if count[0] == 1:
            print func.__name__, 'is deprecated'
        return func(*args, **kwargs)
    return wrapper

II-F. Un décorateur de suppression de boucles While

Considérons une fonction qui renvoie une liste de choses, et cette liste est créée en exécutant une boucle. Si nous ne savons pas combien d'objets seront nécessaires, la façon standard de le faire ressemble à ceci :

 
Sélectionnez
def find_answers():
    answers = []
    while True:
        ans = look_for_next_answer()
        if ans is None:
            break
        answers.append(ans)
    return answers

C'est bien tant que le corps de la boucle reste assez compact. Quand cela devient plus compliqué, comme c'est souvent le cas dans un code réel, cela devient assez illisible. Nous pourrions simplifier cela en utilisant des instructions yield, mais l'utilisateur devrait appeler explicitement list(find_answers()).

Nous pouvons définir un décorateur qui nous construit la liste :

 
Sélectionnez
1.
2.
3.
4.
def vectorized(generator_func):
    def wrapper(*args, **kwargs):
        return list(generator_func(*args, **kwargs))
    return functools.update_wrapper(wrapper, generator_func)

Notre fonction devient alors :

 
Sélectionnez
@vectorized
def find_answers():
    while True:
        ans = look_for_next_answer()
        if ans is None:
            break
        yield ans

II-G. Un système d'enregistrement des plugins

Il s'agit d'un décorateur de classe qui ne modifie pas la classe, mais la place dans un registre global. Il appartient à la catégorie des décorateurs qui renvoient l'objet d'origine :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
class WordProcessor(object):
    PLUGINS = []
    def process(self, text):
        for plugin in self.PLUGINS:
            text = plugin().cleanup(text)
        return text

    @classmethod
    def plugin(cls, plugin):
        cls.PLUGINS.append(plugin)

@WordProcessor.plugin
class CleanMdashesExtension(object):
    def cleanup(self, text):
        return text.replace('&mdash;', u'\N{em dash}')

Nous utilisons ici un décorateur pour décentraliser l'enregistrement des plugins. Nous appelons notre décorateur avec un nom, au lieu d'un verbe, parce que nous l'utilisons pour déclarer que notre classe est un plugin pour WordProcessor. La méthode plugin ajoute simplement la classe à la liste des plugins.

Un mot sur le plugin lui-même : il remplace l'entité HTML pour le tiret long (&mdash;) par un véritable caractère tiret long en Unicode. Il exploite la notation littérale unicode pour insérer un caractère en utilisant son nom dans la base de données unicode (« em dash »). Si le caractère Unicode était inséré directement, il serait impossible de le distinguer d'un tiret long dans le code source d'un programme.

III. Gestionnaires de contexte

Un gestionnaire de contexte est un objet avec les méthodes __enter__ et __exit__ qui peuvent être utilisées dans l'instruction with :

 
Sélectionnez
with manager as var:
    do_something(var)

est le cas le plus simple équivalent à :

 
Sélectionnez
var = manager.__enter__()
try:
    do_something(var)
finally:
    manager.__exit__()

Autrement dit, le protocole de gestionnaire de contexte défini dans la PEP 343 permet l'extraction de la partie ennuyeuse d'une structure try..except..finally dans une classe séparée laissant seulement le bloc intéressant do_something.

  1. La méthode __enter__ est appelée d'abord. Il peut renvoyer une valeur qui sera affectée à var. La partie as est facultative : si elle n'est pas présente, la valeur renvoyée par __enter__ est simplement ignorée.
  2. Le bloc de code en dessous de with est exécuté. Tout comme avec les clauses try, il peut être exécuté avec succès jusqu'à la fin, ou il peut s'interrompre sur un break, un continue ou un return, ou encore déclencher une exception. Quoi qu'il en soit, une fois le bloc terminé, on appelle la méthode __exit__. Si une exception a été déclenchée, les informations sur l'exception sont passées à __exit__, qui est décrit ci-dessous dans la prochaine sous-section. Dans le cas normal, les exceptions peuvent être ignorées, tout comme dans une clause finally, et elles seront déclenchées à nouveau après la fin de __exit__.

Supposons que nous voulions nous assurer que le fichier soit fermé immédiatement après la fin de l'écriture :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
>>> class closing(object):
...   def __init__(self, obj):
...     self.obj = obj
...   def __enter__(self):
...     return self.obj
...   def __exit__(self, *args):
...     self.obj.close()
>>> with closing(open('/tmp/file', 'w')) as f:
...   f.write('the contents\n')

Ici, nous nous sommes assuré que la méthode f.close () est appelée lorsque le bloc with se termine. Comme la fermeture des fichiers est une opération très courante, ce genre d'opération est assuré dans la classe file. Elle a une méthode __exit__ qui appelle close et peut être utilisée comme gestionnaire de contexte :

 
Sélectionnez
1.
2.
>>> with open('/tmp/file', 'a') as f:
...   f.write('more contents\n')

Une utilisation courante de try..finally est de libérer des ressources. Différents cas sont implémentés de la même manière : dans la phase __enter__, la ressource est acquise ; dans la phase __exit__ elle est libérée ; et l'exception, si elle est déclenchée, se propage. Comme pour les fichiers, il y a souvent une opération naturelle à effectuer après l'utilisation de l'objet et il est très pratique de le faire de façon automatique. À chaque nouvelle version, Python offre de nouvelles possibilités :

  • contextlib.closing : identique à l'exemple ci-dessus, appelle close ;
  • Programmation en parallèle

    • concurrent.futures.ThreadPoolExecutor : invoque en parallèle puis détruit le pool de thread (py >= 3.2),
    • concurrent.futures.ProcessPoolExecutor : invoque en parallèle puis détruit le pool de processus (py >= 3.2),
    • nogil : résout le problème GIL temporairement (seulement cython  :( ).

III-A. Intercepter les exceptions

Lorsqu'une exception est déclenchée dans le bloc with, elle est transmise en paramètre à __exit__. Trois arguments sont utilisés, identiques à ceux renvoyés par sys.exc_info() : type, valeur, traceback. Lorsqu'il n'y a aucune exception, les arguments sont positionnés à None. Le gestionnaire de contexte peut « avaler » l'exception en renvoyant une valeur réelle de __exit__. Les exceptions peuvent être facilement ignorées, car si __exit__ n'utilise pas return et n'échoue qu'à la fin, None est renvoyé, une valeur fausse, et donc l'exception est déclenchée après la fin de __exit__.

La possibilité d'intercepter des exceptions ouvre des possibilités intéressantes. Un exemple classique est celui des tests unitaires - nous voulons nous assurer que le code signale le bon type d'exception :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
class assert_raises(object):
    # basé sur pytest et unittest.TestCase 
    def __init__(self, type):
        self.type = type
    def __enter__(self):
        pass
    def __exit__(self, type, value, traceback):
        if type is None:
            raise AssertionError('exception expected')
        if issubclass(type, self.type):
            return True # swallow the expected exception
        raise AssertionError('wrong exception type')

with assert_raises(KeyError):
    {}['foo']

III-B. Utilisation de générateurs pour définir les gestionnaires de contexte

Lors de la discussion sur les générateurs, on a dit que nous préférions les générateurs aux itérateurs mis en œuvre en tant que classes parce qu'ils sont plus courts et plus faciles, et parce que l'état est stocké dans une variable locale, et non une variable d'instance. D'autre part, comme nous l'avons vu dans la section sur la communication bidirectionnelle, le flux de données entre le générateur et son appelant peut être bidirectionnel. Cela comprend les exceptions, qui peuvent être déclenchées dans le générateur. Nous souhaitons implémenter des gestionnaires de contexte sous la forme de fonctions génératrices spéciales. En fait, le protocole de générateur a été conçu pour permettre ce cas d'utilisation.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
@contextlib.contextmanager
def some_generator(<arguments>):
    <setup>
    try:
        yield <value>
    finally:
        <cleanup>

L'assistant contextelib.contextmanager prend un générateur et le transforme en un gestionnaire de contexte. Le générateur doit obéir à certaines règles qui sont appliquées par la fonction wrapper - surtout il doit y avoir un appel à l'instruction yield et un seul. La partie avant yield est exécutée à partir de __enter__, le bloc de code protégé par le gestionnaire de contexte est exécuté lorsque le générateur est suspendu à yield et le reste est exécuté dans __exit__. Si une exception est signalée, l'interpréteur la transmet au wrapper par le biais des arguments d'__exit__, et la fonction wrapper déclenche alors l'exception au point de l'instruction yield. Grâce à l'utilisation de générateurs, le gestionnaire de contexte est plus court et plus simple.

Réécrivons l'exemple closing sous la forme d'un générateur :

 
Sélectionnez
@contextlib.contextmanager
def closing(obj):
    try:
        yield obj
    finally:
        obj.close()

Réécrivons l'exemple assert_raises sous la forme d'un générateur :

 
Sélectionnez
@contextlib.contextmanager
def assert_raises(type):
    try:
        yield
    except type:
        return
    except Exception as value:
        raise AssertionError('wrong exception type')
    else:
        raise AssertionError('exception expected')

Nous utilisons ici un décorateur pour transformer les fonctions du générateur en gestionnaires de contexte !

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Licence Creative Commons
Le contenu de cet article est rédigé par Zbigniew Jędrzejewski-Szmek et est mis à disposition selon les termes de la Licence Creative Commons Attribution 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2013 Developpez.com.