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édemment, 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ès 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 acquis la compréhension transcendantale de yield
, c’est de savoir que quand on appelle la fonction, le code de la fonction n’est pas exécute. 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’exé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’exécute.
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 exé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é là-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 capot, 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 internes 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 |
Petit typo dans l’exemple concret (merci pour le café):
Super bien expliqué !
:-)
Merci à tous les deux.
(la flemme de mettre un tampon…)
Petite coquille également :
for mot in extraire_mots(dossier):
(pour les gens qui reprennent vos exemples en copié-collé ^^’)
Un tout tout grand merci pour ce blog, apprenant python sur le tas pour les necessite de ma recherche, je peux enfin arriver a faire des choses un peu plus complexe grace a vous. La par exemple je suis dans le tuto sur les classes, et je vais peut etre enfin arriver a comprendre la POO.
Pour que ce message soit un tantinet utile, dans la phrase
“creerGenerateur() n’éxécute pas le code de creerGenerateur.creerGenerateur() retourne un objet générateur.” il manque peut etre un “mais” (ok c’etait pas si utile que ca finalement)
Effectivement y a moyen de rendre ça plus clair. J’ai fais un édit :-)
Si j’ai bien compris, l’intérêt de yield est de ne pas stocker en mémoire une grosse liste d’élément pour s’en servir mais d’aller chercher l’info dont on a besoin, s’en servir et tout de suite l’éliminer de la mémoire ?
J’espère que je ne dis pas de bêtises, c’est juste pour bien comprendre quand utiliser yield plutôt que de retourner une liste.
Tout à fait. Yield permet aussi d’applanir des algorithmes complexes pour les exposer comme le parcours d’une liste.
Yo ! Je me suis fais un petit algo récursif pour calculer toutes les combinaisons de n entiers dont la somme fait m.
Et pour pas exploser la pile je me demandais si c’était possible d’utiliser yield. Mais c’est pas évident évident …
def pilepoile(n, taille):
if taille == 1:
return [[n]]
else:
toutes_les_listes = []
for i in range(n + 1):
intermediates = pilepoile(n-i, taille -1)
for l in intermediates:
l.insert(0,i)
toutes_les_listes.extend(intermediates)
return toutes_les_listes
def pilepoile(n, taille):
if taille == 1:
return [[n]]
else:
toutes_les_listes = []
for i in range(n + 1):
intermediates = pilepoile(n-i, taille -1)
for l in intermediates:
l.insert(0,i)
toutes_les_listes.extend(intermediates)
return toutes_les_listes
Désolé j’arrive pas à utiliser les tags de code proprement tamponnez moi fort.
Très bonne explication qui permet d’apréhender les subtilités des générateurs !
Super explication, bravo !
juste un petit update pour python 3, si je ne m’abuse on écrira plutôt par exemple
distribuer.next() ou next(distribuer)
en lieu et place de
distribuer.next()
qui n’existe plus
L’article n’a pas encore été mis à jour python 3.