Cours
Les fonctions Python n’ont pas toujours une instruction return. Les fonctions génératrices sont des fonctions qui utilisent le mot-clé yield au lieu de return.
Ces fonctions produisent des itérateurs générateurs, c’est-à-dire des objets qui représentent un flux de données. Les éléments représentés par un itérateur ne sont créés et fournis que lorsqu’ils sont requis. On parle souvent d’« évaluation paresseuse » (lazy evaluation).
Pour les grands jeux de données, les générateurs offrent une alternative économe en mémoire par rapport au stockage dans des listes, tuples et autres structures de données qui nécessitent de l’espace mémoire pour chacun de leurs éléments. Les fonctions génératrices peuvent aussi créer des itérateurs infinis, ce qui est impossible avec des structures évaluées de manière immédiate comme les listes et les tuples.
Avant de commencer, récapitulons les différences entre fonctions et générateurs :
|
Caractéristique |
Fonction |
Générateur |
|
Production de valeurs |
Renvoie toutes les valeurs d’un coup |
Fournit les valeurs une par une, à la demande |
|
Exécution |
S’exécute complètement avant de renvoyer |
Se met en pause après un yield, reprend quand la valeur suivante est demandée |
|
Mot-clé |
return |
yield |
|
Utilisation mémoire |
Potentiellement élevée, stocke toute la séquence en mémoire |
Faible, ne conserve que la valeur courante et l’état pour la suivante |
|
Itération |
Plusieurs itérations possibles, mais nécessite de stocker toute la séquence |
Conçu pour une itération en un seul passage, plus efficace pour les séquences longues ou infinies |
Créer des fonctions génératrices avec yield en Python
En Python, le terme générateur peut désigner un itérateur générateur ou une fonction génératrice. Ce sont des objets différents mais liés. Dans ce tutoriel, nous utiliserons souvent les termes complets pour éviter toute confusion.
Commençons par les fonctions génératrices. Une fonction génératrice ressemble à une fonction classique, mais contient le mot-clé yield au lieu de return.
Lorsqu’un programme Python appelle une fonction génératrice, il crée un itérateur générateur. Les itérateurs produisent une valeur à la demande et mettent leur exécution en pause jusqu’à ce qu’une autre valeur soit requise. Voyons un exemple pour expliquer ce concept et illustrer la différence entre fonctions classiques et fonctions génératrices.
Utiliser une fonction classique
Commençons par définir une fonction classique, qui contient une instruction return. Cette fonction accepte une suite de mots et une lettre, et renvoie une liste contenant le nombre d’occurrences de la lettre dans chaque mot :
def find_letter_occurrences(words, letter):
output = []
for word in words:
output.append(word.count(letter))
return output
print(
find_letter_occurrences(["apple", "banana", "cherry"], "a")
)
[1, 3, 0]
La fonction renvoie une liste contenant 1, 3 et 0 : il y a un « a » dans apple, trois « a » dans banana et aucun dans cherry. La même fonction peut être réécrite avec une compréhension de liste au lieu d’initialiser une liste vide et d’utiliser .append() :
def find_letter_occurrences(words, letter):
return [word.count(letter) for word in words]
Cette fonction classique renvoie une liste avec tous les résultats à chaque appel. Toutefois, si la liste de mots est volumineuse, l’appel de cette fonction consomme de la mémoire, puisque le programme crée et stocke une nouvelle liste de même taille que l’originale. Si cette fonction est utilisée à répétition sur plusieurs entrées, ou si des fonctions similaires opèrent sur les mêmes données, la pression mémoire peut grimper rapidement.
Utiliser une fonction génératrice
À la place, on peut utiliser une fonction génératrice :
def find_letter_occurrences(words, letter):
for word in words:
yield word.count(letter)
words = ["apple", "banana", "cherry"]
letter = "a"
output = find_letter_occurrences(words, letter)
print(output)
<generator object find_letter_occurrences at 0x102935e00>
La fonction contient le mot-clé yield au lieu de return. Cette fonction génératrice renvoie, lorsqu’elle est appelée, un objet générateur, affecté à output. Cet objet est un itérateur. Il ne contient pas en mémoire les nombres d’occurrences de la lettre dans chaque mot ; le générateur crée et fournit ces valeurs à la demande. Récupérons la première valeur de cet itérateur générateur :
print(next(output))
1
La fonction native next() est une façon d’obtenir la valeur suivante d’un itérateur. Nous verrons d’autres méthodes plus loin dans ce tutoriel.
Le code de la fonction génératrice s’exécute jusqu’à la ligne contenant le mot-clé yield. Dans cet exemple, la boucle for démarre sa première itération et récupère le premier élément de la liste words. La méthode de chaîne .count() renvoie un entier, ici 1, puisqu’il y a une occurrence de « a » dans apple. Le générateur fournit cette valeur, qui est renvoyée par next(output).
Le générateur output se met alors en pause. Il a donc terminé la première itération de la boucle for et compté le nombre d’occurrences de la lettre « a » dans le premier mot de la liste. Il attend désormais d’être sollicité à nouveau.
Si l’on rappelle next() avec output en argument, le générateur reprend l’exécution à l’endroit où il s’était arrêté :
print(next(output))
3
Le générateur reprend à la ligne yield dans la première itération de la boucle for. Comme la boucle n’a pas d’autre code à exécuter, elle repart en tête et récupère le deuxième élément de words. La méthode .count() renvoie cette fois 3, valeur qui est fournie. Le générateur se remet en pause.
Le troisième appel à next() relance l’exécution :
print(next(output))
0
La deuxième itération va au bout de la boucle for, qui passe à la troisième. Le code atteint à nouveau la ligne yield et fournit cette fois l’entier 0, puisqu’il n’y a aucune occurrence de « a » dans cherry.
Le générateur se met à nouveau en pause. Le programme ne détermine son sort qu’au quatrième appel à next() :
print(next(output))
Traceback (most recent call last):
...
StopIteration
L’exécution reprend à la fin de la troisième itération de la boucle for. Cependant, la boucle a fini de parcourir la liste words. Le générateur lève alors l’exception StopIteration.
Dans la plupart des cas d’usage, on n’accède pas aux éléments d’un générateur directement avec next(), mais via un autre processus d’itération. L’exception StopIteration signale la fin de l’itération. Nous approfondirons cela dans la section suivante.
Python propose une autre façon de créer des itérateurs générateurs lorsque leur fonctionnement tient en une seule expression, comme dans l’exemple précédent. L’itérateur générateur output peut être créé avec une expression génératrice :
words = ["apple", "banana", "cherry"]
letter = "a"
output = (word.count(letter) for word in words)
print(next(output))
print(next(output))
print(next(output))
print(next(output))
1
3
0
Traceback (most recent call last):
...
StopIteration
L’expression entre parenthèses affectée à output est une expression génératrice, qui crée un itérateur générateur similaire à celui produit par la fonction génératrice find_letter_occurrences().
Clôturons cette section avec un autre exemple de fonction génératrice pour bien montrer comment l’exécution se met en pause et reprend à chaque valeur demandée :
def show_status():
print("Start")
yield
print("Middle")
yield
print("End")
yield
status = show_status()
next(status)
Start
Cette fonction génératrice n’a pas de boucle. Elle comporte trois lignes avec le mot-clé yield. L’appel à la fonction show_status() crée un itérateur générateur status. Au premier appel à next(status), le générateur démarre : il affiche la chaîne "Start" et se met en pause après la première expression yield. Le générateur fournit None puisqu’aucun objet ne suit le mot-clé yield.
La chaîne "Middle" n’est imprimée que lorsque next() est appelée une deuxième fois :
next(status)
Middle
Le générateur se met en pause après la deuxième expression yield. Le troisième appel à next() affiche la dernière chaîne, "End" :
next(status)
End
Le générateur se met en pause sur le dernier yield. Il lèvera une exception StopIteration à la prochaine demande de valeur sur cet itérateur générateur :
next(status)
Traceback (most recent call last):
...
StopIteration
Nous explorerons d’autres façons d’utiliser les générateurs dans la section suivante.
Travailler avec des itérateurs générateurs
Les fonctions génératrices créent des itérateurs générateurs, et les itérateurs sont itérables. Détaillons : à chaque appel d’une fonction génératrice, Python crée un itérateur. Comme les itérateurs sont itérables, vous pouvez les utiliser dans des boucles for et d’autres processus d’itération.
Ainsi, la fonction native next() n’est pas la seule manière d’accéder aux éléments d’un itérateur. Cette section présente d’autres façons de travailler avec les générateurs.
Utiliser le protocole d’itération de Python avec des itérateurs générateurs
Revenons à une fonction génératrice vue plus haut :
def find_letter_occurrences(words, letter):
for word in words:
yield word.count(letter)
words = ["apple", "banana", "cherry"]
letter = "a"
output = find_letter_occurrences(words, letter)
for value in output:
print(value)
1
3
0
Au lieu d’appeler next() plusieurs fois, cette version utilise l’itérateur générateur output dans une boucle for. Les itérateurs étant itérables, ils peuvent être utilisés dans les boucles for. La boucle récupère les éléments jusqu’à ce qu’il n’y en ait plus.
Contrairement à des structures de données comme les listes et tuples, un itérateur ne s’utilise qu’une seule fois. Le code n’affiche rien si l’on exécute la même boucle for une deuxième fois :
def find_letter_occurrences(words, letter):
for word in words:
yield word.count(letter)
words = ["apple", "banana", "cherry"]
letter = "a"
output = find_letter_occurrences(words, letter)
print("First attempt:")
for value in output:
print(value)
print("Second attempt:")
for value in output:
print(value)
First attempt:
1
3
0
Second attempt:
L’itérateur est épuisé par la première boucle for, il ne peut donc plus fournir de valeurs. Si vous avez à nouveau besoin du générateur après son épuisement, il faut recréer un itérateur générateur à partir de la fonction génératrice.
Il est aussi possible d’avoir plusieurs itérateurs générateurs coexistants dans un même programme :
def find_letter_occurrences(words, letter):
for word in words:
yield word.count(letter)
words = ["apple", "banana", "cherry"]
letter = "a"
first_output = find_letter_occurrences(words, letter)
second_output = find_letter_occurrences(words, letter)
print("First value of first_output:")
print(next(first_output))
print("Values of second_output:")
for value in second_output:
print(value)
print("Remaining values of first_output:")
for value in first_output:
print(value)
First value of first_output:
1
Values of second_output:
1
3
0
Remaining values of first_output:
3
0
La fonction génératrice find_letter_occurrences() crée deux itérateurs générateurs : first_output et second_output. Bien qu’ils s’appuient sur les mêmes données de la liste words, ils progressent indépendamment.
Cet exemple récupère la première valeur de first_output avec next(). L’itérateur fournit 1 et se met en pause. Le programme parcourt ensuite second_output. Comme ce générateur n’a encore rien fourni, la boucle parcourt toutes ses valeurs. Enfin, une autre boucle for itère sur first_output, mais cet itérateur a déjà fourni sa première valeur ; la boucle parcourt donc les valeurs restantes.
La boucle for n’est pas la seule manière d’itérer sur des générateurs :
print(*find_letter_occurrences(words, letter))
print(sorted(find_letter_occurrences(words, letter)))
1 3 0
[0, 1, 3]
Dans ces exemples, le programme appelle directement la fonction génératrice pour créer et utiliser l’itérateur, sans l’affecter à une variable. Dans le premier, l’itérateur est décompressé via l’étoile. Ce procédé s’appuie sur le même protocole d’itération que la boucle for.
Dans le second, l’itérateur est passé à la fonction native sorted(), qui attend un itérable. Les générateurs étant itérables, vous pouvez les utiliser partout où Python itère.
Créer des itérateurs infinis
Un générateur fournit une valeur puis se met en pause jusqu’à la prochaine demande. Chaque fois qu’une valeur est demandée, le code de la fonction génératrice s’exécute jusqu’à la prochaine expression yield. Dans tous les exemples jusqu’ici, la fonction génératrice contenait un nombre fini d’expressions yield. Toutefois, on peut créer un générateur qui fournit un nombre infini de valeurs à l’aide d’une boucle while dans la fonction. Dans l’exemple suivant, le générateur fournit une couleur aléatoire parmi la liste passée en paramètre :
import random
def get_color(colors):
while True:
yield random.choice(colors)
output_colors = get_color(["red", "green", "blue"])
print("First two colors:")
print(next(output_colors))
print(next(output_colors))
print("Next 10 colors using a 'for' loop:")
for _ in range(10):
print(next(output_colors))
First two colors:
green
red
Next 10 colors using a 'for' loop:
blue
green
green
green
red
red
red
blue
green
red
La fonction génératrice get_color() contient une expression yield dans une boucle while. Le code rencontrera donc toujours un nouveau yield lors de la recherche de la valeur suivante. L’itérateur générateur output_colors fournit une infinité de couleurs choisies aléatoirement dans la liste d’entrée. Ce générateur ne sera jamais épuisé.
Il est impossible de créer des structures de données infinies telles que des listes ou des tuples. Les générateurs permettent de créer des itérables infinis. Notez que si vous utilisez l’itérateur directement dans une boucle for, la boucle tournera indéfiniment.
Concepts avancés sur les générateurs
Les générateurs ont des cas d’usage plus avancés en Python. Explorons-en quelques-uns.
Envoyer un objet dans le générateur
Les générateurs peuvent aussi accepter des données supplémentaires utilisables pendant l’évaluation du code. L’instruction contenant le mot-clé yield est une expression qui s’évalue en une valeur. Cette valeur peut être affectée à une variable dans la fonction génératrice. Commençons par un exemple simple pour illustrer le concept :
def generator_function():
value = yield 1
print(f"The yield expression evaluates to: {value}")
value = yield 2
print(f"The yield expression evaluates to: {value}")
output = generator_function()
print(next(output))
print(next(output))
print(next(output))
1
The yield expression evaluates to: None
2
The yield expression evaluates to: None
Traceback (most recent call last):
...
StopIteration
Le mot-clé yield en Python crée une expression qui s’évalue en une valeur. Toutefois, la valeur de cette expression à l’intérieur de la fonction génératrice n’est pas le même objet que celui fourni par le générateur. Prenons la première expression yield. Le générateur fournit l’entier 1. Ainsi, print(next(output)) affiche 1 lors du premier appel et met en pause l’exécution du générateur.
Cependant, l’expression yield dans le générateur s’évalue en un objet, que le code affecte au nom de variable value. Dans cet exemple, yield affecte None à value. Le même processus se répète pour le deuxième yield dans la fonction. Le troisième appel à next() sert à exécuter tout le code restant de la fonction génératrice.
Remplaçons le deuxième et le troisième appels à next() par .send(), une méthode de la classe des générateurs :
def generator_function():
value = yield 1
print(f"The yield expression evaluates to: {value}")
value = yield 2
print(f"The yield expression evaluates to: {value}")
output = generator_function()
print(next(output))
print(output.send("Here's a value"))
print(output.send("Here's another value"))
1
The yield expression evaluates to: Here's a value
2
The yield expression evaluates to: Here's another value
Traceback (most recent call last):
...
StopIteration
La fonction génératrice est inchangée. Le générateur est lancé avec next(), et le code s’exécute jusqu’à fournir le premier entier, 1. Au lieu d’utiliser next() la deuxième fois, le programme appelle output.send(). Cette méthode envoie un objet au générateur. Ici, c’est une chaîne de caractères. L’expression yield dans la fonction s’évalue alors en cette chaîne, affectée à value. Le générateur peut donc l’utiliser dans son code.
Le second appel à .send() envoie un nouvel objet au générateur, à nouveau affecté à la variable value. Le générateur lève StopIteration après le dernier print(), puisqu’il n’y a plus d’expressions yield.
Voyons un autre exemple avec .send(). Le générateur suivant affiche le solde d’un compte, mais ce solde peut être mis à jour :
def get_balance(start_balance):
balance = start_balance
while True:
amount = yield balance
if amount is not None:
balance += amount
current_balance = get_balance(100)
print(next(current_balance))
print(current_balance.send(10))
print(current_balance.send(-20))
print(next(current_balance))
100
110
90
90
La fonction génératrice exige un solde initial à l’appel. La valeur de balance peut changer au fil de l’exécution. Tout objet envoyé au générateur via .send() est affecté à amount. Cette variable vaut None si le générateur fournit une valeur sans qu’aucun objet ne lui soit envoyé, ou bien contient l’objet transmis avec .send().
L’itérateur générateur current_balance démarre avec un solde de 100 $. Le générateur est lancé par next(), qui l’exécute jusqu’à la première valeur fournie.
Une fois démarré, on peut relancer l’exécution avec .send() au lieu de next(). Le générateur additionne la valeur envoyée au solde. Si aucune valeur n’est envoyée (par exemple avec next()), le générateur fournit le solde inchangé.
Fournir directement depuis un autre itérable
Les générateurs Python peuvent aussi fournir des valeurs directement depuis un autre générateur ou itérable grâce à la syntaxe yield from. Voici un exemple de fonction génératrice qui fournit les valeurs d’une liste imbriquée :
def flatten(nested_list):
for item in nested_list:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
nested_list = [1, [2, 3], [4, [5, 6]], 7]
print(list(flatten(nested_list)))
[1, 2, 3, 4, 5, 6, 7]
La fonction génératrice accepte une liste pouvant contenir des listes imbriquées. La boucle for parcourt les éléments. Chaque élément de la liste externe est soit une valeur (ici, un entier), soit une autre liste. Quand l’élément n’est pas une liste, le générateur le fournit directement.
En revanche, lorsque l’élément est une liste, le générateur appelle récursivement la fonction génératrice flatten() avec la liste interne comme argument. Cela crée un nouvel itérateur générateur, qui utilise la liste interne comme source de données. Si cette ligne utilisait yield, le premier générateur fournirait le second générateur. En utilisant yield from, le premier générateur fournit les valeurs issues du second.
Résumé : yield vs return
Les définitions de fonctions avec return et yield se ressemblent, mais leur comportement diffère. Résumons les principales différences :
|
Fonction classique |
Fonction génératrice |
|
|
Mot-clé |
return (implicite si non utilisé explicitement) |
yield |
|
À l’appel |
Exécute le code jusqu’à rencontrer return, puis renvoie la valeur finale |
Crée un itérateur générateur |
|
Arrêt |
S’achève avec l’instruction return |
Mis en pause par yield, peut reprendre plus tard |
|
Valeur renvoyée |
Objet unique (peut être une structure de données) |
Itérateur générateur |
|
Expression yield |
Sans objet (crée une instruction) |
S’évalue à None ou à la valeur envoyée avec .send() |
|
Cas d’usage |
Idéal pour renvoyer un résultat final |
Idéal pour créer un flux de données, notamment des séquences longues ou infinies |
Conclusion
Le mot-clé yield de Python s’utilise dans les fonctions pour définir une fonction génératrice. À l’appel, ces fonctions créent des itérateurs générateurs. Les générateurs illustrent l’évaluation paresseuse en Python, où les expressions sont évaluées lorsque la valeur est nécessaire, plutôt qu’au moment de l’exécution de l’expression. L’expression yield est donc utile pour créer un flux de données dont les valeurs sont produites à la demande, sans avoir à les stocker en mémoire.
Les considérations de performance sont cruciales avec de grands jeux de données nécessitant de nombreuses opérations. Les itérateurs générateurs de Python font partie des principaux outils pour manipuler efficacement de gros volumes de données.
Pour aller plus loin en Python, découvrez ce parcours de carrière Python Developer.
J'ai étudié la physique et les mathématiques au niveau UG à l'université de Malte. J'ai ensuite déménagé à Londres et obtenu un doctorat en physique à l'Imperial College. J'ai travaillé sur de nouvelles techniques optiques pour obtenir des images de la rétine humaine. Aujourd'hui, je me concentre sur l'écriture, la communication et l'enseignement de Python.
