36. Générateurs et itération paresseuse
Dans le chapitre 35, nous avons appris comment l’itération fonctionne en Python via les itérables et les itérateurs. Nous avons vu que les itérateurs renvoient des valeurs une par une lorsqu’elles sont demandées, ce qui permet à Python de traiter des séquences sans tout charger en mémoire d’un seul coup. Nous allons maintenant explorer les générateurs(generator), la manière la plus élégante et pratique qu’offre Python pour créer des itérateurs.
Les générateurs sont des fonctions qui peuvent suspendre et reprendre leur exécution, en produisant des valeurs une par une au moment où elles sont demandées, plutôt que de calculer toutes les valeurs à l’avance et de les stocker en mémoire. Cette approche—appelée évaluation paresseuse(lazy evaluation)—signifie que les valeurs ne sont générées que lorsque c’est nécessaire, ce qui en fait l’une des fonctionnalités les plus puissantes de Python pour écrire du code économe en mémoire.
36.1) Ce que sont les générateurs et pourquoi ils sont utiles
36.1.1) Le problème de la création de grandes listes
Commençons par comprendre le problème que les générateurs résolvent. Supposons que vous deviez traiter une séquence d’un million de nombres. Voici l’approche traditionnelle en utilisant une liste(list) :
# Création d’une liste d’un million de carrés
def get_squares_list(n):
"""Retourner une liste des carrés de 0 à n-1."""
squares = []
for i in range(n):
squares.append(i * i)
return squares
# Cela crée une liste contenant 1 000 000 de nombres en mémoire
numbers = get_squares_list(1_000_000)
print(f"First five squares: {numbers[:5]}") # Output: First five squares: [0, 1, 4, 9, 16]Cette approche a un problème important : elle crée et stocke les un million de nombres en mémoire en une seule fois, même si vous n’avez besoin de les traiter qu’un par un. Pour des jeux de données plus grands ou des calculs plus complexes, cela peut consommer d’énormes quantités de mémoire, voire faire planter votre programme.
36.1.2) Présenter les générateurs : calculer des valeurs à la demande
Un générateur(generator) est un type particulier de fonction(function) qui produit des valeurs une par une, uniquement lorsqu’elles sont demandées. Au lieu de construire et de renvoyer une liste complète, un générateur calcule chaque valeur au besoin et « retient » où il s’était arrêté entre deux appels.
Voici la même fonctionnalité implémentée sous forme de générateur :
# Création d’un générateur de carrés
def get_squares_generator(n):
"""Générer les carrés de 0 à n-1, un par un."""
for i in range(n):
yield i * i # yield met la fonction en pause et renvoie une valeur
# Cela crée un objet générateur, pas une liste
squares_gen = get_squares_generator(1_000_000)
print(squares_gen) # Output: <generator object get_squares_generator at 0x...>
# Obtenir des valeurs une à la fois
print(next(squares_gen)) # Output: 0
print(next(squares_gen)) # Output: 1
print(next(squares_gen)) # Output: 4Le générateur ne calcule pas les un million de carrés à l’avance. À la place, il calcule chaque carré uniquement lorsque vous appelez next() dessus. Entre deux appels, le générateur « met en pause » et mémorise son état (la valeur actuelle de i).
36.1.3) Efficacité mémoire : l’avantage clé
La différence de mémoire entre les listes et les générateurs devient spectaculaire avec de gros jeux de données. Comparons :
import sys
# Approche avec liste : stocke toutes les valeurs
def squares_list(n):
return [i * i for i in range(n)]
# Approche avec générateur : calcule les valeurs à la demande
def squares_generator(n):
for i in range(n):
yield i * i
# Comparer l’utilisation mémoire pour 100 000 nombres
list_result = squares_list(100_000)
gen_result = squares_generator(100_000)
print(f"List size in memory: {sys.getsizeof(list_result):,} bytes")
# Output: List size in memory: 800,984 bytes (actual size may vary)
print(f"Generator size in memory: {sys.getsizeof(gen_result)} bytes")
# Output: Generator size in memory: 200 bytes (actual size may vary)La liste consomme plus de 800 Ko de mémoire, tandis que le générateur n’utilise que 200 octets—peu importe le nombre de valeurs qu’il produira finalement. Le générateur ne stocke que l’état de la fonction (la valeur actuelle de i et l’endroit où reprendre), pas la séquence réelle de valeurs.
36.1.4) Quand les générateurs sont utiles
Les générateurs excellent dans plusieurs scénarios courants :
Traitement de gros fichiers :
def read_large_file(filename):
"""Generate lines from a file one at a time."""
with open(filename, 'r') as file:
for line in file:
yield line.strip()
# Traiter un énorme fichier de logs sans tout charger en mémoire
for line in read_large_file('huge_log.txt'):
if 'ERROR' in line:
print(line)Séquences infinies :
def fibonacci():
"""Generate Fibonacci numbers indefinitely."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Générer des nombres de Fibonacci pour toujours (ou jusqu’à ce que vous arrêtiez de demander)
fib = fibonacci()
print([next(fib) for _ in range(10)])
# Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]36.1.5) Les générateurs sont des itérateurs
Comme nous l’avons appris au chapitre 35, les générateurs sont en fait un type particulier d’itérateur(iterator). Ils implémentent automatiquement le protocole d’itérateur (__iter__() et __next__()), ce qui explique pourquoi ils fonctionnent de façon transparente avec les boucles for :
def countdown(n):
"""Générer un compte à rebours de n à 1."""
while n > 0:
yield n
n -= 1
# Les générateurs fonctionnent directement dans les boucles for
for num in countdown(5):
print(num)
# Output:
# 5
# 4
# 3
# 2
# 1Lorsque vous utilisez un générateur dans une boucle for, Python appelle automatiquement next() dessus de manière répétée jusqu’à ce que le générateur soit épuisé (lève StopIteration).
36.2) Créer des fonctions génératrices avec yield
36.2.1) L’instruction yield : mettre en pause et reprendre
L’instruction yield est ce qui fait d’une fonction une fonction génératrice. Lorsque Python rencontre yield, il fait quelque chose de particulier : au lieu de renvoyer une valeur et de terminer la fonction, il met en pause la fonction et renvoie la valeur. La fois suivante où vous appelez next() sur le générateur, l’exécution reprend juste après l’instruction yield.
Voici un exemple simple qui démontre ce comportement de pause et reprise :
def simple_generator():
"""Demonstrate how yield pauses execution."""
print("Starting generator")
yield 1
print("Resuming after first yield")
yield 2
print("Resuming after second yield")
yield 3
print("Generator finished")
gen = simple_generator()
print("Created generator")
# Output:
# Created generator
print(f"First value: {next(gen)}")
# Output:
# Starting generator
# First value: 1
print(f"Second value: {next(gen)}")
# Output:
# Resuming after first yield
# Second value: 2
print(f"Third value: {next(gen)}")
# Output:
# Resuming after second yield
# Third value: 3
try:
next(gen)
except StopIteration:
print("Generator exhausted - no more values")
# Output:
# Generator finished
# Generator exhausted - no more valuesRemarquez comment l’exécution de la fonction est entrelacée avec les appels à next(). Chaque yield met la fonction en pause, et chaque next() la reprend là où elle s’était arrêtée.
36.2.2) État du générateur : mémoriser les variables locales
Les générateurs mémorisent toutes leurs variables locales entre deux yield. Cela les rend utiles pour maintenir un état sur plusieurs appels :
def counter(start=0):
"""Generate sequential numbers starting from start."""
current = start
while True:
yield current
current += 1
# Le générateur se souvient de 'current' entre les yield
count = counter(10)
print(next(count)) # Output: 10
print(next(count)) # Output: 11
print(next(count)) # Output: 12
# Chaque générateur a son propre état indépendant
count1 = counter(0)
count2 = counter(100)
print(next(count1)) # Output: 0
print(next(count2)) # Output: 100
print(next(count1)) # Output: 1
print(next(count2)) # Output: 101La variable current est conservée à chaque fois que le générateur se met en pause sur un yield et reprend au prochain appel de next(). Cela permet au générateur de continuer à compter à partir de sa dernière valeur. Chaque instance de générateur maintient son propre état indépendant.
36.2.3) Yield dans des boucles : le modèle le plus courant
L’usage le plus courant des générateurs consiste à faire un yield de valeurs à l’intérieur d’une boucle(loop). Ce modèle génère une séquence de valeurs :
def even_numbers(start, end):
"""Generate even numbers in the given range."""
current = start if start % 2 == 0 else start + 1
while current <= end:
yield current
current += 2
# Utiliser le générateur
evens = even_numbers(1, 20)
print(list(evens))
# Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]Chaque itération de la boucle produit une valeur, puis continue vers l’itération suivante lorsque next() est appelé de nouveau.
36.2.4) Plusieurs instructions yield
Un générateur peut avoir plusieurs instructions yield à différents endroits de son code. L’exécution passe par elles dans l’ordre :
def process_data(data):
"""Generate processed data with status messages."""
yield "Starting processing..."
cleaned = [item.strip().lower() for item in data]
yield f"Cleaned {len(cleaned)} items"
unique = list(set(cleaned))
yield f"Found {len(unique)} unique items"
for item in sorted(unique):
yield item
# Traiter des données
data = [" Apple ", "Banana", "apple", "Cherry", "BANANA"]
processor = process_data(data)
for result in processor:
print(result)
# Output:
# Starting processing...
# Cleaned 5 items
# Found 3 unique items
# apple
# banana
# cherryCe modèle est utile pour des générateurs qui doivent effectuer un travail de préparation, produire des informations d’état, puis produire les données réelles.
36.3) Expressions génératrices vs compréhensions de liste
36.3.1) Présenter les expressions génératrices
Dans le chapitre 34, nous avons appris les compréhensions de liste(list comprehensions)—une manière concise de créer des listes. Les expressions génératrices(generator expressions) utilisent une syntaxe presque identique, mais créent des générateurs au lieu de listes.
Une expression génératrice est essentiellement une manière compacte d’écrire une fonction génératrice simple. Comparez ces deux approches équivalentes :
# Fonction génératrice
def squares_function(n):
"""Générer les carrés de 0 à n-1, un par un."""
for x in range(n):
yield x * x
# Expression génératrice - fait la même chose
squares_expression = (x * x for x in range(10))
# Les deux créent des objets générateurs
gen1 = squares_function(10)
gen2 = squares_expression
print(type(gen1)) # Output: <class 'generator'>
print(type(gen2)) # Output: <class 'generator'>
# Les deux produisent les mêmes valeurs
print(list(squares_function(10))) # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(squares_expression)) # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]La syntaxe est presque identique à celle des compréhensions de liste. Les différences sont : utiliser des parenthèses () au lieu de crochets [], et tandis que les compréhensions de liste créent des listes, les expressions génératrices créent des générateurs :
# Compréhension de liste - crée la liste entière en mémoire
squares_list = [x * x for x in range(10)]
print(squares_list)
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Expression génératrice - crée un objet générateur
squares_gen = (x * x for x in range(10))
print(squares_gen)
# Output: <generator object <genexpr> at 0x...>
# Convertir en liste pour voir les valeurs
print(list(squares_gen))
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]Les expressions génératrices offrent la même syntaxe concise que les compréhensions de liste, mais avec l’efficacité mémoire des générateurs.
36.3.2) Comparaison mémoire : quand cela compte
Pour les petites séquences, la différence de mémoire entre les compréhensions de liste et les expressions génératrices est négligeable. Mais pour les grandes séquences, elle devient significative :
import sys
# Petite séquence - différence minimale
small_list = [x for x in range(100)]
small_gen = (x for x in range(100))
print(f"Small list: {sys.getsizeof(small_list)} bytes")
# Output: Small list: 920 bytes (actual size may vary)
print(f"Small generator: {sys.getsizeof(small_gen)} bytes")
# Output: Small generator: 192 bytes (actual size may vary)
# Grande séquence - énorme différence
large_list = [x for x in range(1_000_000)]
large_gen = (x for x in range(1_000_000))
print(f"Large list: {sys.getsizeof(large_list):,} bytes")
# Output: Large list: 8,448,728 bytes (actual size may vary)
print(f"Large generator: {sys.getsizeof(large_gen)} bytes")
# Output: Large generator: 192 bytes (actual size may vary)La taille du générateur reste constante, quel que soit le nombre de valeurs qu’il produira—il ne stocke que l’expression et l’état courant. La liste, en revanche, doit stocker toutes les valeurs en mémoire, c’est pourquoi sa taille augmente proportionnellement au nombre d’éléments.
36.3.3) Expressions génératrices dans les appels de fonction
Les expressions génératrices sont particulièrement élégantes lorsqu’elles sont passées directement à des fonctions qui consomment des itérables. Vous pouvez omettre les parenthèses supplémentaires lorsqu’une expression génératrice est le seul argument :
# Calculer la somme des carrés sans créer de liste
total = sum(x * x for x in range(100)) # Note : pas de parenthèses supplémentaires nécessaires
print(total)
# Output: 328350
# Trouver le maximum de valeurs transformées
numbers = [1, 2, 3, 4, 5]
max_square = max(x * x for x in numbers)
print(max_square)
# Output: 25
# Vérifier si une valeur satisfait une condition
data = [10, 15, 20, 25, 30]
has_large = any(x > 100 for x in data)
print(has_large)
# Output: FalseCe modèle est à la fois économe en mémoire et lisible. Des fonctions comme sum(), max(), min(), any() et all() traitent le générateur une valeur à la fois, sans jamais créer de liste intermédiaire.
36.3.4) Filtrer avec des expressions génératrices
Les expressions génératrices prennent en charge la même logique conditionnelle que les compréhensions de liste :
# Filtrer les nombres pairs
numbers = range(20)
evens = (x for x in numbers if x % 2 == 0)
print(list(evens))
# Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
# Transformer et filtrer
words = ["hello", "world", "python", "programming"]
long_upper = (word.upper() for word in words if len(word) > 5)
print(list(long_upper))
# Output: ['PYTHON', 'PROGRAMMING']36.3.5) Quand les expressions génératrices ne suffisent pas
Les expressions génératrices sont concises et élégantes, mais elles ont des limites. Utilisez des fonctions génératrices lorsque vous avez besoin de :
Logique complexe :
# Trop complexe pour une expression génératrice
def process_log_lines(filename):
"""Traiter un fichier de logs avec une logique complexe."""
with open(filename, 'r') as file:
for line in file:
line = line.strip()
if not line or line.startswith('#'):
continue # Ignorer les lignes vides et les commentaires
parts = line.split('|')
if len(parts) >= 3:
timestamp, level, message = parts[0], parts[1], parts[2]
if level in ('ERROR', 'CRITICAL'):
yield {
'timestamp': timestamp,
'level': level,
'message': message
}Plusieurs yield ou de l’état :
# Une expression génératrice ne peut pas maintenir d’état entre les itérations
def running_total(numbers):
"""Générer la somme cumulée des nombres."""
total = 0
for num in numbers:
total += num
yield total
numbers = [1, 2, 3, 4, 5]
print(list(running_total(numbers)))
# Output: [1, 3, 6, 10, 15]Gestion des erreurs :
# Une expression génératrice ne peut pas gérer les exceptions
def safe_divide(numbers, divisor):
"""Générer les résultats de division en gérant les erreurs."""
for num in numbers:
try:
yield num / divisor
except ZeroDivisionError:
yield float('inf')36.4) Quand utiliser des générateurs plutôt que des listes
36.4.1) Grands jeux de données : le principal cas d’usage
La raison la plus convaincante d’utiliser des générateurs est lorsqu’on travaille avec de grandes quantités de données. Si vous traitez des millions d’enregistrements, les générateurs peuvent faire la différence entre un programme qui s’exécute sans problème et un programme qui plante.
Mauvaise approche - Charger tout le fichier en mémoire :
# NE FAITES PAS ÇA avec de gros fichiers
def count_errors_bad(filename):
"""Load entire file into memory - will crash with large files."""
with open(filename, 'r') as file:
lines = file.readlines() # Charge TOUT le fichier en mémoire
error_count = 0
for line in lines:
if 'ERROR' in line:
error_count += 1
return error_count
# Si le fichier fait 10 Go, cela essaie de charger 10 Go en mémoire !Bonne approche - Utiliser un générateur :
def read_log_lines(filename):
"""Generate lines from a log file one at a time."""
with open(filename, 'r') as file:
for line in file:
yield line.strip()
def count_errors_good(filename):
"""Count errors without loading entire file into memory."""
error_count = 0
for line in read_log_lines(filename):
if 'ERROR' in line:
error_count += 1
return error_count
# Cela fonctionne efficacement même avec des fichiers de logs de plusieurs gigaoctets
# car une seule ligne est conservée en mémoire à la fois
count = count_errors_good('huge_application.log')
print(f"Found {count} errors")L’approche avec générateur traite une ligne à la fois, donc l’utilisation mémoire reste constante quelle que soit la taille du fichier. Un fichier de 10 Go utilise la même quantité de mémoire qu’un fichier de 10 Ko.
36.4.2) Séquences infinies ou de longueur inconnue
Les générateurs sont parfaits pour les séquences dont vous ne connaissez pas la longueur à l’avance ou dont la séquence est conceptuellement infinie :
def user_input_stream():
"""Generate user inputs until they type 'quit'."""
while True:
user_input = input("Enter a number (or 'quit'): ")
if user_input.lower() == 'quit':
break
try:
yield int(user_input)
except ValueError:
print("Invalid number, try again")
# Traiter les saisies utilisateur au fil de leur arrivée
total = 0
count = 0
for number in user_input_stream():
total += number
count += 1
print(f"Running average: {total / count:.2f}")Vous ne pouvez pas créer une liste de longueur inconnue, mais un générateur gère cela naturellement.
36.4.3) Transformations en chaîne : construire des pipelines de données
Lorsque vous devez appliquer plusieurs transformations aux données, les générateurs vous permettent d’enchaîner les opérations sans créer de listes intermédiaires :
# Transformer des nombres en plusieurs étapes
def generate_numbers(n):
"""Generate numbers from 1 to n."""
for i in range(1, n + 1):
yield i
def square_numbers(numbers):
"""Generate squares of input numbers."""
for num in numbers:
yield num * num
def keep_even(numbers):
"""Generate only even numbers."""
for num in numbers:
if num % 2 == 0:
yield num
# Enchaîner des générateurs - aucune liste intermédiaire n’est créée
numbers = generate_numbers(10)
squared = square_numbers(numbers)
even_squares = keep_even(squared)
# Traiter les résultats
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]Chaque étape traite une valeur à la fois, en la transmettant à l’étape suivante. C’est économe en mémoire et cela vous permet de traiter des jeux de données plus grands que la RAM disponible.
Sans générateurs, vous auriez besoin de listes intermédiaires :
# Approche non génératrice - crée des listes intermédiaires
numbers = list(range(1, 11)) # [1, 2, 3, ..., 10]
squared = [n * n for n in numbers] # [1, 4, 9, ..., 100]
even_squares = [n for n in squared if n % 2 == 0] # [4, 16, 36, 64, 100]
# Avec des générateurs - pas de listes intermédiaires
numbers = (i for i in range(1, 11))
squared = (n * n for n in numbers)
even_squares = (n for n in squared if n % 2 == 0)
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]Pour un pipeline avec trois étapes traitant un million d’éléments, l’approche avec liste créerait trois listes d’un million d’éléments chacune. L’approche avec générateur ne garde qu’une seule valeur en mémoire à la fois.
36.4.4) Quand les listes sont meilleures que les générateurs
Malgré leurs avantages, les générateurs ne sont pas toujours le bon choix. Utilisez des listes quand vous avez besoin de :
Itérations multiples :
# Liste - on peut itérer plusieurs fois
numbers = [1, 2, 3, 4, 5]
print(sum(numbers)) # Output: 15
print(max(numbers)) # Output: 5 (works fine)
# Générateur - on ne peut itérer qu’une fois
numbers_gen = (x for x in range(1, 6))
print(sum(numbers_gen)) # Output: 15
print(max(numbers_gen)) # Output: ValueError: max() iterable argument is emptySi vous devez traiter les mêmes données plusieurs fois, utilisez une liste.
Accès aléatoire :
# Besoin d’accéder à des éléments par index - utiliser une liste
students = ['Alice', 'Bob', 'Charlie', 'Diana']
print(students[2]) # Output: Charlie
# Les générateurs ne prennent pas en charge l’indexation
students_gen = (name for name in students)
# students_gen[2] # ERROR: 'generator' object is not subscriptableInformation de longueur :
# Besoin de connaître la longueur - utiliser une liste
data = [1, 2, 3, 4, 5]
print(f"Processing {len(data)} items")
# Les générateurs n’ont pas de longueur
data_gen = (x for x in data)
# len(data_gen) # ERROR: object of type 'generator' has no len()Petits jeux de données :
# Pour les petits jeux de données, les listes conviennent et sont plus pratiques
small_data = [x * 2 for x in range(10)]
# Les économies de mémoire d’un générateur ne sont pas significatives ici
# et la liste est plus flexible36.4.5) Guide de décision pratique
Voici un guide pratique pour choisir entre générateurs et listes :
Utilisez des générateurs quand :
- Vous traitez de gros fichiers ou jeux de données
- Vous travaillez avec des flux de données ou des saisies utilisateur
- Vous construisez des pipelines de traitement de données
- L’efficacité mémoire est importante
- Vous n’avez besoin d’itérer qu’une seule fois
- La séquence est infinie ou très longue
Utilisez des listes quand :
- Le jeu de données est petit (< 10 000 éléments en général)
- Vous avez besoin d’itérer plusieurs fois
- Vous avez besoin d’un accès aléatoire par index
- Vous avez besoin de connaître la longueur
- Vous devez passer les données à du code qui attend une liste
36.4.6) Convertir entre générateurs et listes
Vous pouvez facilement convertir entre générateurs et listes quand c’est nécessaire :
# Générateur vers liste
numbers_gen = (x * 2 for x in range(5))
numbers_list = list(numbers_gen)
print(numbers_list)
# Output: [0, 2, 4, 6, 8]
# Liste vers générateur (en utilisant une expression génératrice)
numbers_list = [1, 2, 3, 4, 5]
numbers_gen = (x for x in numbers_list)Cette flexibilité signifie que vous pouvez commencer avec un générateur pour l’efficacité, et convertir en liste uniquement lorsque vous avez besoin de fonctionnalités spécifiques aux listes :
# Commencer avec un générateur pour l’efficacité mémoire
numbers = (x for x in range(1, 1001))
filtered = (x for x in numbers if x % 7 == 0)
# Convertir en liste lorsque vous avez besoin de plusieurs itérations
multiples_of_seven = list(filtered)
# Vous pouvez maintenant utiliser les fonctionnalités de liste
print(f"Count: {len(multiples_of_seven)}")
# Output: Count: 142
print(f"First: {multiples_of_seven[0]}")
# Output: First: 7
print(f"Last: {multiples_of_seven[-1]}")
# Output: Last: 994
# Peut itérer plusieurs fois
total = sum(multiples_of_seven)
average = total / len(multiples_of_seven)
print(f"Average: {average:.1f}")
# Output: Average: 500.5Les générateurs sont l’une des fonctionnalités les plus élégantes de Python pour écrire du code économe en mémoire. Ils vous permettent de traiter de gros jeux de données, de construire des pipelines de données et de travailler avec des séquences infinies—tout en gardant votre code propre et lisible. Au fil de votre expérience, vous développerez une intuition pour savoir quand les générateurs sont le bon outil pour le travail.