Outils pour utilisateurs

Outils du site


cours:informatique:fun_mooc:python3_uca_inria:540_expressions

Ceci est une ancienne révision du document !


Expressions et fonctions génératrices

Précédemment nous avons vu les notions de compréhensions, compréhensions de liste, de *set* et de dictionnaire. Et nous savons que les compréhensions constituent un moyen extrêmement simple et expressif de parcourir des itérables et d'appliquer des traitements sur ces itérables.

Ces compréhensions cependant ont un inconvénient majeur, c'est qu'elles créent des structures de données temporaires; une compréhension de liste va créer une liste temporaire. Or, si par exemple, on veut juste calculer, au final, uniquement la somme des éléments composant cette compréhension, on a créé une liste pour rien.

Pour répondre à ce type de problématique il existe l' expression génératrice. Une expression génératrice utilise une syntaxe très proche d'une compréhension de liste, mais la différence, c'est que ce qui sera retourné sera un itérateur et non pas une liste, un set ou un dictionnaire. C'est donc quelque chose qui permet d'économiser énormément de mémoire tout en gardant toute la fonctionnalité d'une compréhension.

Nous aborderons également la généralisation des expressions génératrices que l'on appelle fonctions génératrices.

Expression génératrice

# compréhension de liste simple
# produit une liste en mémoire
>>> carre = [x**2 for x in range(1000)]

Cette liste est créée en mémoire et contient les carrés de tous les entiers allant de 0 à 999. Mais supposons maintenant que je veuille simplement calculer la somme de ces carrés. Pour calculer la somme des éléments d'un itérable, on peut utiliser la fonction *built-in* sum:

>>> sum(carre)
332833500

Pour calculer la somme de ces éléments, on peut éviter de créer une liste temporaire qui contient tous ces éléments. Pour cela on va utiliser une expression génératrice.

# cette fois carre référence une expression génératrice.
# L'expression génératrice est encadrée par des parenthèses
>>> carre = ( x**2 for x in range(1000) )
 
# on peut s'en assurer
>>> type(carre)
generator

À la place d'une liste, on obtient un objet *carré* qui s'appelle generator_object. C'est en fait un itérateur qui va calculer à la volée le carré de chaque élément retourné par *range* de 1000. Comme *range* est également un objet qui ne crée pas de liste temporaire mais qui retourne, à chaque fois qu'on l'appelle, l'entier suivant, en fait, on va parcourir tous les éléments de cette expression génératrice sans jamais créer de structure temporaire. Maintenant, on peut calculer la somme de *carré* et l' on va obtenir le même résultat qu'avec la liste mais la grande différence, c'est qu' on ne créé pas de liste temporaire.

On insistera sur le fait que cette expression génératrice est un itérateur ; par conséquent, si on calcule de nouveau somme de *carré*, on va obtenir 0 puisque cet itérateur a été consommé une première fois, il ne génère plus aucun élément. Mais comme la création d'un générateur est une opération extrêmement peu coûteuse puisque, lorsque je crée mon générateur, je ne fais absolument aucun calcul, et que les calculs seront faits à la volée, lorsque j'en ai besoin, je peux très facilement recréer un générateur et de nouveau calculer la somme des carrés.
# l'expression génératrice est un itérateur
# l'appel de la fonction sum le consomme
>>> total = sum(carre)
 
>>> print(total)
332833500
 
# un second appel sur le meme iterateur ne produira
# plus de calcul
>>> sum(carre)
0

Un intérêt des expressions génératrices, c'est qu'on peut les chaîner. Ça nous permet donc d'avoir une succession de traitements qui peut s'exécuter sans jamais avoir besoin de créer des objets temporaires.

Imaginons un exemple: trouver tous les palindromes pour les carrés des nombres entre 0 et 999. Un palindrome, pour un nombre, c'est un nombre qu'on peut lire de gauche à droite ou de droite à gauche. Par exemple, 121 est un palindrome, qu'on le lise de gauche à droite ou de droite à gauche, ça fait toujours 121.

# une première expression génératrice produisant les carrés
# entre 0 et 999
>>> gen_carre = ( x**2 for x in range(1000) )
 
# une seconde expression génératrice, exploitant la première
# elle identifiera les palindromes
>>> palin = (x for x in gen_carre if str(x) == str(x)[::-1])

La seconde expression génératrice convertit l' entier en chaîne de caractères et compare si l'entier reversé est le même. Le renversement d'une chaîne de caractère est obtenu avec un slice de pas -1. [::-1].

Maintenant, si l'on souhaite obtenir ces palindromes dans une liste, on peut créer la liste de manière explicite:

