Salut ! Dans cet article, je t'explique comment les générateurs peuvent sauver ta vie quand tu travailles avec plein de données. T'as déjà vu ton programme planter parce qu'il essayait de charger un fichier de plusieurs giga-octets en mémoire ? Ou tu as voulu créer une séquence infinie sans faire exploser ta RAM ? Bienvenue au club ! Les générateurs sont exactement ce qu'il te faut. On va explorer ensemble comment ils fonctionnent vraiment, et tu vas voir comment ils vont transformer ta façon de gérer les données.
Le problème : la mémoire est limitée
Un exemple qui pose problème
Imagine que tu veux traiter les carrés des nombres de 0 à 1 million :
# Approche naïve avec une liste
def creer_carres(n):
resultat = []
for i in range(n):
resultat.append(i ** 2)
return resultat
# Ou avec une compréhension de liste
carres = [i ** 2 for i in range(1000000)]
# Problème : tout est en mémoire !
# Pour 1 million de nombres, ça fait environ 8 MB
# Pour 100 millions ? 800 MB...
Le problème : Python crée toute la liste en mémoire d'un coup, même si tu n'as besoin que d'un élément à la fois.
La solution : les générateurs
# Générateur : crée les valeurs à la demande
def generer_carres(n):
for i in range(n):
yield i ** 2
# Utilisation
carres = generer_carres(1000000)
# Aucune valeur n'est calculée tant qu'on ne les demande pas
for carre in carres:
if carre > 100:
break # On peut s'arrêter quand on veut
print(carre)
L'avantage : Le générateur ne calcule et ne stocke qu'une valeur à la fois. Tu peux traiter des milliards de valeurs sans problème de mémoire !
Qu'est-ce qu'un générateur ?
La définition
Un générateur est une fonction spéciale qui produit une séquence de valeurs au lieu de retourner une seule valeur. Au lieu d'utiliser return, elle utilise le mot-clé yield.
Différence clé : return vs yield
# Fonction normale avec return
def fonction_normale():
return 1
return 2 # Jamais atteint
return 3 # Jamais atteint
resultat = fonction_normale()
print(resultat) # 1
# Générateur avec yield
def generateur():
yield 1
yield 2
yield 3
gen = generateur()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
Ce qui se passe :
-
returntermine immédiatement la fonction et renvoie une valeur -
yieldsuspend la fonction, retourne une valeur, et reprend là où elle s'était arrêtée au prochain appel
Visualisons l'exécution
def compteur(n):
print("Début du générateur")
i = 0
while i < n:
print(f"Avant yield {i}")
yield i
print(f"Après yield {i}")
i += 1
print("Fin du générateur")
# Création du générateur
gen = compteur(3)
print("Générateur créé")
# Premier appel
print(f"Valeur : {next(gen)}")
print("---")
# Deuxième appel
print(f"Valeur : {next(gen)}")
print("---")
# Troisième appel
print(f"Valeur : {next(gen)}")
print("---")
# Quatrième appel (provoque StopIteration)
try:
print(f"Valeur : {next(gen)}")
except StopIteration:
print("Générateur épuisé!")
Résultat :
Générateur créé
Début du générateur
Avant yield 0
Valeur : 0
---
Après yield 0
Avant yield 1
Valeur : 1
---
Après yield 1
Avant yield 2
Valeur : 2
---
Après yield 2
Fin du générateur
Générateur épuisé!
Tu vois comment l'exécution se suspend et reprend à chaque yield ? C'est la magie des générateurs !
Créer des générateurs : trois méthodes
Tu as trois façons de créer des générateurs :
Méthode 1 : Fonction avec yield
La méthode la plus simple est d'utiliser le mot-clé yield dans la fonction.
def fibonacci(n):
"""Génère les n premiers nombres de Fibonacci."""
a, b = 0, 1
count = 0
while count < n:
yield a
a, b = b, a + b
count += 1
# Utilisation
for nombre in fibonacci(10):
print(nombre, end=" ")
# 0 1 1 2 3 5 8 13 21 34
Méthode 2 : Expression génératrice
Comme une compréhension de liste, mais avec des parenthèses :
# Compréhension de liste (crée toute la liste)
carres_liste = [x ** 2 for x in range(10)]
# Expression génératrice (calcule à la demande)
carres_gen = (x ** 2 for x in range(10))
print(type(carres_liste)) # <class 'list'>
print(type(carres_gen)) # <class 'generator'>
# Utilisation identique
for carre in carres_gen:
print(carre, end=" ")
# 0 1 4 9 16 25 36 49 64 81
Méthode 3 : Classe avec iter et next
Pour un contrôle total (rarement nécessaire), on utilise une classe avec deux méthodes spéciales __iter__ et __next__:
class Compteur:
def __init__(self, max):
self.max = max
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current >= self.max:
raise StopIteration
self.current += 1
return self.current
# Utilisation
for i in Compteur(5):
print(i, end=" ")
# 1 2 3 4 5
En pratique, tu vas utiliser presque toujours les méthodes 1 ou 2.
Avantages des générateurs
1. Économie de mémoire
import sys
# Liste : tout en mémoire
liste = [i for i in range(1000000)]
print(f"Taille de la liste : {sys.getsizeof(liste):,} bytes")
# Générateur : presque rien en mémoire
gen = (i for i in range(1000000))
print(f"Taille du générateur : {sys.getsizeof(gen):,} bytes")
Résultat :
Taille de la liste : 8,448,728 bytes (~8 MB)
Taille du générateur : 104 bytes
La différence est dingue ! Le générateur est plus de 80 000 fois plus petit.
2. Calcul paresseux (lazy evaluation)
Les valeurs ne sont calculées que quand tu en as besoin :
def nombres_avec_log(n):
"""Générateur qui log chaque calcul."""
for i in range(n):
print(f"Calcul de {i}")
yield i ** 2
# Création : aucun calcul
gen = nombres_avec_log(5)
print("Générateur créé, aucun calcul effectué")
# On prend juste les 2 premiers
print("\nRécupération des 2 premiers :")
premier = next(gen)
deuxieme = next(gen)
print(f"\nRésultats : {premier}, {deuxieme}")
# Les 3 autres ne sont jamais calculés !
Résultat :
Générateur créé, aucun calcul effectué
Récupération des 2 premiers :
Calcul de 0
Calcul de 1
Résultats : 0, 1
3. Séquences infinies
Tu peux créer des générateurs qui ne se terminent jamais :
def nombres_impairs():
"""Générateur infini de nombres impairs."""
n = 1
while True: # Boucle infinie !
yield n
n += 2
# Utilisation safe avec une limite
impairs = nombres_impairs()
for _ in range(10):
print(next(impairs), end=" ")
# 1 3 5 7 9 11 13 15 17 19
Impossible de faire ça avec une liste !
Application pratique 1 : Lire un gros fichier
Le problème avec l'approche classique
# ❌ Mauvaise approche : charge tout en mémoire
def traiter_fichier_mauvais(nom_fichier):
with open(nom_fichier, 'r') as f:
lignes = f.readlines() # Tout le fichier en RAM !
for ligne in lignes:
traiter_ligne(ligne)
Si le fichier fait 10 GB, ton programme va planter.
Solution avec un générateur
def lire_fichier_par_chunks(nom_fichier, taille_chunk=1024):
"""Lit un fichier par morceaux."""
with open(nom_fichier, 'r') as f:
while True:
chunk = f.read(taille_chunk)
if not chunk:
break
yield chunk
# Utilisation
for chunk in lire_fichier_par_chunks('gros_fichier.txt'):
traiter_chunk(chunk)
# Un seul chunk en mémoire à la fois !
Traiter les lignes une par une
def lire_lignes(nom_fichier):
"""Générateur qui lit un fichier ligne par ligne."""
with open(nom_fichier, 'r', encoding='utf-8') as f:
for ligne in f: # f est déjà un générateur !
yield ligne.strip()
# Filtrer les lignes vides
def lignes_non_vides(nom_fichier):
for ligne in lire_lignes(nom_fichier):
if ligne:
yield ligne
# Compter les mots dans un fichier énorme
def compter_mots(nom_fichier):
total = 0
for ligne in lignes_non_vides(nom_fichier):
total += len(ligne.split())
return total
# Fonctionne même avec des fichiers de plusieurs GB !
Application pratique 2 : Pipeline de traitement de données
Les générateurs sont parfaits pour créer des pipelines de transformation de données.
Exemple : Analyser des logs
def lire_logs(nom_fichier):
"""Lit un fichier de logs."""
with open(nom_fichier, 'r') as f:
for ligne in f:
yield ligne.strip()
def filtrer_erreurs(lignes):
"""Garde seulement les lignes d'erreur."""
for ligne in lignes:
if 'ERROR' in ligne:
yield ligne
def extraire_timestamp(lignes):
"""Extrait le timestamp de chaque ligne."""
for ligne in lignes:
# Format: "2024-01-15 10:30:45 ERROR ..."
parties = ligne.split()
if len(parties) >= 2:
yield f"{parties[0]} {parties[1]}"
def compter_par_heure(timestamps):
"""Compte les erreurs par heure."""
from collections import Counter
compteur = Counter()
for timestamp in timestamps:
heure = timestamp.split()[1].split(':')[0] # Extrait l'heure
compteur[heure] += 1
return compteur
# Pipeline complet
logs = lire_logs('application.log')
erreurs = filtrer_erreurs(logs)
timestamps = extraire_timestamp(erreurs)
resultats = compter_par_heure(timestamps)
print("Erreurs par heure :")
for heure, count in sorted(resultats.items()):
print(f"{heure}h: {count} erreurs")
L'avantage : Chaque étape est un générateur. On traite les données en flux, sans jamais charger tout le fichier en mémoire !
Application pratique 3 : Générer des données de test
import random
from datetime import datetime, timedelta
def generer_utilisateurs(n):
"""Génère n utilisateurs de test."""
prenoms = ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"]
noms = ["Martin", "Dupont", "Bernard", "Petit", "Robert"]
for i in range(n):
prenom = random.choice(prenoms)
nom = random.choice(noms)
email = f"{prenom.lower()}.{nom.lower()}{i}@mail.com"
age = random.randint(18, 70)
yield {
"id": i + 1,
"nom": f"{prenom} {nom}",
"email": email,
"age": age
}
def generer_transactions(utilisateurs, nb_transactions):
"""Génère des transactions pour les utilisateurs."""
date_debut = datetime.now() - timedelta(days=365)
for i in range(nb_transactions):
utilisateur = random.choice(list(utilisateurs))
montant = round(random.uniform(10, 1000), 2)
date = date_debut + timedelta(days=random.randint(0, 365))
yield {
"id": i + 1,
"utilisateur_id": utilisateur["id"],
"montant": montant,
"date": date.strftime("%Y-%m-%d")
}
# Utilisation
utilisateurs = list(generer_utilisateurs(100))
transactions = generer_transactions(utilisateurs, 1000)
# On peut maintenant traiter les transactions une par une
montant_total = sum(t["montant"] for t in transactions)
print(f"Montant total : {montant_total:.2f}€")
Chaîner des générateurs
Tu peux combiner plusieurs générateurs pour créer des transformations complexes.
Exemple : Traitement de nombres
def nombres(n):
"""Génère les nombres de 1 à n."""
for i in range(1, n + 1):
yield i
def filtrer_pairs(nombres):
"""Garde seulement les nombres pairs."""
for n in nombres:
if n % 2 == 0:
yield n
def multiplier(nombres, facteur):
"""Multiplie chaque nombre par un facteur."""
for n in nombres:
yield n * facteur
def limiter(nombres, max_elements):
"""Limite le nombre d'éléments."""
count = 0
for n in nombres:
if count >= max_elements:
break
yield n
count += 1
# Pipeline
nums = nombres(100)
pairs = filtrer_pairs(nums)
doubles = multiplier(pairs, 2)
premiers = limiter(doubles, 5)
print(list(premiers)) # [4, 8, 12, 16, 20]
Version plus élégante avec des expressions génératrices
# Tout en une ligne !
resultat = (
n * 2
for n in range(1, 101)
if n % 2 == 0
)
# Prendre les 5 premiers
print(list(limiter(resultat, 5))) # [4, 8, 12, 16, 20]
yield from : déléguer à un autre générateur
yield from permet de déléguer la production de valeurs à un autre générateur.
Sans yield from
def sous_generateur():
yield 1
yield 2
yield 3
def generateur_principal():
# Méthode verbeuse
for valeur in sous_generateur():
yield valeur
yield 4
yield 5
print(list(generateur_principal())) # [1, 2, 3, 4, 5]
Avec yield from
def generateur_principal():
yield from sous_generateur() # Plus simple !
yield 4
yield 5
print(list(generateur_principal())) # [1, 2, 3, 4, 5]
Exemple pratique : Aplatir une structure imbriquée
def aplatir(structure):
"""Aplatit une structure imbriquée de listes."""
for element in structure:
if isinstance(element, list):
yield from aplatir(element) # Récursif !
else:
yield element
# Test
nested = [1, [2, 3, [4, 5]], 6, [7, [8, 9]]]
print(list(aplatir(nested))) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
Envoyer des valeurs dans un générateur
Les générateurs peuvent aussi recevoir des valeurs avec la méthode send().
Exemple basique
def echo():
"""Générateur qui répète ce qu'on lui envoie."""
while True:
valeur = yield
if valeur is not None:
print(f"Reçu : {valeur}")
gen = echo()
next(gen) # Initialiser le générateur
gen.send("Hello") # Reçu : Hello
gen.send("World") # Reçu : World
gen.send(42) # Reçu : 42
Exemple pratique : Calculateur de moyenne mobile
def moyenne_mobile(fenetre=3):
"""Calcule une moyenne mobile."""
valeurs = []
while True:
nouvelle_valeur = yield sum(valeurs) / len(valeurs) if valeurs else 0
valeurs.append(nouvelle_valeur)
# Garder seulement les N dernières valeurs
if len(valeurs) > fenetre:
valeurs.pop(0)
# Utilisation
calc = moyenne_mobile(3)
next(calc) # Initialiser
print(calc.send(10)) # 10.0
print(calc.send(20)) # 15.0
print(calc.send(30)) # 20.0
print(calc.send(40)) # 30.0 (moyenne de 20, 30, 40)
print(calc.send(50)) # 40.0 (moyenne de 30, 40, 50)
Générateurs vs Listes : Quand utiliser quoi ?
Utilisez une liste quand :
# ✅ Tu dois accéder aux éléments plusieurs fois
resultats = [calcul_couteux(x) for x in range(10)]
print(resultats[0])
print(resultats[5])
for r in resultats: # Deuxième itération
print(r)
# ✅ Tu as besoin de la longueur
nombres = [i for i in range(100)]
print(len(nombres)) # Possible avec une liste
# ✅ Tu veux accéder par index
nombres[50] # Possible avec une liste
# ✅ Les données sont petites
petite_liste = [i ** 2 for i in range(100)] # OK
Utilisez un générateur quand :
# ✅ Les données sont volumineuses
gros_fichier = (ligne for ligne in open('huge.txt'))
# ✅ Tu n'itères qu'une seule fois
for ligne in lire_fichier('data.txt'):
traiter(ligne)
# ✅ Tu veux du calcul paresseux
resultats = (calcul_couteux(x) for x in range(1000000))
premier = next(resultats) # Calcule seulement le premier
# ✅ Tu crées des séquences infinies
def nombres_premiers():
# Génère tous les nombres premiers (infini)
...
# ✅ Pipeline de transformations
pipeline = (
transform3(x)
for x in transform2(y)
for y in transform1(z)
for z in source_data
)
Performances : Benchmark
Comparons les performances en pratique :
import time
import sys
def benchmark_liste(n):
"""Créer une liste."""
start = time.time()
liste = [i ** 2 for i in range(n)]
somme = sum(liste)
duree = time.time() - start
taille = sys.getsizeof(liste)
return duree, taille, somme
def benchmark_generateur(n):
"""Créer un générateur."""
start = time.time()
gen = (i ** 2 for i in range(n))
somme = sum(gen)
duree = time.time() - start
taille = sys.getsizeof(gen)
return duree, taille, somme
n = 1000000
duree_l, taille_l, somme_l = benchmark_liste(n)
duree_g, taille_g, somme_g = benchmark_generateur(n)
print(f"Liste :")
print(f" Temps : {duree_l:.4f}s")
print(f" Mémoire : {taille_l:,} bytes")
print(f" Somme : {somme_l:,}")
print(f"\nGénérateur :")
print(f" Temps : {duree_g:.4f}s")
print(f" Mémoire : {taille_g:,} bytes")
print(f" Somme : {somme_g:,}")
print(f"\nGain mémoire : {taille_l / taille_g:.1f}x")
Résultat typique :
Liste :
Temps : 0.0891s
Mémoire : 8,697,464 bytes
Somme : 333,332,833,333,500,000
Générateur :
Temps : 0.0623s
Mémoire : 104 bytes
Somme : 333,332,833,333,500,000
Gain mémoire : 83,629.5x
Le générateur est plus rapide ET utilise 83 000 fois moins de mémoire ! C'est fou.
Pièges courants à éviter
Piège 1 : Un générateur s'épuise
gen = (i for i in range(3))
# Première itération
for i in gen:
print(i) # 0, 1, 2
# Deuxième itération
for i in gen:
print(i) # Rien ! Le générateur est épuisé
# Solution : recréer le générateur
gen = (i for i in range(3))
Piège 2 : Pas de len() sur un générateur
gen = (i for i in range(100))
# ❌ Erreur
# print(len(gen)) # TypeError
# ✅ Solution : convertir en liste (si petit)
liste = list(gen)
print(len(liste))
Piège 3 : Pas d'accès par index
gen = (i ** 2 for i in range(10))
# ❌ Erreur
# print(gen[5]) # TypeError
# ✅ Solutions
# 1. Convertir en liste
liste = list(gen)
print(liste[5])
# 2. Utiliser itertools.islice
from itertools import islice
gen = (i ** 2 for i in range(10))
cinquieme = next(islice(gen, 5, 6))
print(cinquieme)
Outils utiles du module itertools
Le module itertools offre plein d'outils pratiques pour travailler avec les générateurs.
islice : Découper un générateur
from itertools import islice
def nombres():
i = 0
while True:
yield i
i += 1
# Prendre les éléments 10 à 14
gen = nombres()
subset = islice(gen, 10, 15)
print(list(subset)) # [10, 11, 12, 13, 14]
chain : Chaîner des générateurs
from itertools import chain
gen1 = (i for i in range(3))
gen2 = (i for i in range(10, 13))
combine = chain(gen1, gen2)
print(list(combine)) # [0, 1, 2, 10, 11, 12]
takewhile et dropwhile
from itertools import takewhile, dropwhile
nombres = (i for i in range(10))
# Prendre tant que < 5
petits = takewhile(lambda x: x < 5, nombres)
print(list(petits)) # [0, 1, 2, 3, 4]
# Ignorer tant que < 5, puis tout prendre
nombres = (i for i in range(10))
grands = dropwhile(lambda x: x < 5, nombres)
print(list(grands)) # [5, 6, 7, 8, 9]
cycle et repeat
from itertools import cycle, repeat
# Répéter une séquence indéfiniment
couleurs = cycle(['rouge', 'vert', 'bleu'])
for i, couleur in enumerate(couleurs):
if i >= 7:
break
print(couleur, end=" ")
# rouge vert bleu rouge vert bleu rouge
# Répéter une valeur
trois = repeat(3, times=5)
print(list(trois)) # [3, 3, 3, 3, 3]
Exercices pratiques
Exercice 1 : Générateur de nombres premiers
Crée un générateur qui produit tous les nombres premiers.
Exercice 2 : Générateur de permutations
Crée un générateur qui produit toutes les permutations d'une liste.
Exercice 3 : Parser CSV
Crée un générateur qui lit un fichier CSV et retourne des dictionnaires.
Exercice 4 : Pagination
Crée un générateur qui découpe une liste en pages de taille N.
Solutions
Cliquez pour voir les solutions
# Solution 1 : Nombres premiers
def nombres_premiers():
"""Génère tous les nombres premiers."""
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
n = 2
while True:
if est_premier(n):
yield n
n += 1
# Test
premiers = nombres_premiers()
print([next(premiers) for _ in range(10)])
# [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
# Solution 2 : Permutations
def permutations(liste):
"""Génère toutes les permutations."""
if len(liste) <= 1:
yield liste
else:
for i in range(len(liste)):
element = liste[i]
reste = liste[:i] + liste[i+1:]
for p in permutations(reste):
yield [element] + p
# Test
print(list(permutations([1, 2, 3])))
# [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
# Solution 3 : Parser CSV
def parser_csv(nom_fichier):
"""Parse un CSV et retourne des dictionnaires."""
with open(nom_fichier, 'r') as f:
# Lire la première ligne (headers)
headers = next(f).strip().split(',')
# Lire les lignes de données
for ligne in f:
valeurs = ligne.strip().split(',')
yield dict(zip(headers, valeurs))
# Solution 4 : Pagination
def paginer(liste, taille_page):
"""Découpe une liste en pages."""
for i in range(0, len(liste), taille_page):
yield liste[i:i + taille_page]
# Test
donnees = list(range(25))
for i, page in enumerate(paginer(donnees, 10)):
print(f"Page {i + 1}: {page}")
# Page 1: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Page 2: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
# Page 3: [20, 21, 22, 23, 24]
Conclusion
Les générateurs sont un outil absolument indispensable pour tout développeur Python qui bosse avec des données volumineuses ou qui veut vraiment optimiser l'utilisation de la mémoire. Ils permettent d'écrire du code élégant, performant et qui scale vraiment bien.
Les points clés à retenir :
-
yield suspend l'exécution et permet de reprendre plus tard
-
Les générateurs sont paresseux : ils calculent à la demande
-
Économie de mémoire massive : parfait pour les gros volumes
-
Ils sont parfaits pour les pipelines de transformation de données
-
Utilise les expressions génératrices pour la simplicité
-
itertools offre des outils vraiment puissants pour manipuler les générateurs
-
Un générateur s'épuise : tu ne peux l'itérer qu'une fois
Commence par remplacer tes listes par des générateurs dans les cas où tu n'as besoin d'itérer qu'une fois. Progressivement, tu vas développer l'instinct de quand les utiliser. Les générateurs vont vraiment changer ta façon de penser le traitement des données en Python !
Curieux d'approfondir d'autres concepts avancés en Python ? Consulte les articles sur Python sur codewithmpia!

Laisser un commentaire