42  Itérateurs, générateurs et itertools

Vous utilisez des for depuis le début du cursus. Mais comment fonctionnent-ils en interne ? Comment Python sait-il parcourir une liste, un dict, un fichier ? La réponse : le protocole d’itération — un mécanisme qui permet d’écrire ses propres objets itérables, de produire des données à la demande, et de traiter des flux infinis. C’est la clé de la performance pour de gros volumes.

42.1 Itérables vs itérateurs

Deux notions distinctes à ne pas confondre.

Itérable

Un itérable est un objet sur lequel on peut boucler avec for. Implémente __iter__() qui renvoie un itérateur.

Exemples d’itérables : list, tuple, str, dict, set, range, objets fichier…

Itérateur

Un itérateur est un objet qui produit les éléments un à un. Implémente __next__().

# Une liste est un itérable
liste = [1, 2, 3]

# iter() obtient un itérateur depuis un itérable
it = iter(liste)
print(type(it))

# next() avance d'un élément
print(next(it))
print(next(it))
print(next(it))
<class 'list_iterator'>
1
2
3
# Quand l'itérateur est épuisé, next() lève StopIteration
it = iter([1, 2])
next(it); next(it)
next(it)     # StopIteration
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[2], line 4
      1 # Quand l'itérateur est épuisé, next() lève StopIteration
      2 it = iter([1, 2])
      3 next(it); next(it)
----> 4 next(it)     # StopIteration

StopIteration: 

Ce que fait un for en coulisses

for x in liste:
    print(x)

…est équivalent à :

it = iter(liste)
while True:
    try:
        x = next(it)
    except StopIteration:
        break
    print(x)

Python cache cette complexité, mais c’est utile à connaître pour comprendre certains comportements.

42.2 Créer son itérable (protocole manuel)

On peut créer un objet itérable en implémentant __iter__ et __next__. Rarement nécessaire en pratique (les générateurs le font mieux), mais important à comprendre pour le TOSA.

class Compteur:
    """Itère de 1 à max (inclus)."""

    def __init__(self, max):
        self.max = max
        self.courant = 0

    def __iter__(self):
        return self        # l'objet est lui-même son itérateur

    def __next__(self):
        if self.courant >= self.max:
            raise StopIteration
        self.courant += 1
        return self.courant


c = Compteur(5)
for x in c:
    print(x, end=" ")
1 2 3 4 5 
⚠️ Un itérateur est à usage unique

Une fois épuisé, il reste épuisé. Pour re-parcourir, il faut recréer l’itérateur.

c = Compteur(3)
print(list(c))        # [1, 2, 3]
print(list(c))        # [] — épuisé !
[1, 2, 3]
[]

Les listes (itérables) peuvent être re-parcourues parce que iter() crée un nouvel itérateur à chaque fois.

42.3 Les générateurs avec yield

Le yield transforme une fonction en générateur. Au lieu de calculer et retourner toute la liste, elle produit les éléments un par un, à la demande.

Premier exemple

def compter_jusqu_a(n):
    """Générateur des entiers de 1 à n."""
    i = 1
    while i <= n:
        yield i        # produit i et SUSPEND la fonction
        i += 1

# Usage comme n'importe quel itérable
for x in compter_jusqu_a(5):
    print(x, end=" ")
1 2 3 4 5 
# On peut aussi le transformer en liste
print(list(compter_jusqu_a(5)))
[1, 2, 3, 4, 5]

Comprendre yield

yield n’est pas return. Quand Python rencontre yield :

  1. Il produit la valeur au code appelant.
  2. Il suspend la fonction à cet endroit.
  3. Quand on appelle next(), la fonction reprend exactement là où elle s’était arrêtée.
def demo():
    print("  [début]")
    yield 1
    print("  [après 1er yield]")
    yield 2
    print("  [après 2e yield]")
    yield 3
    print("  [fin]")


g = demo()                  # ne lance RIEN encore
print("→ appel de next :")
print(next(g))              # exécute jusqu'au 1er yield
print("→ appel de next :")
print(next(g))
print("→ appel de next :")
print(next(g))
→ appel de next :
  [début]
