Comment utiliser yield et les générateurs en Python ? 19


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 intension, 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 “forin…” 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

19 thoughts on “Comment utiliser yield et les générateurs en Python ?

  • Poulet 2.0

    Petit typo dans l’exemple concret (merci pour le café):

            for mot in ligne.s<strong>p</strong>lit():
  • Sam Post author

    :-)

    Merci à tous les deux.

    (la flemme de mettre un tampon…)

  • Titus Crow

    Petite coquille également :
    for mot in extraire_mots(dossier):

    (pour les gens qui reprennent vos exemples en copié-collé ^^’)

  • Feadurn

    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)

  • Sam Post author

    Effectivement y a moyen de rendre ça plus clair. J’ai fais un édit :-)

  • Krikor

    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.

  • Sam Post author

    Tout à fait. Yield permet aussi d’applanir des algorithmes complexes pour les exposer comme le parcours d’une liste.

  • policier moustachu

    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

  • policier moustachu

    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

  • policier moustachu

    Désolé j’arrive pas à utiliser les tags de code proprement tamponnez moi fort.

  • GUILLAUME LE GALL

    Très bonne explication qui permet d’apréhender les subtilités des générateurs !

  • Maejoz

    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

  • Darkapus

    Je suis en train d’apprendre le python pour faire un crawler rapide (j’essaye d’utiliser scrapy)

    Avant j’utilisais PHPCrawler .. et franchement trop lent, me fallait une solution pour pacourir le web plus rapidement.

    Bref je me demandais justement à quoi servait Yield ^^ maintenant c’est concret dans ma tête :D

    Merci

  • Maritoun

    Bonjour,

    C’est mon premier Comment sur ce site que je trouve exceptionnel. Il est d’une grande intelligence dans sa pédagogie. Je voulais juste poser une question, pardon si elle est redondante.

    Dans l’article il est dit que tout objet sur lequel on peut faire “for .. in” est par définition un itérable. Faut il cependant exclure les générateurs et disant qu’ils sont l’exception à la règle. Ou alors rajouter qu’est itérable ce qui est stocké en mémoire en intégralité (pas généré à la volée) (ce qui est presque sous entendu juste après : “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.”).

    Si on inclus les générateurs dans l’ensemble des itérables, on part en itéception (solvable though) avec le “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:”

    Merci pour votre travail en tout cas. Best Python tutorials eva !

  • Sam Post author

    Le fait de stocker ou non les éléments en mémoires ne change rien à la nature d’être itérable ou non. Si on peut faire iter() dessus, on peut faire une boucle for dessus, et donc c’est itérable :)

  • Bloubi

    “on utilise une liste en intension (wp) non ?

    merci pour l’article sinon, bien cool comme toujours !

Leave a comment

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <pre> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.