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.
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 16Aucune 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.
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 : StopIterationCette 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ère | Liste | Générateur |
|---|---|---|
| Mémoire occupée | Toutes les valeurs | Une seule à la fois |
| Moment du calcul | À la construction | À chaque demande |
| Nombre de parcours | Illimité | Un seul |
| len et slicing | Disponibles | Indisponibles |
| Suite sans fin | Impossible | Naturelle |
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.
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.
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.
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 suffisentLa 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.
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 rienLe 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é.
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
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.
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.
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.