1
→ appel de next :
  [après 1er yield]
2
→ appel de next :
  [après 2e yield]
3

Voyez : la fonction reprend son exécution à chaque next, et les print intermédiaires s’exécutent au bon moment. C’est fondamentalement différent d’une fonction classique.

Gros avantage : économie mémoire

Une liste de 10 millions d’éléments occupe plusieurs centaines de Mo. Un générateur qui les produit à la demande : quelques centaines d’octets.

import sys

# Liste : tous les éléments en mémoire
liste_millions = [x ** 2 for x in range(10_000_000)]
print(f"Liste : {sys.getsizeof(liste_millions):,} octets")

# Générateur : quasi rien en mémoire
gen_millions = (x ** 2 for x in range(10_000_000))
print(f"Générateur : {sys.getsizeof(gen_millions):,} octets")
Liste : 89,095,160 octets
Générateur : 200 octets

Expressions génératrices (rappel)

On a vu les expressions génératrices au chapitre 1 de la Partie 3 :

# Parenthèses = générateur, pas liste
carres = (x ** 2 for x in range(10))
print(carres)
print(list(carres))
<generator object <genexpr> at 0x00000157462B4AD0>
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Elles sont équivalentes à une fonction générateur courte :

def carres_gen():
    for x in range(10):
        yield x ** 2

42.4 Cas d’usage typiques des générateurs

1. Lecture paresseuse d’un gros fichier

def lire_lignes_non_vides(chemin):
    """Générateur : lit ligne par ligne, saute les vides."""
    with open(chemin, "r", encoding="utf-8") as f:
        for ligne in f:
            ligne = ligne.strip()
            if ligne:
                yield ligne

# Usage : mémoire constante, même pour un fichier de 10 Go
for ligne in lire_lignes_non_vides("gros_fichier.txt"):
    traiter(ligne)

2. Suite infinie

Un générateur peut ne jamais s’arrêter. Pas de problème tant qu’on ne le transforme pas en liste.

def entiers():
    """Suite infinie 1, 2, 3, 4..."""
    n = 1
    while True:
        yield n
        n += 1


# Prendre les 10 premiers
from itertools import islice

print(list(islice(entiers(), 10)))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

3. Suite de Fibonacci

Exemple classique : générer les nombres de Fibonacci à la demande.

def fibonacci():
    """Générateur infini de Fibonacci."""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b


# 20 premiers
from itertools import islice
print(list(islice(fibonacci(), 20)))
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181]

4. Pipeline de transformations

On peut chaîner des générateurs pour traiter un flux par étapes, sans stocker les intermédiaires.

def nombres():
    for i in range(1, 11):
        yield i

def carres(source):
    for n in source:
        yield n ** 2

def pairs(source):
    for n in source:
        if n % 2 == 0:
            yield n


# Chaînage : nombres → carrés → pairs
resultat = pairs(carres(nombres()))
print(list(resultat))
[4, 16, 36, 64, 100]

Chaque étape produit un élément à la fois. Aucun stockage intermédiaire — idéal pour des flux énormes.

42.5 yield from — déléguer à un autre générateur

yield from (Python 3.3+) permet de déléguer l’itération à un autre itérable.

def compter(debut, fin):
    yield from range(debut, fin)


print(list(compter(1, 5)))
[1, 2, 3, 4]

Équivalent à :

def compter(debut, fin):
    for x in range(debut, fin):
        yield x

Utile pour composer des générateurs :

def chaine(*iterables):
    """Comme itertools.chain : enchaîne plusieurs itérables."""
    for it in iterables:
        yield from it


print(list(chaine([1, 2, 3], (4, 5), "AB")))
[1, 2, 3, 4, 5, 'A', 'B']

42.6 Le module itertools

itertools est une bibliothèque de briques réutilisables pour composer des itérateurs. Extrêmement testé au TOSA Expert.

count, cycle, repeat — infinis