# a ce stade palin est une expression génératrice,
# c'est un itérable mais aucun calcul n'a encore été fait
>>> palin is iter(palin)
True
 
# Pour obtenir les palindromes, on crée la liste, ce qui
# déclenche les calculs et produit les résultats
>>> liste_palindromes = list(palin)
 
>>>  print(liste_palindromes)
[0, 1, 4, 9, 121, 484, 676, 10201, 12321, 14641, 40804, 44944, 69696, 94249, 698896]

Fonctions génératrices

Cette notion d'expression génératrice est très puissante et extrêmement souple. La bonne pratique en Python est de toujours manipuler des itérateurs le plus loin possible sans créer de structure de données temporaire et de ne créer la structure de données qu' à la fin par exemple, pour stocker les résultats.

Évidemment, cette logique est de dire qu'en Python, tout est un objet donc tout prend de la place et il est inutile de créer des objets si on n'en a pas besoin; on ne les crée que quand c'est strictement nécessaire.

Cependant, l'expression génératrice a une limitation, c'est que dans une expression génératrice exactement comme dans une compréhension de liste, on ne peut mettre qu'une expression.

Or, certains traitements plus sophistiqués réclameront plus qu'une expression. Les expressions génératrices ont été généralisées dans un concept qu'on appelle fonctions génératrices. Vous pouvez créer des fonctions qui vont se comporter comme des expressions génératrices. Mais comme ce sera défini à l'intérieur d'une fonction, vous aurez toute la souplesse de ce que vous pouvez définir dans une fonction.

Revenons sur la notion de fonction. Lorsque vous définissez une fonction, votre fonction retourne forcément une valeur. Si vous ne mettez pas d'instruction return, elle va retourner l'objet *None* ; si vous mettez une instruction return, elle va retourner le résultat de l'expression qui suit le mot clé return. Une fonction, lorsqu'elle a retourné, détruit toutes ses variables locales et par conséquent, ne conserve aucun état entre deux appels. Une fonction génératrice a un comportement qui est différent.

On définit une fonction génératrice comme toute autre fonction avec la même syntaxe, on lui donne ici le nom *gen* mais au lieu d'écrire un return, on utilise l'instruction yield.

# définition d'une fonction génératrice 
>>> def gen():
...    print('instructions lues au premier appel')
...    yield 10
...    print('instructions lues au deuxième appel')
...    yield 20
...    print('instructions lues au troisième appel')
...    yield 30

Regardons ce qu'est cet objet *gen*. *gen* est une fonction et lorsque on appelle cette fonction un objet générateur est produit.

# un appel de la fonction instancie un objet generator
# qui est un itérable
>>> gen()
<generator object gen at 0x7fd63c4b9f90>
 
# on peut vérifier facilement que generator est un itérable
>>> g = gen()
 
>>> g is iter(g)
True
 
>>> next(g)
instructions lues au premier appel
10
 
>>> next(g)
instructions lues au deuxième appel
20
 
>>> next(g)
instructions lues au troisième appel
30
 
>>> next(g)
StopIteration                             Traceback (most recent call last)
<ipython-input-70-e734f8aca5ac> in <module>

L'appel de la fonction *gen* a créée un objet. Cet objet est un itérateur que l'on manipule dans l'exemple ci-dessus comme tout autre itérateur. On peut directement le parcourir par appel de la fonction buil-tin *next*. On voit alors apparaître les premières instructions et la valeur retournée est celle présente après la première instruction *yield* rencontrée.

Lors du rappelle de *next*, l'execution reprends là où elle s'était arrêtée, c'est-à-dire au *yield* de 10, et continue jusqu' à la prochaine instruction *yield* ou jusqu' à la fin du bloc d'instruction de la fonction génératrice et retourne alors l'exception *StopIteration* exactement comme un itérateur normal.

Évidemment, en pratique, vous n'allez pas mettre une multitude de `yield` ; ça serait absolument peu pratique. En pratique, vous allez faire plutôt une boucle par exemple, une boucle `for` ou un `while`. Regardons maintenant un exemple un peu plus réaliste.

Définissons un générateur que l'on appellera *carre*, et qui prend deux arguments *a* et *b*. Ce générateur produira les carrés entre les bornes a et b via une boucle for.

# définition de la fonction génératrice (générateur)
>>> def carre(a, b): 
...     for i in range(a,b+1): 
...         yield i**2 
 
# on pourra produire les carrés au moment voulu ainsi
>>> [ x for x in carre(0,10) ]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

