Si vous avez aimé les générateurs, vous avez du creuser un peu yield
et vous apercevoir qu’on pouvait créer des coroutines avec. Mais sans vraiment comprendre ce que ça faisait.
On va se faire une petit intro. C’est un sujet vraiment avancé, donc si vous avez autre chose de moins compliqué à comprendre en Python (n’importe quoi à part les métaclasses :)), ne vous prenez pas la tête sur cet article. Ecoutez juste la musique :
D’abord, rappel sur le fonctionnement des générateurs (qui sont un prérequis de l’article, donc si besoin, relisez le tuto dédié) :
def soleil(): print('Premier next()') print('Yield 1') yield 1 print('Deuxième next()') print('Yield 2') yield 2 print('Troisième next()') print('Yield 3') yield 3 # pas de quatrième next(), # donc on ne passe jamais ici print('Pas vu') # rappel, ceci ne déclenche pas le code # de soleil() puisqu'il y a yield dedans print("Creation du generateur") undeuxtrois = soleil() # On execute le code jusqu'au yield 1 res = next(undeuxtrois) print('res = %s' % res) # On execute le code jusqu'au yield 2 res = next(undeuxtrois) print('res = %s' % res) # On execute le code jusqu'au yield 3 res = next(undeuxtrois) print('res = %s' % res) print('Good bye') ## Premier next() ## Yield 1 ## res = 1 ## Deuxième next() ## Yield 2 ## res = 2 ## Troisième next() ## Yield 3 ## res = 3 ## Good bye |
Chaque fois qu’on appelle next()
sur le générateur, il va exécuter le code jusqu’au prochain yield
, et retourner la valeur de celui-ci, puis mettre le générateur en pause.
On peut assigner le résultat d’un yield
, mais si on fait des next()
, on obtient toujours None
:
def lune(): print('Premier next()') print('Yield 1') x = (yield 1) print('Deuxième next()') print('Avant le yield 2, x = %s' % x) print('Yield 2') x = (yield 2) print('Troisième next()') print('Avant le yield 3, x = %s' % x) print('Yield 3') x = (yield 3) print('Pas vu') print("Creation du generateur") generateur = lune() res = next(generateur) print('res = %s' % res) res = next(generateur) print('res = %s' % res) res = next(generateur) print('res = %s' % res) print('Good bye') ## Creation du generateur ## Premier next() ## Yield 1 ## res = 1 ## Deuxième next() ## Avant le yield 2, x = None ## Yield 2 ## res = 2 ## Troisième next() ## Avant le yield 3, x = None ## Yield 3 ## res = 3 ## Good bye |
La raison est que cette valeur doit venir de l’extérieur. Pour la fournir, il faut utiliser la méthode send()
et non la fonction next()
.
Mais elle ne fonctionne pas du tout pareil. En fait, si on l’appelle cash pistache, ça plante :
print("Creation du generateur") generateur = lune() res = generateur.send("A") print('res = %s' % res) ## Creation du generateur ## Traceback (most recent call last): ## File "test.py", line 24, in ## res = generateur.send("A") ## TypeError: can't send non-None value to a just-started generator |
C’est parce que, contrairement à next()
qui va jusqu’au prochain yield
, send()
PART du dernier yield
atteint pour aller au suivant.
Il faut donc d’abord arriver à un premier yield
avant de faire un send()
. On peut le faire en utilisant au moins un next()
.
Voici donc notre nouveau code :
def lune(): print('On fait au moins un next()') print('Yield 1') x = (yield 1) print('Premier send(), x = %s' % x) print('Yield 2') x = (yield 2) print('Deuxième send(), x = %s' % x) print('Yield 3') x = (yield 3) # Comme on fait un next() et 3 send() # on arrive là print('Troisième send(), x = %s' % x) print('YOLOOOOO') print("Creation du generateur") generateur = lune() next(generateur) # Ou generateur.send(None) res = generateur.send("A") print('res = %s' % res) res = generateur.send("B") print('res = %s' % res) res = generateur.send("C") print('res = %s' % res) print('Good bye') ## Creation du generateur ## On fait au moins un next() ## Yield 1 ## Premier send(), x = A ## Yield 2 ## res = 2 ## Deuxième send(), x = B ## Yield 3 ## res = 3 ## Troisième send(), x = C ## YOLOOOOO ## Traceback (most recent call last): ## File "test.py", line 33, in ## res = generateur.send("C") ## StopIteration |
send()
agit donc comme next()
. Il va aller jusqu’au prochain yield
et lui faire retourner sa valeur. Mais il y a des différences :
- Elle doit partir d’un précédent
yield
. - Donc il faut au moins avoir atteint un
yield
vianext()
. - Ce précédent
yield
peut retourner une valeur : celle passée viasend(val).
La valeur peut être n’importe quel objet : string, int, classe, list, etc.
Bref, send()
permet de créer un générateur donc le comportement n’est pas figé dans le marbre.
Par exemple :
def creer_fontaine(): contenu = "soda" while True: x = yield contenu if x: contenu = x fontaine = creer_fontaine() for x in range(5): print(next(fontaine)) # on change le contenu de la fontaine fontaine.send("lait") for x in range(5): print(next(fontaine)) soda soda soda soda soda lait lait lait lait lait |
On peut même s’en servir pour faire des trucs chelou comme injecter une dépendance à la volée ou contrôler le flux de son générateur :
def fuckitjaiplusdenomcool(start, inc=lambda x: x + 1): x = start # on controle le flux du générateur en changeant # la valeur de x qui peut tout stopper while x: sent = yield x if sent: inc = sent # la valeur de x dépend de ce bout de code # qui est injectable x = inc(x) generateur = fuckitjaiplusdenomcool(1) for x in generateur: print(x) if x > 10: # si on dépasse 10, on décrémente generateur.send(lambda x: x - 1) ## 1 ## 2 ## 3 ## 4 ## 5 ## 6 ## 7 ## 8 ## 9 ## 10 ## 11 ## 9 ## 8 ## 7 ## 6 ## 5 ## 4 ## 3 ## 2 ## 1 |
Mais bon, pas la peine de rentrer dans des cas si compliqués.
Néanmoins, un cas d’usage de send()
est de créer une coroutine. Une coroutine est simplement une tâche.
C’est un bout de code qui fait une tache, avec un bout d’initialisation, et un bout de finalisation, et un bout d’exécution.
Par exemple, j’ai un filtre qui prend un fichier rempli d’adresses IP. Il va recevoir du texte, et si le texte contient une adresse IP, il le signale, et remplit un compteur sur le disque.
Si on devait coder ça en objet on dirait :
import re class Filtre: # initialisation def __init__(self, ipfile, counterfile): with open(ipfile, 'r') as f: self.banned_ips = set(f) with open(counterfile) as f: self.count = int(f.read()) self.counterfile = open(counterfile, 'w') def check(self, line): # récupère les ip et check celles qui sont # à filtrer ips = re.findall( r'[0-9]+(?:\.[0-9]+){3}', line) bad_ips = [ip for ip in ips if ip in self.banned_ips] # si il y a des ip à filtrer, on incrémente le compteur if bad_ips: self.count += len(bad_ips) self.counterfile.seek(0) self.counterfile.write(str(self.count)) # on retourn les valeurs trouvées return bad_ips def close(self): self.counterfile.close() |
On l’utiliserait comme ça :
f = Filtre("/chemin/vers/liste", "/chemin/vers/counteur") for line in text: print(f.check(line)) f.close() |
Notez que pour une tâche, l’API est toujours la même : initialiser, exécuter la tâche autant de fois que nécessaire, puis finaliser.
Les coroutines sont un mot qu’on met sur ce principe (initialiser, exec, finaliser), mais avec une API sous forme de générateur. Le même code en coroutine :
def filtre(ipfile, counterfile): # Initialisation with open(ipfile, 'r') as f: banned_ips = set(f) with open(counterfile) as f: count = int(f.read()) counterfile = open(counterfile, 'w') # Exécution bad_ips = [] while True: try: # entree et sortie de notre send(), qui équivaut # aux params de "check()" line = yield bad_ips # GeneratorExit est levé is on fait generator.close() # On ne peut pas ignorer cette erreur, mais # on peut mettre du code de finalisation ici. # Bon en vrai faudrait faire un finally quelque part # mais c'est pour l'exemple bande de peer reviewers except GeneratorExit: counterfile.close() ips = re.findall( r'[0-9]+(?:\.[0-9]+){3}', line) bad_ips = [ip for ip in ips if ip in banned_ips] # si il y a des ip à filtrer, on incrémente le compteur if bad_ips: count += len(bad_ips) counterfile.seek(0) counterfile.write(str(count)) |
On l’utiliserait comme ça :
f = filtre("/chemin/vers/liste", "/chemin/vers/counteur") next(f) for line in text: print(f.send(line)) # ceci raise GeneratorExit f.close() |
Généralement on veut pas se faire chier à appeler next()
à chaque fois, donc toutes les libs à base de coroutine ont ce genre de décorateur :
def coroutine(func): def wrapper(*arg, **kwargs): generator = func(*arg, **kwargs) next(generator) return generator return wrapper |
Afin de pouvoir faire ça :
@coroutine def filtre(ipfile, counterfile): ... |
Ca a un double usage : ça appelle next()
automatiquement, et ça signale que la fonction est destinée à être utilisée comme coroutine.
Mais voilà, c’est tout, une coroutine c’est juste ça : utiliser un générateur pour faire une tâche qui consiste à s’initialiser, faire un traitement plusieurs fois, et optionellement, se finaliser. On utilisera une coroutine pour ne pas reinventer la roue car c’est un problème bien défini, qui a une solution. D’autant qu’une coroutine bouffe moins de ressources qu’une classe.
Les usages avancés des coroutines impliquent de chaîner plusieurs coroutines, comme des tuyaux.
Souvenez-vous, en Python il est courant de chaîner des générateurs :
def mettre_au_carre(iterable): for x in iterable: yield x * x def filtrer_les_pairs(iterable): for x in iterable: if x % 2 == 0: yield x def strigifier(iterable): for x in iterable: yield str(x) # on pipe les données d'un générateur à l'autre nombres = range(10) carres = mettre_au_carre(nombres) carres_pairs = filtrer_les_pairs(carres) fete_du_string = strigifier(carres_pairs) for x in fete_du_string: print(repr(x)) ## '0' ## '4' ## '16' ## '36' ## '64' |
On peut faire pareil avec les coroutines. Cependant, la logique est inversée : au lieu de lire les données, on les envoie :
@coroutine def mettre_au_carre(ouput): while True: x = (yield) ouput.send(x * x) @coroutine def filtrer_les_paires(ouput): while True: x = (yield) if x % 2 == 0: ouput.send(x) @coroutine def strigifier(ouput): while True: x = (yield) ouput.send(str(x)) @coroutine def afficher(): while True: x = (yield) print(x) nombres = range(10) # chaque coroutine est la sortie d'une autre afficheur = afficher() fete_du_string = strigifier(afficheur) paires = filtrer_les_paires(fete_du_string) carre = mettre_au_carre(paires) # on envoit les données vers la première coroutine # et elle fait suivre aux autres for x in nombres: carre.send(x) ## '0' ## '4' ## '16' ## '36' ## '64' |
Vous allez me dire : “ça fait la même chose, et c’est plus compliqué, quel interêt ?”.
En fait, ça ne fait pas exactement la même chose.
Dans le cas des générateurs ordinaires, on déclenche le traitement par la fin. On fait une boucle qui demande quelle est la prochaine donnée, et si il y en a une, on l’affiche. C’est pratique si on sait qu’on a des données sous la main car on demande (next()
est appelée par la boucle for
) la donnée suivante à chaque fois : c’est du PULL.
Mais que se passe-t-il si on n’a pas encore les données ? Si on traite des données qui arrivent par évenement ?
Par exemple, si on écrit un serveur HTTP qui doit réagir aux requêtes ?
Dans ce cas, on ne peut envoyer (send()
) la donnée suivante dans notre pipeline de générateurs uniquement quand elle arrive, et les coroutines font exactement cela : c’est du PUSH.
En résumé :
yield
permet de faire des générateurs- On peut demander la prochaine valeur du générateur avec
next()
. Dans ce cas, le code s’exécute jusqu’au prochainyield
. - On peut envoyer une valeur au générateur avec
send()
. Dans ce cas, on DOIT partir d’unyield
existant duquel on récupère la valeur envoyée via une assignation. Donc il faut au moins unnext()
avant d’utiliser unsend()
et un signe égal sur leyield
. send()
va aussi aller au prochainyield
et retourner sa valeur.- Une coroutine n’est qu’une formalisation de la manière d’éffectuer une tâche avec un init, une exécution et une finalisation optionelle en utilisant un générateur. C’est une solution générique à un problème courant, mais plus léger qu’une classe.
- Généralement on décore les générateurs coroutines avec un décorateur
@coroutine
pour s’éviter d’appelernext()
à la main et notifier l’usage qu’il est fait de ce générateur. - On peut chaîner des coroutines comme on chaîne des générateurs, mais au lieu de lire les données une à une (PULL), on les envoie une par une (PUSH). Cela est pratique quand on ne sait pas à l’avance quand une nouvelle donnée va arriver.
Si vous êtes arrivé jusqu’ici, vous méritez un cookie.
Ca tombe bien, ce blog utilise des cookies, et la loi m’oblige à vous le notifier.
manière d’éffectuer => manière d’effectuer
tache => tâche (à plusieurs endroits)
La regex pour les adresses ip n’est pas tout à fait correcte (mais vu la longueur qu’elle devrait faire je suppose que c’est fait exprès)
Article très intéressant, il faudrait que je puisse mettre ça en pratique un jour. Le plus difficile en fait, c’est d’avoir les automatismes de penser à ce genre de chose en lieu et place d’une solution plus classique mais finalement moins pythonique.
Pareil que Marc.
Pareil que cendrier que marc
def filtrer_les_paires(iterable):
-> filtrer les PAIRS (sans “e”, je comprenais pas le sens du truc)
Merci pour l’article, génial pour se réveiller un lundi matin, mais c’est vrai que ça doit pas être évident à caser dans un projet (alors que REST ;) LE lourd).
D’ailleurs je me demande dans quelle mesure ça peut être utile pour le genre de site que vous gérez.
Le seul truc qui me vient en tête serait un encodage “à la volée” des vidéo uploadés
En fait c’est utile pour tous les trucs qui ont besoin de réagir à un événement et déclencher une chaîne de traitement :
– chat
– scrapping
– server
– UI
Mais c’est quelque chose qu’on fait naturellement autrement : on va faire une liste de tâches, itérer, prendre le résultat de la première, le passer à la deuxième, et recommencer. Les coroutines sont juste une formalisation de ce workflow en un outil léger, rien de plus.
Après, je n’ai pas montré des agencements complexes de coroutines. Là on ne voit que les chaînes, mais on peut faire beaucoup plus : les graphes de task, avec des traitements conditionels, etc.
Je déterre un peu, mais il me semble que tu as abusé du M-w C-y (un mec aussi bien que toi utilise emacs non? :D) sur la version “coroutine” du filtre : il y a des
self.*
à la fin qui n’ont rien à faire là.Ce qui m’inquiète c’est que tu sois le premier à l’avoir vu :( Combien de personnes ont lu l’article sans tiquer… Bref, merci, c’est corrigé.
Ben, je dois reconnaître que j’avais survolé il y a quelques temps l’articles (quand il est sorti je pense) et que je m’étais dit “oue ça a l’air cool, mais ça me sert à rien”, du coup, j’ai pas cherché à tout lire et comprendre. Et pi là j’en ai eu besoin, du coup j’ai potassé le truc ;)