Le mot-clé yield est utilisé à l'intérieur d'une fonction. On parle alors de fonction génératrice, qui renvoie elle-même un générateur. Contrairement à une fonction classique qui s'arrête et renvoie une valeur finale avec return, une fonction génératrice met son exécution en pause, renvoie une valeur intermédiaire et se souvient de son état pour reprendre là où elle en était à la prochaine itération.
Comment fonctionne un générateur ?
Un générateur est en réalité un objet itérable. Pour interagir manuellement avec lui, Python utilise les fonctions natives iter() et next() :
-
La fonction
iter()permet d'obtenir un itérateur à partir d'un objet -
La fonction
next()demande à l'itérateur de calculer et de renvoyer la valeur suivante
À noter
Itérable vs Itérateur vs Générateur 🤯.
On va reprendre terme par terme :
-
Itérable : Un objet que l'on peut parcourir (comme une liste). Il possède la méthode
__iter__()(ce qui permet d'appeleriter()dessus), mais ne possède pas la méthode__next__() -
Itérateur : L'objet renvoyé par
iter()qui effectue réellement le parcours. Il possède obligatoirement les deux méthodes__iter__()et__next__() -
Générateur : Un itérateur spécifique. Parce qu'il est créé via
yield(ou une expression génératrice), il est déjà un itérateur dès sa création. Il possède nativement__next__()et__iter__(). Il est donc à la fois un itérable et un itérateur
Un générateur est un itérateur, et donc aussi un itérable. Sa particularité est qu'il génère les valeurs à la demande plutôt que de parcourir des valeurs déjà existantes en mémoire.
def compteur_personnalise(maximum): print("Début du compteur...") compteur = 1 while compteur <= maximum: yield compteur # Pause ici compteur += 1 # Création du générateur mon_compteur = compteur_personnalise(2) # Rien ne s'exécute ici ! (Lazy evaluation) # L'exécution du corps de la fonction démarre seulement au premier appel de next() print(next(mon_compteur)) # Affiche : Début du compteur... puis 1 print(next(mon_compteur)) # Affiche : 2 # Si on appelle next() une 3ème fois : # print(next(mon_compteur)) -> Lève une exception StopIteration
On est d'accord que gérer une exception StopIteration manuellement est fastidieux. Heureusement, vous connaissez la fameuse boucle for qui gère tout cela automatiquement.
def compteur_personnalise(maximum): print("Début du compteur...") compteur = 1 while compteur <= maximum: yield compteur # Pause ici compteur += 1 for nombre in compteur_personnalise(5): print(f"Compteur actuel : {nombre}") # Compteur actuel : 1 # ... # Compteur actuel : 5
La boucle s'arrête proprement.
Les expressions génératrices
Vous connaissez certainement les compréhensions de liste. Vous serez heureux de savoir qu'il existe un équivalent pour les générateurs : les expressions génératrices.
Il suffit de remplacer les [] par des () :
# Compréhension de liste (crée la liste entière en mémoire) liste_carres = [x**2 for x in range(10)] # Expression génératrice (utilise yield en arrière-plan) generateur_carres = (x**2 for x in range(10))
Astuce simple et concise 😁.
yield from
yield from permet à un générateur de déléguer une partie des opérations à un autre itérable. Cela permet d'éviter les boucles for imbriquées :
def sous_generateur(): yield "A" yield "B" def generateur_principal(): yield 1 # Au lieu de faire : # for element in sous_generateur(): # yield element yield from sous_generateur() # Délégation directe ! yield 2 for valeur in generateur_principal(): print(valeur) # Affiche : 1, A, B, 2
C'est particulièrement pratique pour parcourir des structures récursives :
def flatten(data): for item in data: if isinstance(item, list): yield from flatten(item) else: yield item list(flatten([1, [2, [3, 4]], 5])) # → [1, 2, 3, 4, 5]
Pourquoi utiliser yield ?
yield est particulièrement utile pour le traitement de données volumineuses. Au lieu de tout charger en mémoire, le générateur produit les valeurs une par une, à la demande :
import sys # La liste stocke les 100 000 éléments en mémoire liste = [x**2 for x in range(100_000)] # Le générateur ne stocke que la logique de calcul generateur = (x**2 for x in range(100_000)) print(sys.getsizeof(liste)) # ~800 000 octets (800 Ko) print(sys.getsizeof(generateur)) # ~200 octets, et ce, peu importe la taille !
À noter
sys.getsizeof renvoie la taille de la liste et de ses pointeurs, mais ne suit pas ces pointeurs jusqu'aux objets référencés. La RAM réellement consommée est donc supérieure.