from itertools import count, cycle, repeat, islice

# count : suite arithmétique infinie
print(list(islice(count(10, 2), 5)))           # 10, 12, 14, 16, 18

# cycle : répète un itérable indéfiniment
print(list(islice(cycle([1, 2, 3]), 8)))       # 1 2 3 1 2 3 1 2

# repeat : répète une valeur, optionnellement n fois
print(list(repeat("Hello", 3)))
[10, 12, 14, 16, 18]
[1, 2, 3, 1, 2, 3, 1, 2]
['Hello', 'Hello', 'Hello']

chain — enchaîner des itérables

from itertools import chain

print(list(chain([1, 2], (3, 4), "AB")))
[1, 2, 3, 4, 'A', 'B']

Équivalent à notre yield from mais builtin — et plus optimisé.

islice — slicer un itérable

Comme liste[start:stop:step] mais pour n’importe quel itérable (utile pour les infinis !).

from itertools import islice, count

# Éléments 5 à 10 d'un compteur infini
print(list(islice(count(), 5, 10)))

# Les 3 premiers d'un itérable
from itertools import cycle
print(list(islice(cycle("abc"), 7)))
[5, 6, 7, 8, 9]
['a', 'b', 'c', 'a', 'b', 'c', 'a']

takewhile, dropwhile

  • takewhile(pred, it) : prend les éléments tant que pred(x) est vrai, puis s’arrête.
  • dropwhile(pred, it) : ignore les éléments tant que vrai, puis garde tout le reste.
from itertools import takewhile, dropwhile

nombres = [1, 3, 5, 2, 8, 9, 1]

# Prendre tant que < 5
print(list(takewhile(lambda x: x < 5, nombres)))

# Ignorer tant que < 5
print(list(dropwhile(lambda x: x < 5, nombres)))
[1, 3]
[5, 2, 8, 9, 1]

Attention : ce n’est pas un filtre. Dès que la condition devient fausse (pour takewhile) ou vraie (pour dropwhile), le comportement bascule et ne revient pas.

zip_longest — zip qui ne s’arrête pas au plus court

Rappel : zip s’arrête dès qu’un itérable est épuisé. zip_longest continue en utilisant une valeur de remplissage.

from itertools import zip_longest

noms = ["Alice", "Bob", "Charlie"]
ages = [30, 25]

print(list(zip(noms, ages)))                       # s'arrête à 2
print(list(zip_longest(noms, ages, fillvalue=0)))  # complète à 0
[('Alice', 30), ('Bob', 25)]
[('Alice', 30), ('Bob', 25), ('Charlie', 0)]

product — produit cartésien

Toutes les combinaisons possibles — équivalent à plusieurs for imbriqués.

from itertools import product

couleurs = ["rouge", "vert"]
tailles = ["S", "M", "L"]

for c, t in product(couleurs, tailles):
    print(f"{c}-{t}", end=" ")

print()

# product(it, repeat=n) : n fois le même itérable
print(list(product("AB", repeat=2)))
rouge-S rouge-M rouge-L vert-S vert-M vert-L 
[('A', 'A'), ('A', 'B'), ('B', 'A'), ('B', 'B')]

permutations, combinations

from itertools import permutations, combinations

# Permutations : l'ordre compte
print(list(permutations("ABC", 2)))

# Combinations : l'ordre ne compte PAS
print(list(combinations("ABC", 2)))
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]
[('A', 'B'), ('A', 'C'), ('B', 'C')]

groupby — regrouper les éléments consécutifs

Regroupe les éléments consécutifs selon une clé.

from itertools import groupby

donnees = "aaabbbccddddeea"

for cle, groupe in groupby(donnees):
    print(f"{cle} x{len(list(groupe))}", end="  ")
a x3  b x3  c x2  d x4  e x2  a x1  
⚠️ groupby ne trie pas

groupby regroupe seulement les éléments adjacents. Si vous voulez grouper tout ce qui est identique, triez d’abord.

from itertools import groupby

