Generator et yield en Python: traiter gros fichiers sans RAM
1. Aperçu
Lorsque vous traitez des fichiers journaux de plusieurs Go ou que vous streamez des millions d’enregistrements depuis une base de données, une approche « lire tout dans une list puis traiter » rendra votre programme gourmand en RAM et risque de le faire planter. Cet article vous aide à comprendre comment fonctionnent generator et yield, à les comparer avec les listes ordinaires, et à les appliquer dans des problèmes réels comme la lecture de fichiers volumineux ou les pipelines de traitement de données. À la fin, vous saurez écrire des generators correctement et saurez quand ne pas les utiliser.
2. Problème avec l’approche traditionnelle
Supposons que vous ayez besoin de lire un fichier journal de 5 Go et de compter les lignes contenant « ERROR » :
def count_errors_bad(filepath: str) -> int:
with open(filepath, "r") as f:
lines = f.readlines() # charge TOUT le fichier en RAM
return sum(1 for line in lines if "ERROR" in line)
f.readlines() lit l’intégralité du contenu du fichier en tant que liste de chaînes en mémoire. Avec un fichier de 5 Go, Python allouera ~5 Go+ de RAM (plus la surcharge des objets) avant même de commencer à compter. Si la machine n’a que 4 Go de RAM, le programme lancera une MemoryError ou gèlera le système à cause du swap.
Le problème fondamental : vous n’avez besoin de traiter qu’une ligne à la fois, mais le code force la machine à garder toutes les lignes en mémoire simultanément.
3. Qu’est-ce qu’un generator ?
Un generator est un type d’itérable spécial : il produit les valeurs à la demande (lazy evaluation) au lieu de calculer et stocker tout d’avance. Au lieu de retourner une liste complète, un generator retourne les valeurs une par une et ne calcule la valeur suivante que lorsqu’on la demande.
def count_errors_good(filepath: str) -> int:
with open(filepath, "r") as f:
return sum(1 for line in f if "ERROR" in line)
Ici, f (l’objet fichier) est lui-même déjà un iterator — il lit une ligne à la fois quand la boucle le demande, sans charger le fichier entier en RAM. La mémoire utilisée n’est que d’une seule ligne à la fois, que le fichier fasse 5 Go ou 50 Go.
4. Le mot-clé yield : créer une fonction generator
Vous pouvez écrire votre propre generator en utilisant yield dans une fonction. Quand une fonction contient yield, Python la transforme automatiquement en fonction generator — l’appeler n’exécute pas le code immédiatement mais retourne un objet generator.
def read_large_file(filepath: str):
with open(filepath, "r") as f:
for line in f:
yield line.strip()
yield line.strip()suspend la fonction ici, retournant la valeurline.strip()vers l’extérieur.- Le prochain appel à
next(), la fonction reprend son exécution à partir du point d’arrêt, sans recommencer depuis le début. - L’état de la fonction (variables locales, position du curseur de fichier) est préservé entre les appels à
yield.
Essayez :
gen = read_large_file("access.log")
print(type(gen))
print(next(gen))
print(next(gen))
<class 'generator'>
127.0.0.1 - GET /index.html 200
127.0.0.1 - GET /style.css 200
Notez que la ligne gen = read_large_file(...) n’ouvre pas le fichier, ne lit rien du tout. Le code à l’intérieur s’exécute seulement quand vous appelez next(gen) pour la première fois.
5. Comparaison mémoire : generator vs list
Pour voir clairement la différence, utilisons le module sys pour mesurer la taille des objets :
import sys
def squares_list(n: int) -> list[int]:
return [i * i for i in range(n)]
def squares_gen(n: int):
for i in range(n):
yield i * i
big_list = squares_list(1_000_000)
big_gen = squares_gen(1_000_000)
print(f"Taille list: {sys.getsizeof(big_list):,} bytes")
print(f"Taille generator: {sys.getsizeof(big_gen):,} bytes")
Taille list: 8,448,728 bytes
Taille generator: 208 bytes
Une list contenant 1 million d’éléments occupe près de 8,5 Mo, tandis qu’un generator est juste un petit objet de 208 octets — car il ne stocke aucune valeur, il stocke seulement l’état pour calculer la prochaine valeur quand nécessaire.
Note : Un generator ne peut être parcouru qu’une seule fois. Après l’avoir parcouru complètement, appeler à nouveau
next()lèveStopIteration. Si vous devez le parcourir plusieurs fois, vous devez créer un nouveau generator en rappelant la fonction.
6. Generator expression : une syntaxe plus concise
Similaire à une list comprehension mais en utilisant des parenthèses au lieu de crochets, vous avez une generator expression — pas besoin de définir une fonction séparée :
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_squares_gen = (n * n for n in numbers if n % 2 == 0)
print(sum(even_squares_gen))
220
Le fait d’utiliser des parenthèses () au lieu de crochets [] amène Python à créer un generator au lieu d’une list immédiatement. Ceci est utile quand vous n’avez besoin des valeurs qu’une seule fois, par exemple en les passant directement à sum(), max() ou any().
7. Construire un pipeline de traitement de données avec des generators
La force des generators est la possibilité de chaîner plusieurs étapes de traitement sans créer de listes intermédiaires. Supposons que vous ayez besoin : de lire un fichier CSV de commandes → filtrer celles avec status = "PAID" → calculer la somme de amount.
import csv
def read_orders(filepath: str):
with open(filepath, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def filter_paid(orders):
for order in orders:
if order["status"] == "PAID":
yield order
def extract_amount(orders):
for order in orders:
yield float(order["amount"])
def total_paid_amount(filepath: str) -> float:
orders = read_orders(filepath)
paid_orders = filter_paid(orders)
amounts = extract_amount(paid_orders)
return sum(amounts)
Chaque fonction read_orders, filter_paid, extract_amount est un generator — elles ne traitent pas l’intégralité des données immédiatement, elles se « chaînent » simplement pour former un pipeline. Quand sum(amounts) appelle next(), les valeurs sont tirées (pull) à travers chaque étape : lire 1 ligne CSV → vérifier le status → convertir en float → accumuler. À tout moment, un seul enregistrement existe en mémoire, que le fichier CSV ait 10 lignes ou 10 millions.
7.1. Tester le pipeline avec pytest
import pytest
from pathlib import Path
@pytest.fixture
def orders_csv(tmp_path: Path) -> str:
content = (
"order_id,amount,status\n"
"1,100.0,PAID\n"
"2,50.0,PENDING\n"
"3,200.0,PAID\n"
)
file_path = tmp_path / "orders.csv"
file_path.write_text(content)
return str(file_path)
def test_total_paid_amount(orders_csv):
result = total_paid_amount(orders_csv)
assert result == pytest.approx(300.0)
tmp_path est une fixture intégrée de pytest qui crée un répertoire temporaire séparé pour chaque test. Ce test crée un fichier CSV fictif avec 3 commandes, seules 2 étant PAID, et vérifie que le total est exactement 300,0.
8. yield from : déléguer à un autre generator
Quand un generator doit appeler et imbriquer les résultats d’un autre generator, yield from permet un code plus concis que d’écrire une boucle manuelle.
def read_file_lines(filepath: str):
with open(filepath) as f:
for line in f:
yield line.strip()
def read_multiple_files(filepaths: list[str]):
for filepath in filepaths:
yield from read_file_lines(filepath)
yield from read_file_lines(filepath) équivaut à écrire for line in read_file_lines(filepath): yield line, mais c’est plus concis et gère correctement le cas où le generator enfant a une valeur return. La fonction read_multiple_files vous permet de parcourir plusieurs fichiers journaux comme s’ils formaient un seul flux continu, sans avoir besoin de fusionner des listes.
9. Envoyer des valeurs dans un generator avec send()
Un generator n’est pas seulement capable de produire des valeurs — vous pouvez aussi envoyer des valeurs à l’intérieur à l’aide de send(), le transformant en une coroutine simple.
def running_average():
total = 0.0
count = 0
average = None
while True:
value = yield average
total += value
count += 1
average = total / count
avg_gen = running_average()
next(avg_gen) # initialise le generator, exécute jusqu'au premier yield
print(avg_gen.send(10)) # envoie 10, average = 10/1
print(avg_gen.send(20)) # envoie 20, average = 30/2
print(avg_gen.send(30)) # envoie 30, average = 60/3
10.0
15.0
20.0
La ligne value = yield average fait deux choses : elle retourne average vers l’extérieur et attend de recevoir une valeur via send() pour l’assigner à value. Vous devez appeler next(avg_gen) une première fois pour amener le generator au premier point yield — si vous appelez send() directement au début quand le generator n’a pas encore commencé, Python lève une TypeError.
Note : L’utilisation de
send()rend le code plus difficile à lire qu’un generator ordinaire. Dans la pratique, les frameworks commeasyncioont abstrait ce mécanisme ; vous avez rarement besoin d’écrire vous-mêmesend()sauf si vous construisez votre propre bibliothèque.
10. Quand NE PAS utiliser un generator
Un generator n’est pas toujours mieux qu’une list :
- Accès aléatoire ou parcours multiples requis : un generator ne supporte pas
gen[5]ou de repartir du début. Si vous avez besoin delen(), de slicing ou de parcourir les données 2-3 fois, une list est plus appropriée. - Données petites, pas de souci RAM : avec quelques milliers d’éléments, la surcharge de gestion d’état d’un generator est parfois plus lente qu’une list comprehension car les lists sont optimisées au niveau C.
- Parallélisation ou distribution entre plusieurs workers : une list est facile à diviser en chunks à envoyer au multiprocessing ; un generator doit être partiellement « matérialisé » avant de pouvoir être divisé.
- Débogage plus difficile : imprimer une list montre immédiatement toutes les valeurs ; imprimer un generator ne montre que
<generator object ... at 0x...>, et pour voir les valeurs vous devez le parcourir (et une fois parcouru le generator est « épuisé », inutilisable de nouveau).
Si vous n’êtes pas sûr, une règle simple : utilisez une list quand les données tiennent en RAM et ont besoin d’être réutilisées plusieurs fois ; utilisez un generator quand les données sont volumineuses, ne doivent être traitées que séquentiellement une fois, ou pour construire un pipeline de streaming.
11. Conclusion
Les generators et yield résolvent exactement le bon problème : traiter des données volumineuses sans avoir besoin de les charger complètement en RAM, grâce au mécanisme de lazy evaluation — calculer les valeurs uniquement quand elles sont demandées. Vous avez vu comment écrire une fonction generator, une generator expression, chaîner plusieurs generators en pipeline avec yield from, et même comment envoyer des données en sens inverse dans un generator avec send(). Rappelez-vous du compromis : les generators économisent de la mémoire mais en contrepartie vous perdez la capacité à les parcourir à nouveau, à accéder aléatoirement, et parfois le code est plus difficile à lire. Choisissez le bon outil pour le bon problème, ne utilisez pas un generator partout juste parce que « c’est une économie de RAM ».