backend / python
Itérateurs et générateurs
Explication
Ce que vous allez apprendre
- Comprendre le protocole
iter()/next()/StopIterationqui alimente chaque bouclefor - Transformer une classe itérateur verbeuse en une simple fonction générateur avec
yield - Utiliser
yield frompour déléguer à un sous-générateur, notamment en récursion - Envoyer des valeurs dans un générateur en cours d'exécution avec
.send() - Choisir un générateur plutôt qu'une liste pour traiter un gros volume sans saturer la mémoire
Dans quel contexte ?
Un développeur doit traiter un fichier de logs de plusieurs gigaoctets ligne par ligne pour compter les erreurs 500. Charger tout le fichier avec lignes = f.readlines() avant de le parcourir sature la mémoire du serveur et fait planter le script. Écrire une fonction générateur lire_lignes(chemin) qui yield une ligne à la fois résout le problème : le fichier n'est jamais entièrement chargé en mémoire, seule la ligne courante l'est.
Ce qui se cache derrière chaque boucle for
Chaque fois que vous écrivez for x in quelque_chose, Python suit un protocole précis en coulisses : il demande un itérateur à l'objet via iter(), puis appelle next() dessus en boucle jusqu'à recevoir un signal d'arrêt (StopIteration). Comprendre ce mécanisme démystifie complètement le fonctionnement de toutes les boucles que vous avez utilisées depuis le début du cours.
Les générateurs : le même résultat, en beaucoup plus simple
Écrire une classe complète avec __iter__ et __next__ pour un simple compteur est verbeux. Une fonction générateur, qui utilise yield au lieu de return, produit exactement le même comportement en quelques lignes. Le mot-clé yield suspend l'exécution de la fonction à cet endroit précis et rend la main à l'appelant, tout en conservant l'état interne intact pour la prochaine reprise.
| Approche | Lignes de code | Mémoire pour 1 million d'éléments |
|---|---|---|
Classe avec __iter__/__next__ | ~10 lignes | Quasi constante |
Fonction générateur avec yield | ~4 lignes | Quasi constante |
| Liste construite entièrement | 1 ligne | Plusieurs dizaines de Mo |
Bonne pratique
Dès qu'une fonction produit une séquence de valeurs qui sera consommée une par une (dans un for, par exemple), préférez yield à la construction d'une liste complète avec return liste. Le code appelant ne voit presque aucune différence, mais la consommation mémoire change radicalement sur de gros volumes.
La paresse comme philosophie
Un générateur ne calcule rien tant qu'on ne le lui demande pas. C'est cette propriété qui permet d'itérer sur un flux théoriquement infini de nombres, ou de traiter un fichier de plusieurs gigaoctets ligne par ligne sans jamais le charger entièrement en mémoire. C'est un changement de mentalité important par rapport aux compréhensions de listes vues plus tôt, qui elles calculent tout immédiatement.
Aller plus loin : délégation et communication bidirectionnelle
yield from délègue élégamment à un sous-générateur, très utile pour des structures récursives comme l'aplatissement de listes imbriquées. Et send() permet même d'envoyer une valeur à l'intérieur d'un générateur en cours d'exécution, transformant le générateur en une véritable petite coroutine capable de dialoguer avec l'extérieur.
Commandes & code
Itérateurs et générateurs
Le protocole qui alimente tous les for de Python, et comment l'exploiter.
# Le protocole iterateur : __iter__ retourne un iterateur, __next__ retourne l'element suivant
class CompteurBornes:
def __init__(self, debut, fin):
self.actuel = debut
self.fin = fin
def __iter__(self):
return self # l'objet est son propre iterateur
def __next__(self):
if self.actuel >= self.fin:
raise StopIteration
valeur = self.actuel
self.actuel += 1
return valeur
for n in CompteurBornes(0, 5):
print(n)
# Ce que fait "for" en coulisses :
it = iter(CompteurBornes(0, 3))
while True:
try:
print(next(it))
except StopIteration:
break
# --- Generateurs : meme resultat, syntaxe radicalement plus simple ---
def compteur_bornes(debut, fin):
actuel = debut
while actuel < fin:
yield actuel # suspend l'execution et renvoie une valeur
actuel += 1
for n in compteur_bornes(0, 5):
print(n)
# Un generateur est paresseux : rien n'est calcule avant qu'on le demande
def nombres_infinis():
n = 0
while True:
yield n
n += 1
gen = nombres_infinis()
print(next(gen), next(gen), next(gen)) # 0 1 2 -- consomme a la demande
# Generateur pour traiter un gros fichier sans le charger entierement en memoire
def lire_gros_fichier(chemin):
with open(chemin, encoding="utf-8") as f:
for ligne in f:
yield ligne.strip()
# yield from : deleguer a un sous-generateur ou sous-iterable
def aplatir(structure):
for element in structure:
if isinstance(element, list):
yield from aplatir(element) # delegation recursive
else:
yield element
print(list(aplatir([1, [2, 3, [4, 5]], 6]))) # [1, 2, 3, 4, 5, 6]
# Generateurs bidirectionnels : send() envoie une valeur DANS le generateur
def accumulateur():
total = 0
while True:
valeur = yield total # yield retourne total, ET recoit la prochaine valeur envoyee
total += valeur
acc = accumulateur()
next(acc) # "amorcer" le generateur (avance jusqu'au premier yield)
print(acc.send(10)) # 10
print(acc.send(5)) # 15
print(acc.send(20)) # 35
# close() et gestion des exceptions dans un generateur
def generateur_avec_nettoyage():
try:
yield 1
yield 2
finally:
print("Nettoyage du generateur execute")
g = generateur_avec_nettoyage()
next(g)
g.close() # declenche le "finally" a l'interieur du generateur
# itertools : boite a outils pour manipuler des iterateurs efficacement
import itertools
print(list(itertools.islice(nombres_infinis(), 5))) # prend les 5 premiers
print(list(itertools.chain([1, 2], [3, 4]))) # concatene sans copier
print(list(itertools.combinations([1, 2, 3], 2))) # combinaisons
print(list(itertools.groupby("aaabbbcc"))) # regroupe les elements consecutifs
print(list(itertools.accumulate([1, 2, 3, 4]))) # sommes cumulees
# Comparaison memoire : generateur vs liste sur un grand volume
import sys
liste = [x for x in range(100_000)]
generateur = (x for x in range(100_000))
print(sys.getsizeof(liste), "octets pour la liste")
print(sys.getsizeof(generateur), "octets pour le generateur") # bien plus petitRésumé
yieldtransforme une fonction en générateur : l'exécution se suspend et reprend à la demande.yield fromdélègue à un sous-générateur, utile pour la récursion ou la composition.send()permet d'envoyer une valeur dans le générateur au point de suspension.- Les générateurs consomment une mémoire quasi constante, contrairement aux listes.
Exercices pratiques
Mission : diagnostiquer un générateur déjà épuisé
Objectif : Comprendre pourquoi un générateur ne peut être parcouru qu'une seule fois, puis convertir une classe itérateur verbeuse en générateur et écrire une délégation récursive.
Contexte
Un rapport statistique appelle resultats = filtrer_erreurs(logs) (une fonction générateur), calcule total = sum(resultats) pour compter les erreurs, puis tente d'afficher for r in resultats: print(r) juste après pour lister ces erreurs. La boucle ne s'exécute jamais et personne ne comprend pourquoi, alors que filtrer_erreurs fonctionne parfaitement en isolation.
Tu dois expliquer ce comportement, réécrire une classe itérateur verbeuse en générateur, puis écrire une fonction qui aplatit une structure de listes imbriquées avec yield from.