personnes = [
    {"nom": "Alice", "ville": "Paris"},
    {"nom": "Bob",   "ville": "Lyon"},
    {"nom": "Eve",   "ville": "Paris"},
]

# ❌ Sans tri : Alice et Eve ne se regrouperont pas
for ville, groupe in groupby(personnes, key=lambda p: p["ville"]):
    print(f"{ville} : {[p['nom'] for p in groupe]}")

# ✅ Avec tri
print("---")
personnes_triees = sorted(personnes, key=lambda p: p["ville"])
for ville, groupe in groupby(personnes_triees, key=lambda p: p["ville"]):
    print(f"{ville} : {[p['nom'] for p in groupe]}")
Paris : ['Alice']
Lyon : ['Bob']
Paris : ['Eve']
---
Lyon : ['Bob']
Paris : ['Alice', 'Eve']

accumulate — sommes cumulées (ou autre)

from itertools import accumulate
import operator

# Somme cumulée
print(list(accumulate([1, 2, 3, 4])))

# Maximum cumulé
print(list(accumulate([3, 1, 4, 1, 5, 9, 2], max)))

# Produit cumulé
print(list(accumulate([1, 2, 3, 4], operator.mul)))
[1, 3, 6, 10]
[3, 3, 4, 4, 5, 9, 9]
[1, 2, 6, 24]

Récapitulatif

Fonction Utilité
count(start, step) Compteur infini
cycle(iterable) Répétition infinie
repeat(valeur, n) Répète une valeur
chain(*iterables) Enchaîne plusieurs itérables
islice(it, stop) Slice d’itérable
takewhile(pred, it) Prend tant que vrai
dropwhile(pred, it) Ignore tant que vrai
zip_longest(*its) zip sans s’arrêter au court
product(*its) Produit cartésien
permutations(it, r) Permutations
combinations(it, r) Combinaisons
groupby(it, key) Regroupement consécutif
accumulate(it, func) Accumulation (somme, max…)

🧩 Quiz 2.1 — Itérateurs et générateurs

Question 1

Quelle différence entre un itérable et un itérateur ?

  1. Aucune, synonymes
  2. L’itérateur produit les éléments un à un ; l’itérable peut en fournir un
  3. L’itérable est plus rapide
  4. L’itérateur est un tuple

b) — l’itérable (list, dict, str…) répond à iter() et peut être parcouru plusieurs fois. L’itérateur répond à next() et ne peut être parcouru qu’une fois.

Question 2

Que renvoie cette fonction ?

def f():
    yield 1
    yield 2
    yield 3

x = f()
type(x)
  1. <class 'list'>
  2. <class 'generator'>
  3. <class 'function'>
  4. <class 'tuple'>

b) <class 'generator'> — la présence de yield transforme la fonction en fabrique de générateurs. L’appel renvoie un objet générateur.

def f():
    yield 1

print(type(f()))
<class 'generator'>

Question 3

Que s’affiche-t-il ?

g = (x * 2 for x in range(3))
print(list(g))
print(list(g))
  1. [0, 2, 4] puis [0, 2, 4]
  2. [0, 2, 4] puis []
  3. [0, 2, 4] puis une erreur
  4. [] puis [0, 2, 4]

b) — un générateur est à usage unique. Le premier list(g) le consomme ; le second le trouve vide.

g = (x * 2 for x in range(3))
print(list(g))
print(list(g))
[0, 2, 4]
[]

Question 4

Que renvoie list(itertools.chain([1, 2], [3, 4])) ?

  1. [[1, 2], [3, 4]]
  2. [1, 2, 3, 4]
  3. [(1, 3), (2, 4)]
  4. Une erreur

b) [1, 2, 3, 4]chain enchaîne plusieurs itérables comme s’ils étaient un seul.

from itertools import chain
print(list(chain([1, 2], [3, 4])))
[1, 2, 3, 4]

Question 5

Quelle fonction prend les n premiers éléments d’un itérable infini ?

  1. list(it)[:n]
  2. it[:n]
  3. itertools.islice(it, n)
  4. itertools.takewhile(it, n)

