Chapitre 37 : Générateurs Avancés et Coroutines Simples
Objectif
Ce module a pour but d'explorer les fonctionnalités avancées des générateurs en Python, notamment la méthode send(), qui transforme un simple générateur en une coroutine. Cela jette les bases pour comprendre les concepts plus modernes de async/await.
1. Rappel sur les Générateurs
Un générateur est une fonction qui utilise le mot-clé yield pour produire une série de valeurs de manière paresseuse (une à la fois), sans stocker toute la séquence en mémoire.
def simple_generator():
yield 1
yield 2
yield 3
gen = simple_generator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
Chaque appel à next(gen) exécute le code jusqu'au prochain yield, retourne la valeur, et met l'état du générateur en pause.
2. Plus qu'un simple yield : (yield)
L'instruction yield peut aussi être utilisée comme une expression. Lorsqu'elle est utilisée de cette manière, elle peut recevoir des valeurs de l'extérieur.
La syntaxe est variable = (yield). Les parenthèses sont importantes.
def simple_coroutine():
print("Coroutine démarrée.")
valeur_recue = (yield) # Le générateur se met en pause ici, en attente d'une valeur.
print(f"Coroutine a reçu : {valeur_recue}")
(yield) # Se met en pause une dernière fois avant de terminer.
# --- Interaction ---
# 1. Créer la coroutine (générateur)
coro = simple_coroutine()
# 2. Démarrer la coroutine
# On doit appeler next() une première fois pour avancer jusqu'au premier 'yield'.
# C'est ce qu'on appelle "amorcer" (priming) la coroutine.
next(coro)
# Output: Coroutine démarrée.
# 3. Envoyer une valeur à la coroutine
# La méthode send() envoie une valeur qui devient le résultat de l'expression (yield).
# Le code de la coroutine reprend alors jusqu'au prochain 'yield'.
coro.send("Hello World")
# Output: Coroutine a reçu : Hello World
Flux d'exécution détaillé :
coro = simple_coroutine(): Crée l'objet générateur, mais n'exécute aucun code.next(coro): Exécute le code desimple_coroutinejusqu'à la lignevaleur_recue = (yield). Le générateur se met en pause avant l'assignation.coro.send("Hello World"): La valeur"Hello World"est envoyée au point de pause. Elle est assignée àvaleur_recue. Le code reprend, la ligneprint(f"Coroutine a reçu : {valeur_recue}")s'exécute, puis le générateur atteint le(yield)final et se remet en pause.
3. La Méthode .send()
La méthode send(valeur) fait deux choses :
- Elle envoie une valeur au générateur, qui devient la valeur de l'expression
(yield). - Elle continue l'exécution du générateur jusqu'au prochain
yieldet retourne la valeur produite par ceyield.
next(generateur) est équivalent à generateur.send(None).
Voyons un exemple plus interactif : un "running average".
def running_average():
"""Coroutine qui calcule une moyenne glissante."""
total = 0.0
count = 0
average = None
while True:
# Le générateur produit la moyenne actuelle et attend une nouvelle valeur.
nouvelle_valeur = (yield average)
total += nouvelle_valeur
count += 1
average = total / count
# --- Utilisation ---
avg_coro = running_average()
# 1. Amorcer la coroutine. Elle s'exécute jusqu'au premier (yield average).
# 'average' est None à ce stade, donc next() retourne None.
print(f"Amorçage : {next(avg_coro)}") # ou avg_coro.send(None)
# 2. Envoyer des valeurs et recevoir la moyenne en retour.
print(f"Envoi de 10, Moyenne : {avg_coro.send(10):.2f}") # Moyenne : 10.00
print(f"Envoi de 20, Moyenne : {avg_coro.send(20):.2f}") # Moyenne : 15.00
print(f"Envoi de 3, Moyenne : {avg_coro.send(3):.2f}") # Moyenne : 11.00
Cette structure permet de créer des "pipelines" de traitement de données où les générateurs agissent comme des unités de traitement qui reçoivent, transforment et envoient des données.
4. Fermer et Lancer des Exceptions dans les Générateurs
a. .close()
La méthode close() permet de terminer un générateur proprement. Quand elle est appelée, une exception GeneratorExit est levée à l'intérieur du générateur, au point où il était en pause.
def simple_coroutine():
print("Coroutine démarrée.")
try:
while True:
valeur = (yield)
print(f"Reçu : {valeur}")
except GeneratorExit:
print("Coroutine se ferme.")
coro = simple_coroutine()
next(coro)
coro.send(10)
coro.close()
# Output:
# Coroutine démarrée.
# Reçu : 10
# Coroutine se ferme.
C'est utile pour s'assurer que les ressources (comme des fichiers ou des connexions) sont bien libérées.
b. .throw()
La méthode throw(type_exception, valeur, traceback) permet de lever une exception à l'intérieur du générateur, au point où il est en pause.
def gestion_erreur_coro():
print("Démarrage...")
while True:
try:
valeur = (yield)
print(f"Reçu : {valeur}")
except ValueError:
print("--- Erreur de valeur gérée ! ---")
coro = gestion_erreur_coro()
next(coro)
coro.send(10)
coro.send(20)
coro.throw(ValueError) # Lève une ValueError dans le générateur
coro.send(30)
# Output:
# Démarrage...
# Reçu : 10
# Reçu : 20
# --- Erreur de valeur gérée ! ---
# Reçu : 30
Si l'exception n'est pas attrapée par le try...except à l'intérieur du générateur, elle se propage à l'appelant (celui qui a fait .throw()).
5. yield from : Déléguer à un Sous-Générateur
Introduit en Python 3.3, yield from simplifie grandement le code lorsqu'un générateur doit en appeler un autre. Il crée un canal transparent entre l'appelant extérieur et le sous-générateur.
Sans yield from :
def sous_generateur():
yield 1
yield 2
def generateur_principal_complique():
# On doit manuellement itérer et re-yield
for item in sous_generateur():
yield item
Avec yield from :
def sous_generateur():
val = (yield "Prêt à recevoir")
print(f"Sous-générateur a reçu: {val}")
return "Résultat du sous-générateur"
def generateur_principal():
# yield from gère tout : amorçage, envoi de valeurs, réception de la valeur de retour
resultat = yield from sous_generateur()
print(f"Le sous-générateur a retourné : {resultat}")
# --- Utilisation ---
g = generateur_principal()
# 1. Amorçage : next(g) avance jusqu'au yield dans le sous_generateur
message = next(g)
print(f"Message du sous-générateur : {message}") # Prêt à recevoir
# 2. Envoi de valeur : g.send() envoie directement au sous_generateur
try:
g.send("Valeur envoyée")
except StopIteration:
# Quand le sous-générateur se termine avec 'return', une StopIteration est levée.
# Le generateur_principal attrape cette exception et récupère la valeur de retour.
pass
# Output:
# Message du sous-générateur : Prêt à recevoir
# Sous-générateur a reçu: Valeur envoyée
# Le sous-générateur a retourné : Résultat du sous-générateur
yield from gère automatiquement :
- La transmission des
send()etnext()au sous-générateur. - La transmission des
yielddu sous-générateur à l'appelant. - La gestion des
throw()etclose(). - La récupération de la valeur de
returndu sous-générateur.
Conclusion
Les générateurs avancés, avec send(), throw(), close() et yield from, sont le fondement historique des coroutines en Python. Ils permettent de créer des pipelines de traitement de données complexes et des systèmes basés sur des événements. Bien que la syntaxe async/await (qui est basée sur ces concepts) soit aujourd'hui la manière standard d'écrire du code asynchrone, comprendre le fonctionnement interne des générateurs en tant que coroutines est essentiel pour une maîtrise complète de la programmation concurrente en Python.
Exercice : Coroutine pour Filtrer des Données
Objectif
Cet exercice a pour but de vous faire créer une coroutine (basée sur un générateur) qui reçoit des données, les filtre selon un critère, et les envoie à une autre coroutine "cible". Cela illustre comment chaîner des coroutines pour créer un pipeline de traitement.
Contexte
Les pipelines de coroutines sont un patron de conception puissant pour traiter des flux de données. Chaque coroutine agit comme une étape de traitement : elle reçoit des données de la source (ou de l'étape précédente), effectue une opération, et passe le résultat à l'étape suivante.
Dans cet exercice, vous allez construire un pipeline simple à deux étapes :
- Une coroutine qui filtre des nombres pour ne garder que les multiples d'un certain nombre.
- Une coroutine "puits" (
sink) qui reçoit les données filtrées et les affiche.
Énoncé
-
Créez un nouveau fichier Python nommé
coroutine_pipeline.py. -
Créez une coroutine "puits"
print_sink.- Cette coroutine doit être décorée avec un décorateur d'amorçage (voir ci-dessous) pour éviter d'avoir à appeler
next()manuellement. - Elle doit tourner dans une boucle infinie (
while True). - À chaque itération, elle attend de recevoir une valeur avec
(yield). - Elle affiche la valeur reçue avec un préfixe, par exemple :
f"Sink: a reçu {valeur}".
- Cette coroutine doit être décorée avec un décorateur d'amorçage (voir ci-dessous) pour éviter d'avoir à appeler
-
Créez un décorateur d'amorçage
@coroutine.- Ce décorateur prend une fonction générateur en argument.
- Il définit une fonction wrapper qui :
- Crée le générateur en appelant la fonction.
- Amorce le générateur en appelant
next()dessus. - Retourne le générateur amorcé.
- Ce décorateur simplifiera grandement l'utilisation de nos coroutines.
-
Créez la coroutine de filtrage
filter_multiple.- Décorez-la avec
@coroutine. - Elle doit accepter deux arguments :
multiple(l'entier pour le test de divisibilité) ettarget(la coroutine cible où envoyer les résultats). - Dans une boucle infinie, elle doit :
- Attendre de recevoir un nombre avec
nombre = (yield). - Vérifier si
nombreest un multiple demultiple. - Si c'est le cas, envoyer ce nombre à la coroutine
targeten utilisanttarget.send(nombre).
- Attendre de recevoir un nombre avec
- Décorez-la avec
-
Mettez en place le pipeline dans le bloc principal.
- Créez une instance de la coroutine puits
print_sink. - Créez une instance de la coroutine de filtrage
filter_multiple, en lui passant3comme multiple et leprint_sinkcomme cible. - Envoyez une série de nombres (de 1 à 10, par exemple) à la coroutine de filtrage en utilisant une boucle
foret la méthodesend().
- Créez une instance de la coroutine puits
Résultat Attendu
Seuls les nombres multiples de 3 doivent être affichés par la coroutine "puits".
Sink: a reçu 3
Sink: a reçu 6
Sink: a reçu 9
Solution
# coroutine_pipeline.py
from functools import wraps
def coroutine(func):
"""Décorateur pour amorcer automatiquement une coroutine."""
@wraps(func)
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
next(gen) # Amorce le générateur
return gen
return wrapper
@coroutine
def print_sink():
"""Coroutine qui agit comme un puits de données, affichant ce qu'elle reçoit."""
print("Sink: Prêt à recevoir des données.")
try:
while True:
valeur = (yield)
print(f"Sink: a reçu {valeur}")
except GeneratorExit:
print("Sink: Fermeture.")
@coroutine
def filter_multiple(multiple: int, target):
"""
Coroutine qui reçoit des nombres, filtre les multiples de 'multiple',
et les envoie à 'target'.
"""
print(f"Filter: Prêt à filtrer les multiples de {multiple}.")
try:
while True:
nombre = (yield)
if nombre % multiple == 0:
target.send(nombre)
except GeneratorExit:
target.close() # Propage la fermeture à la cible
# --- Mise en place du pipeline ---
if __name__ == "__main__":
# 1. Créer le puits (la fin du pipeline)
sink = print_sink()
# 2. Créer le filtre, en le connectant au puits
# Le filtre enverra ses résultats à 'sink'
filtrage = filter_multiple(3, sink)
# 3. Envoyer des données au début du pipeline (le filtre)
print("\nEnvoi des données au pipeline...")
for i in range(1, 11):
print(f"Source: envoi de {i}")
filtrage.send(i)
# 4. Fermer le pipeline
print("\nFermeture du pipeline.")
filtrage.close()