Dans ce cas d'école extrêmement simple d'un générateur qui produit juste les carrés, on aurait pu mettre ça dans une expression génératrice. Passons à un cas un peu plus sophistiqué où ce serait moins commode d'écrire ce fonctionnement dans une expression génératrice.

Pour cela, on va créer un générateur palindrome qui prend en argument directement un itérateur (ou objet itérable). Et puis ensuite, je vais faire un `for i in it:` - cet objet itérable - et dedans, je vais regarder si `isinstance`. Ça veut dire quoi ? Ça veut dire si *i* est une instance soit d'une chaîne de caractères soit d'un entier, deux possibilités. `and` - alors, je vais mettre tout ça entre parenthèses parce que je vous rappelle que, lorsqu'on écrit une expression entre parenthèses, on peut la mettre sur plusieurs lignes - et *str de i égale égale à str de i en le renversant moins un* `str(i) == str(i)[::-1])` alors - il ne faut pas que j'oublie mon deux points de fin de bloc de code - , alors, je vais simplement faire un `yield de i`. Regardons de nouveau cette expression génératrice. Mon palindrome, ma fonction palindrome, va prendre un itérable ; il va parcourir cet itérable et il va regarder chaque élément: est-ce que c'est un entier ou une chaîne de caractères ? Si c'est un entier ou une chaîne de caractère, il va dire: je sais travailler dessus, je vais le convertir en chaîne de caractères donc si c'est un entier, ça devient une chaîne, si c'est une chaîne, ça reste une chaîne, et je regarde si cette chaîne est égale à la chaîne prise dans l'autre sens, donc effectivement, si c'est un palindrome.

J'exécute cela. Et maintenant, je retourne dans mon interpréteur, et je vais évaluer ça. Donc ici, pour commencer comme exemple, je vais simplement lui passer une liste. Donc je vais dire: *p égale la liste qui comprend 121, 10, 12321* donc on voit que j'ai quelques palindromes, et puis des chaînes de caractères 'abc', et puis 'abba'. 'p = palin([121, 10, 12321, 'abc', 'abba'])`. Voilà. J'exécute mon itérateur et maintenant, je récupère tout cela dans une liste, et je vais pouvoir extraire les palindromes, que ce soient des entiers ou des chaînes de caractères.

Évidemment, l'intérêt principal de ma fonction génératrice n'est pas de prendre une liste temporaire mais de travailler directement sur un itérateur. Donc, par exemple, j'aurais tout à fait pu lire, ouvrir un fichier qui contient, sur chaque ligne, un nombre ou une chaîne de caractères et passer directement cet objet fichier à mon générateur palindrome. Dans ce cas, je n'aurais créé aucune structure temporaire, mon générateur palindrome aurait extrait les palindromes contenus dans ce fichier et après, j'aurais pu les mettre dans une liste, dans un ensemble, ou j'aurais pu les traiter d'une autre manière.

Je vais vous montrer une dernière possibilité d'exécuter ce palindrome. Donc on va faire `list(palin)` pour voir le résultat affiché ; et à mon palindrome, je vais simplement lui passer une expression génératrice, qui va être les *carrés de x pour x in range de 1000* `list(palin(x2 for x in range(1000)))`. Et donc là, ici, j'ai passé une expression génératrice à ma fonction génératrice palindrome, donc je n'ai aucune structure de données temporaire qui est créée ; mon expression génératrice va générer les carrés un à un, elle va les passer un à un à mon générateur palindrome, et mon générateur palindrome va ensuite produire les palindromes qui sont contenus dedans. Vous noterez d'ailleurs que lorsque je passe une expression génératrice à une fonction, les parenthèses sont facultatives et dans ce cas-là, je ne les ai pas mises. Regardons le résultat. Et on voit maintenant que j'ai obtenu la liste de tous les palindromes des carrés allant de 0 à 999 sans avoir créé aucune structure de données temporaire. Je vous rappelle qu'en Python tout est un objet, donc tout a un surcoût mémoire. Python vous encourage à ne jamais créer de structure de données temporaire. C'est pourquoi nous avons les notions *d'expression génératrice* ou de fonction génératrice**. Et comme ces objets produisent des *itérateurs* et que le protocole d'itération est universel en Python, vous pouvez utiliser ces expressions ou ces fonctions génératrices absolument dans tous les mécanismes d'itération en Python, les boucles *for* ou d'autres mécanismes d'itération.

cours/informatique/fun_mooc/python3_uca_inria/540_expressions.1621077017.txt.gz · Dernière modification : 2021/05/15 11:10 de 77.192.232.26