c) islice(it, n)list(it)[:n] plante sur un itérable infini (on essaie de tout transformer en liste). it[:n] ne fonctionne que sur des séquences (pas les itérateurs). islice est la bonne réponse.

from itertools import count, islice
print(list(islice(count(1), 5)))
[1, 2, 3, 4, 5]

Question 6

Que renvoie list(itertools.combinations("ABC", 2)) ?

  1. [("A", "B"), ("A", "C"), ("B", "C")]
  2. [("A", "B"), ("A", "C"), ("B", "C"), ("B", "A"), ("C", "A"), ("C", "B")]
  3. [("A", "A"), ("B", "B"), ("C", "C")]
  4. ["AB", "AC", "BC"]

a)combinations ne garde que les combinaisons sans ordre (pas de doublons avec ordre inversé). permutations donnerait la réponse b.

from itertools import combinations, permutations
print(list(combinations("ABC", 2)))
print(list(permutations("ABC", 2)))
[('A', 'B'), ('A', 'C'), ('B', 'C')]
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]

Question 7

Quel est le principal avantage des générateurs sur les listes ?

  1. Ils sont plus rapides
  2. Ils consomment très peu de mémoire, même sur de grandes séquences
  3. Ils supportent plus d’opérations
  4. Ils sont plus sûrs

b) — un générateur produit un élément à la fois, sans jamais stocker la séquence complète en mémoire. Indispensable pour des flux massifs ou infinis.

Question 8

Que fait yield from [1, 2, 3] ?

  1. Rien, c’est une erreur
  2. Équivaut à trois yield successifs : yield 1; yield 2; yield 3
  3. Crée une liste
  4. Synonyme de return [1, 2, 3]

b)yield from iter équivaut à for x in iter: yield x. Permet de déléguer à un sous-itérable.

def f():
    yield from [1, 2, 3]
    yield from "AB"

print(list(f()))
[1, 2, 3, 'A', 'B']

✏️ Exercice 2.1 — Premier générateur

Écrivez un générateur pairs_jusqu_a(n) qui renvoie les entiers pairs de 2 à n inclus.

def pairs_jusqu_a(n):
    ...

print(list(pairs_jusqu_a(10)))      # [2, 4, 6, 8, 10]
print(list(pairs_jusqu_a(0)))       # []
def pairs_jusqu_a(n):
    i = 2
    while i <= n:
        yield i
        i += 2


print(list(pairs_jusqu_a(10)))
print(list(pairs_jusqu_a(0)))
print(list(pairs_jusqu_a(1)))
[2, 4, 6, 8, 10]
[]
[]

Version plus Pythonique

def pairs_jusqu_a(n):
    yield from range(2, n + 1, 2)


print(list(pairs_jusqu_a(10)))
[2, 4, 6, 8, 10]

✏️ Exercice 2.2 — Générateur de nombres premiers

Écrivez un générateur premiers() infini qui produit les nombres premiers. Utilisez islice pour obtenir les 20 premiers.

from itertools import islice

def est_premier(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True

def premiers():
    ...

print(list(islice(premiers(), 20)))
from itertools import islice


def est_premier(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True


def premiers():
    """Générateur infini des nombres premiers."""
    n = 2
    while True:
        if est_premier(n):
            yield n
        n += 1


# 20 premiers nombres premiers
print(list(islice(premiers(), 20)))

# Ou : les premiers < 100
from itertools import takewhile
print(list(takewhile(lambda p: p < 100, premiers())))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71]
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]

Points à retenir

  • premiers() est infini — il ne s’arrêtera jamais tout seul.
  • islice pour borner à n valeurs, takewhile pour borner par une condition.
  • Économie mémoire : même si on veut un milliard de premiers, on ne les stocke pas tous.

✏️ Exercice 2.3 — Pipeline de traitement

