Les générateurs en Python : produire les valeurs une par une

Un générateur produit ses valeurs une par une, à la demande. Ce que change le mot-clé yield, et pourquoi un générateur ne se parcourt qu'une fois.
7 min de lecture
Believemy logo

Définition

Vous avez un fichier de journal de plusieurs gigaoctets et vous voulez y compter les lignes en erreur. Le réflexe est d'écrire une fonction qui lit tout, range chaque ligne dans une liste, puis rend cette liste. Elle marche sur un petit fichier et fait tomber la machine sur un gros : tout doit tenir en mémoire avant le moindre calcul. Le générateur existe pour éviter cela.

Un générateur est une fonction qui produit ses valeurs une par une, à mesure qu'on les lui demande, au lieu de toutes les calculer puis de les rendre en bloc. On le reconnaît à un mot-clé : son corps contient un yield là où une fonction ordinaire écrirait return. L'appelant ne récupère donc plus un résultat, mais un objet capable de lui en fournir plusieurs.

PYTHON
def carres(n):
    for i in range(n):
        yield i * i            # livre une valeur, puis met la fonction en pause

for valeur in carres(5):
    print(valeur)

# 0 1 4 9 16

Aucune liste n'existe pendant ce parcours : chaque carré est calculé quand la boucle le réclame, transmis, puis oublié. Un générateur tient ainsi dans quelques centaines d'octets. Vous l'utilisez pourtant comme n'importe quelle collection, car c'est un itérateur, donc un itérable que for parcourt sans rien savoir de sa fabrication.


Ce que fait vraiment l'appel

Reste une question que l'exemple laisse en suspens : quand le corps de la fonction s'exécute-t-il ? Appeler carres(5) n'en exécute pas une seule ligne. L'appel fabrique un objet générateur et s'arrête là. Le code démarre à la première valeur demandée, s'interrompt sur le yield, puis reprend à cet endroit précis à la demande suivante, ses variables intactes. Regardez quand chaque print sort.

PYTHON
def compteur():
    print("demarrage")
    yield 1
    print("entre les deux")
    yield 2

c = compteur()   # aucune ligne du corps n'a encore tourne
next(c)          # affiche demarrage, renvoie 1
next(c)          # reprend apres le premier yield, affiche entre les deux, renvoie 2
next(c)          # plus rien a produire : StopIteration

Cette suspension fait tout l'intérêt du procédé : une fonction ordinaire va du début à la fin en une traite et oublie tout en sortant, un générateur garde sa place et ses variables. Quand il n'a plus rien à produire, il lève StopIteration, le signal de fin que la boucle for attrape toute seule. C'est pour cela qu'un parcours se termine proprement sans condition d'arrêt.


Générateur ou liste

Les deux se parcourent de la même façon, et rien, à la lecture d'une boucle, ne dit lequel vous manipulez. Ce qui les sépare tient à la mémoire, au moment du calcul et au nombre de relectures possibles. Le tableau ci-dessous les met côte à côte.

CritèreListeGénérateur
Mémoire occupéeToutes les valeursUne seule à la fois
Moment du calculÀ la constructionÀ chaque demande
Nombre de parcoursIllimitéUn seul
len et slicingDisponiblesIndisponibles
Suite sans finImpossibleNaturelle
Mémoire occupée par une liste et par un générateur quand le nombre d'éléments granditLa mémoire d'une liste grandit avec le nombre d'éléments : 2 Mo pour 250 000 éléments, 8 Mo pour un million, à raison de 8 octets par élément. Un générateur reste à 200 octets, quel que soit le nombre d'éléments qu'il produit.Liste8 octets par élément2 Mo4 Mo6 Mo8 MoGénérateur200 octets250 000500 000750 0001 000 000nombre d'élémentsà un million, la liste occupe 40 000 fois plus

L'écart que montre ce graphique n'est pas une nuance : à un million de valeurs, la liste occupe des dizaines de milliers de fois plus de place. Le choix se fait donc vite. Un générateur dès que la source est volumineuse et traitée au fil de l'eau, une liste dès qu'il faut compter, trier ou relire.

La mémoire d'une liste face à celle d'un générateur
1 000
Liste35,2 Ko
Générateur200 o
soit 180 fois plus pour la liste

