Les générateurs sont une fonctionalité fabuleuse de Python, et une étape indispensable dans la maîtrise du langage. Une fois compris, vous ne pourrez plus vous en passer.
Rappel sur les itérables
Quand vous lisez des éléments un par un d’une liste, on appelle cela l’itération:
lst = [1, 2, 3]
>>> for i in lst :
... print(i)
1
2
3
Et quand on utilise une liste en intention, on créé une liste, donc un itérable. Encore une fois, avec une boucle for
, on prend ses éléments un par un, donc on itère dessus:
lst = [x*x for x in range(3)]
>>> for i in lst :
... print(i)
0
1
4
À chaque fois qu’on peut utiliser “for
… in
…” sur quelque chose, c’est un itérable : lists, strings, files…
Ces itérables sont pratiques car on peut les lire autant qu’on veut, mais ce n’est pas toujours idéal car on doit stocker tous les éléments en mémoire.
Les générateurs
Si vous vous souvenez de l’article sur les comprehension lists, on peut également créer des expressions génératrices:
generateur = (x*x for x in range(3))
>>> for i in generateur :
... print(i)
0
1
4
La seule différence avec précédement, c’est qu’on utilise ()
au lieu de []
. Mais on ne peut pas lire generateur
une seconde fois car le principe des générateurs, c’est justement qu’ils génèrent tout à la volée: ici il calcule 0
, puis l’oublie, puis calcule 1
, et l’oublie, et calcule 4
. Tout ça un par un.
Le mot clé yield
yield
est un mot clé utilisé en lieu et place de return
, à la différence prêt qu’on va récupérer un générateur.
>>> def creerGenerateur() :
... mylist = range(3)
... for i in mylist:
... yield i*i
...
>>> generateur = creerGenerateur() # crée un générateur
>>> print(generateur) # generateur est un objet !
< generator object creerGenerateur at 0x2b484b9addc0>
>>> for i in generateur:
... print(i)
0
1
4
Ici c’est un exemple inutile, mais dans la vraie vie vivante, c’est pratique quand on sait que la fonction va retourner de nombreuses valeurs qu’on ne souhaite lire qu’une seule fois.
Le secret des maîtres Zen qui ont aquis la comprenhension transcendentale de yield
, c’est de savoir que quand on appelle la fonction, le code de la fonction n’est pas éxécuté. A la place, la fonction va retourner un objet générateur.
C’est pas évident à comprendre, alors relisez plusieurs fois cette partie.
creerGenerateur()
n’éxécute pas le code de creerGenerateur
. creerGenerateur()
retourne un objet générateur.
En fait, tant qu’on ne touche pas au générateur, il ne se passe rien. Puis, dès qu’on commence à itérer sur le générateur, le code de la fonction s’éxécuter.
La première fois que le code s’éxécute, il va partir du début de la fonction, arriver jusqu’à yield
, et retourner la première valeur. Ensuite, à chaque nouveau tour de boucle, le code va reprendre de la où il s’est arrêté (oui, Python sauvegarde l’état du code du générateur entre chaque appel), et éxécuter le code à nouveau jusqu’à ce qu’il rencontre yield
. Donc dans notre cas, il va faire un tour de boucle.
Il va continuer comme ça jusqu’à ce que le code ne rencontre plus yield
, et donc qu’il n’y a plus de valeur à retourner. Le générateur est alors considéré comme définitivement vide. Il ne peut pas être “rembobiné”, il faut en créer un autre.
La raison pour laquelle le code ne rencontre plus yield est celle de votre choix: condition if
/else
, boucle, recursion… Vous pouvez même yielder à l’infini.
Un exemple concret et un café, plz
yield
permet non seulement d’économiser de la mémoire, mais surtout de masquer la complexité d’un algo derrière une API classique d’itération.
Supposez que vous ayez une fonction qui – tada ! – extrait les mots de plus de 3 caractères de tous les fichiers d’un dossier.
Elle pourrait ressembler à ça:
import os
def extraire_mots(dossier):
for fichier in os.listdir(dossier):
with open(os.path.join(dossier, fichier)) as f:
for ligne in f:
for mot in ligne.split():
if len(mot) > 3:
yield mot
Vous avez là un algo dont on masque complètement la complexité, car du point de vue de l’utilisateur, il fait juste ça:
for mot in extraire_mots(dossier):
print mot
Et pour lui c’est transparent. En plus, il peut utiliser tous les outils qu’on utilise sur les itérables d’habitude. Toutes les fonctions qui acceptent les itérables acceptent donc le résultat de la fonction en paramètre grâce à la magie du duck typing. On créé ainsi une merveilleuse toolbox.
Controller yield
>>> class DistributeurDeCapote():
stock = True
def allumer(self):
while self.stock:
yield "capote"
...
Tant qu’il y a du stock, on peut récupérer autant de capotes que l’on veut.
>>> distributeur_en_bas_de_la_rue = DistributeurDeCapote()
>>> distribuer = distributeur_en_bas_de_la_rue.allumer()
>>> print distribuer.next()
capote
>>> print distribuer.next()
capote
>>> print([distribuer.next() for c in range(4)])
['capote', 'capote', 'capote', 'capote']
Dès qu’il n’y a plus de stock…
>>> distributeur_en_bas_de_la_rue.stock = False
>>> distribuer.next()
Traceback (most recent call last):
File "<ipython-input-22-389e61418395>", line 1, in <module>
distribuer.next()
StopIteration
< type 'exceptions.StopIteration'>
Et c’est vrai pour tout nouveau générateur:
>>> distribuer = distributeur_en_bas_de_la_rue.allumer()
>>> distribuer.next()
Traceback (most recent call last):
File "<ipython-input-24-389e61418395>", line 1, in <module>
distribuer.next()
StopIteration
Allumer une machine vide n’a jamais permis de remplir le stock ;-) Mais il suffit de remplir le stock pour repartir comme en 40:
>>> distributeur_en_bas_de_la_rue.stock = True
>>> distribuer = distributeur_en_bas_de_la_rue.allumer()
>>> for c in distribuer :
... print c
capote
capote
capote
capote
capote
capote
capote
capote
capote
capote
capote
capote
...
itertools
: votre nouveau module favori
Le truc avec les générateurs, c’est qu’il faut les manipuler en prenant en compte leur nature: on ne peut les lire qu’une fois, et on ne peut pas déterminer leur longeur à l’avance. itertools
est un module spécialisé la dedans: map
, zip
, slice
… Il contient des fonctions qui marchent sur tous les itérables, y compris les générateurs.
Et rappelez-vous, les strings, les listes, les sets et même les fichiers sont itérables.
Chaîner deux itérables, et prendre les 10 premiers caractères ? Piece of cake !
>>> import itertools
>>> d = DistributeurDeCapote().allumer()
>>> generateur = itertools.chain("12345", d)
>>> generateur = itertools.islice(generateur, 0, 10)
>>> for x in generateur:
... print x
...
1
2
3
4
5
capote
capote
capote
capote
capote
Les dessous de l’itération
Sous le capôt, tous les itérables utilisent un générateur appelé “itérateur”. On peut récupérer l’itérateur en utiliser la fonction iter()
sur un itérable:
>>> iter([1, 2, 3])
< listiterator object at 0x7f58b9735dd0>
>>> iter((1, 2, 3))
< tupleiterator object at 0x7f58b9735e10>
>>> iter(x*x for x in (1, 2, 3))
< generator object at 0x7f58b9723820>
Les itérateurs ont une méthode next() qui retourne une valeur pour chaque appel de la méthode. Quand il n’y a plus de valeur, ils lèvent l’exception StopIteration
:
>>> gen = iter([1, 2, 3])
>>> gen.next()
1
>>> gen.next()
2
>>> gen.next()
3
>>> gen.next()
Traceback (most recent call last):
File "< stdin>", line 1, in < module>
StopIteration
Message à tous ceux qui pensent que je fabule quand je dis qu’en Python on utilise les exceptions pour contrôler le flux d’un programme (sacrilège !): ceci est le mécanisme des boucles interne en Python. Les boucles for
utilisent iter() pour créer un générateur, puis attrappent une exception pour s’arrêter. À chaque boucle for
, vous levez une exception sans le savoir.
Pour la petite histoire, l’implémentation actuelle est que iter() appelle la méthode __iter__() sur l’objet passé en paramètre. Donc ça veut dire que vous pouvez créer vos propres itérables:
>>> class MonIterableRienQuaMoi(object):
... def __iter__(self):
... yield 'Python'
... yield "ça"
... yield 'déchire'
...
>>> gen = iter(MonIterableRienQuaMoi())
>>> gen.next()
'Python'
>>> gen.next()
'ça'
>>> gen.next()
'déchire'
>>> gen.next()
Traceback (most recent call last):
File "< stdin>", line 1, in < module>
StopIteration
>>> for x in MonIterableRienQuaMoi():
... print x
...
Python
ça
déchire