Retour au cours

backend / python

Itérateurs et générateurs

Leçon 141 exercice

Explication

Ce que vous allez apprendre

  • Comprendre le protocole iter()/next()/StopIteration qui alimente chaque boucle for
  • Transformer une classe itérateur verbeuse en une simple fonction générateur avec yield
  • Utiliser yield from pour 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.

ApprocheLignes de codeMémoire pour 1 million d'éléments
Classe avec __iter__/__next__~10 lignesQuasi constante
Fonction générateur avec yield~4 lignesQuasi constante
Liste construite entièrement1 lignePlusieurs 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.

python
# 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 petit

Résumé

  • yield transforme une fonction en générateur : l'exécution se suspend et reprend à la demande.
  • yield from dé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

1 disponible
1

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.

Résoudre l’exercice →