Le générateur reste à son poids fixe pendant que la liste grandit avec les données. C'est là qu'il devient le seul praticable, sur un fichier qu'on ne peut pas charger en entier.

Les ordres de grandeur sont ceux de CPython sur une machine 64 bits : environ 28 octets par entier plus 8 octets de pointeur dans la liste, et environ 200 octets pour l'objet générateur, quelle que soit la suite qu'il produit. Mesurez les vôtres avec sys.getsizeof.

Bon à savoir

La confusion la plus fréquente porte sur range, qu'on prend pour un générateur parce qu'il ne stocke rien. C'est un cousin, pas un membre de la famille : il se relit autant de fois qu'on veut et accepte len comme le découpage.


L'expression génératrice

Écrire une fonction entière avec yield pour une suite de valeurs simples est souvent disproportionné. Python offre plus court : une compréhension de liste placée entre parenthèses au lieu de crochets devient une expression génératrice. Le résultat n'est plus une liste constituée d'un coup, mais un générateur.

PYTHON
lourd = [i * i for i in range(10000000)]   # dix millions de valeurs en mémoire
leger = (i * i for i in range(10000000))   # rien du tout, tant qu'on ne parcourt pas

total = sum(i * i for i in range(10000000))   # les parenthèses de sum suffisent

La deuxième ligne ne calcule rien : elle prépare une recette, pas un résultat. La troisième mérite qu'on s'y arrête, car lorsque l'expression est le seul argument d'un appel, les parenthèses de l'appel suffisent. C'est la forme la plus courante au quotidien, bien avant les fonctions à yield : un crochet remplacé par une parenthèse suffit à désamorcer un calcul qui saturerait la mémoire.


Le piège du parcours unique

Un générateur s'épuise. Arrivé au bout, il ne se rembobine pas : la deuxième lecture ne rend plus rien et, surtout, aucune erreur ne vient vous prévenir.

PYTHON
gen = (i for i in range(3))

print(list(gen))   # [0, 1, 2]  le générateur est maintenant vide
print(list(gen))   # []         aucune erreur, juste plus rien

Le symptôme classique apparaît quand un même générateur traverse deux traitements : le premier consomme tout et fonctionne, le second travaille sur du vide et renvoie zéro. Pas d'exception, pas de message, juste un résultat faux que rien ne distingue d'un résultat juste. Deux issues : convertir en liste, ou reconstruire un générateur neuf.

Un générateur refuse aussi certaines opérations. len(gen) lève une TypeError, l'accès par rang également, et connaître le nombre d'éléments impose de tout consommer : on ne peut pas compter ce qui n'a pas encore été fabriqué.

Attention

Un générateur qui lit un fichier n'ouvre rien tant qu'on ne le parcourt pas. Le renvoyer depuis une fonction où le fichier se referme à la sortie du bloc donne un objet valide qui échoue à la première valeur, avec un ValueError sur un fichier fermé. Consommez-le avant la fermeture.


Questions fréquentes

Question

Quelle différence entre yield et return ?

Les deux rendent une valeur, mais pas au même prix. return termine la fonction : la valeur part, l'état local disparaît, rien ne reprendra. yield livre une valeur puis met la fonction en pause, variables comprises. Un return nu reste possible dans un générateur : il ne rend rien et arrête la production.

Question

Un générateur est-il toujours plus rapide ?

Non, et c'est un malentendu répandu. Sur un parcours complet, la liste est souvent un peu plus rapide, car produire chaque valeur à la demande coûte un léger surcoût. Le vrai gain porte sur la mémoire et sur le délai avant la première valeur, décisif quand le parcours s'arrête tôt ou que la suite est très longue.

Question

Comment obtenir une liste à partir d'un générateur ?

Avec list(mon_generateur), qui consomme tout et remet les valeurs en mémoire. L'opération annule l'économie recherchée et ne se justifie que si plusieurs parcours sont nécessaires ; sur une suite sans fin, elle ne se termine jamais. La formation Python revient sur cet arbitrage à travers le traitement de gros fichiers.

Termes connexes

Découvrez notre glossaire Python

Parcourez les termes et définitions les plus couramment utilisés dans le domaine du développement avec Python.

Partager cet article

Tu veux nous aider ? Fais un lien vers cet article sur tes réseaux ou encore mieux : sur ton site, dans un article ou dans ta newsletter.