Créez un pipeline de générateurs qui :

  1. Lit des lignes depuis une « source » (liste simulée).
  2. Supprime les lignes vides (après .strip()).
  3. Convertit en majuscules.
  4. Ne garde que les lignes de plus de 5 caractères.

Chaque étape doit être un générateur séparé.

lignes_source = [
    "  alice  ",
    "",
    "bob",
    "  charlotte",
    "   ",
    "diana",
    "eve",
]

def source(lignes): ...
def nettoyer(it): ...
def majuscules(it): ...
def filtrer_longues(it, mini=5): ...

# Chaînage
pipeline = filtrer_longues(majuscules(nettoyer(source(lignes_source))))
print(list(pipeline))
def source(lignes):
    for ligne in lignes:
        yield ligne

def nettoyer(it):
    for ligne in it:
        strippee = ligne.strip()
        if strippee:
            yield strippee

def majuscules(it):
    for ligne in it:
        yield ligne.upper()

def filtrer_longues(it, mini=5):
    for ligne in it:
        if len(ligne) > mini:
            yield ligne


lignes_source = [
    "  alice  ",
    "",
    "bob",
    "  charlotte",
    "   ",
    "diana",
    "eve",
]

pipeline = filtrer_longues(majuscules(nettoyer(source(lignes_source))))
print(list(pipeline))
['CHARLOTTE']

Analyse

  • Chaque étape est un générateur indépendant, testable isolément.
  • Les étapes se composent comme des fonctions.
  • Aucun intermédiaire n’est stocké en mémoire — chaque élément circule une fois à travers tout le pipeline.
  • C’est le même principe qu’Unix pipe (cmd1 | cmd2 | cmd3).

✏️ Exercice 2.4 — Regrouper avec groupby

Utilisez itertools.groupby pour compter les occurrences consécutives dans une chaîne (RLE — Run-Length Encoding).

Exemple : "aaabbc"[('a', 3), ('b', 2), ('c', 1)].

from itertools import groupby

def compresser(chaine):
    ...

print(compresser("aaabbc"))
print(compresser("aabbbccccd"))
print(compresser(""))
from itertools import groupby

def compresser(chaine):
    return [(c, len(list(groupe))) for c, groupe in groupby(chaine)]


print(compresser("aaabbc"))
print(compresser("aabbbccccd"))
print(compresser(""))
print(compresser("abba"))       # attention : 2 groupes pour 'a'
[('a', 3), ('b', 2), ('c', 1)]
[('a', 2), ('b', 3), ('c', 4), ('d', 1)]
[]
[('a', 1), ('b', 2), ('a', 1)]

Décompression (bonus)

def decompresser(paires):
    return "".join(c * n for c, n in paires)


compresse = compresser("aaabbc")
print(compresse)
print(decompresser(compresse))
[('a', 3), ('b', 2), ('c', 1)]
aaabbc

Points à retenir

  • groupby ne trie pas. Il regroupe les consécutifs. D’où "abba" donne 3 groupes.
  • Le groupe renvoyé par groupby est un itérateur lui aussi. On doit le consommer (avec list ou len(list(...))) dans la même itération.
  • Le RLE est un algorithme de compression simple, utilisé en codage d’images (PCX, BMP, fax).

À retenir

Points clés du chapitre
  1. Itérable : répond à iter() (liste, tuple, str, dict…). Itérateur : répond à next().
  2. for x in it: est équivalent à while True: try: x = next(it); except StopIteration: break.
  3. Générateurs : fonction avec yield. Produit à la demande, économie mémoire massive.
  4. yield suspend la fonction ; next() reprend à l’endroit exact.
  5. Expressions génératrices (x for x in ...) : parenthèses au lieu de crochets.
  6. yield from délègue à un sous-itérable.
  7. itertools : count, cycle, chain, islice, takewhile, dropwhile, zip_longest, product, permutations, combinations, groupby, accumulate.
  8. groupby ne trie pas — triez avant si besoin.
  9. Idéal pour les gros fichiers, les flux, les suites infinies.

← Chapitre précédent : RegexChapitre suivant : Les décorateurs →