Bases

Générateurs en Python : yield, itérateurs et compréhensions de listes et de dictionnaires

Mis à jour: · Skillaria.Top

Un générateur en Python est un objet qui fournit ses valeurs une à une, à la demande, sans conserver toute la séquence en mémoire ; on le crée avec une fonction contenant le mot-clé yield ou avec une expression génératrice entre parenthèses. Les compréhensions de listes, de dictionnaires et d'ensembles sont une écriture courte pour construire directement une collection : [x * 2 for x in xs], {k: v for k, v in paires}, {x for x in xs}.

Un générateur en Python est un objet qui fournit ses valeurs une par une, seulement lorsqu'on les demande, sans garder toute la séquence en mémoire. On le crée de deux façons : avec une fonction contenant le mot-clé yield, ou avec une expression génératrice entre parenthèses. À côté, on trouve les compréhensions de listes, de dictionnaires et d'ensembles : une écriture courte pour construire une collection complète en une ligne. Voyons comment fonctionne yield, en quoi un générateur diffère d'un itérateur, et comment éviter qu'une compréhension ne devienne illisible.

Itérateurs : ce sur quoi repose la boucle for

Pour comprendre les générateurs, il faut d'abord connaître les itérateurs. Un objet itérable (iterable) est ce qu'on peut parcourir avec une boucle : liste, chaîne, dictionnaire, fichier. Un itérateur est un objet qui retient sa position courante et renvoie l'élément suivant à chaque appel de next() ; quand les éléments sont épuisés, il lève StopIteration.

nombres = [10, 20, 30]
it = iter(nombres)   # obtenir un itérateur à partir de la liste
print(next(it))      # 10
print(next(it))      # 20
print(next(it))      # 30
next(it)             # StopIteration

La boucle for fait exactement cela : elle appelle iter(), puis next() jusqu'à StopIteration. On peut écrire son propre itérateur sous forme de classe, avec les méthodes __iter__ et __next__ :

class CompteARebours:
    def __init__(self, debut):
        self.courant = debut

    def __iter__(self):
        return self

    def __next__(self):
        if self.courant <= 0:
            raise StopIteration
        valeur = self.courant
        self.courant -= 1
        return valeur

print(list(CompteARebours(3)))  # [3, 2, 1]

Cela fonctionne, mais le code est très répétitif. Les générateurs font la même chose en trois lignes.

Qu'est-ce qu'un générateur en Python et comment fonctionne yield

Une fonction génératrice est une fonction ordinaire qui contient yield. Son appel n'exécute aucun code : il renvoie un objet générateur. Chaque next() lance le corps de la fonction jusqu'au yield suivant, fournit la valeur, puis gèle la fonction avec ses variables locales. Le next() suivant reprend exactement au même endroit.

def compte_a_rebours(debut):
    while debut > 0:
        yield debut
        debut -= 1

gen = compte_a_rebours(3)
print(gen)          # <generator object compte_a_rebours at 0x...>
print(next(gen))    # 3
print(list(gen))    # [2, 1] — la suite à partir de l'arrêt

Quand la fonction arrive à sa fin (ou à un return), le générateur lève lui-même StopIteration, et la boucle for se termine proprement. D'où la différence principale entre yield et return : return termine la fonction et renvoie une seule valeur, yield suspend la fonction et peut fournir autant de valeurs que nécessaire.

Générateur et itérateur : les différences

Tout générateur est un itérateur : il possède __iter__ et __next__. Mais tout itérateur n'est pas un générateur. La différence tient à la manière de le créer et à ses possibilités.

Itérateur (classe)Générateur
Comment le créerclasse avec __iter__ et __next__fonction avec yield ou (x for x in ...)
Étatgéré à la main dans des attributsconservé automatiquement dans les variables locales
StopIterationvous le levez vous-mêmeapparaît tout seul à la fin de la fonction
En plusce que vous écrivezsend(), throw(), close()

Une propriété importante leur est commune : on ne peut les parcourir qu'une seule fois. Une seconde boucle for sur un générateur épuisé ne produit rien : il faut en créer un nouveau.

Générateur infini et lecture de gros fichiers

Un générateur n'a pas besoin de se terminer. Une séquence infinie est sans danger tant que vous ne prenez qu'un nombre limité d'éléments :

from itertools import islice

def identifiants():
    n = 1
    while True:
        yield f"ID-{n:04d}"
        n += 1

print(list(islice(identifiants(), 3)))  # ['ID-0001', 'ID-0002', 'ID-0003']

Le cas le plus pratique est le traitement d'un gros fichier ligne par ligne. Les générateurs peuvent être enchaînés en pipeline : en mémoire, il n'y a alors qu'une ligne à la fois, et non le fichier entier.

def lire_lignes(chemin):
    with open(chemin, encoding="utf-8") as f:
        for ligne in f:
            yield ligne.rstrip("\n")

def seulement_erreurs(lignes):
    for ligne in lignes:
        if "ERROR" in ligne:
            yield ligne

for ligne in seulement_erreurs(lire_lignes("app.log")):
    print(ligne)

yield from et send

yield from transmet toutes les valeurs d'un autre objet itérable, ce qui évite une boucle imbriquée :

def aplatir(groupes):
    for g in groupes:
        yield from g

print(list(aplatir([[1, 2], [3], [4, 5]])))  # [1, 2, 3, 4, 5]

La méthode send() envoie une valeur à l'intérieur du générateur : elle devient le résultat de l'expression yield. Avant le premier send, il faut « amorcer » le générateur avec next(). C'est ainsi qu'on écrit, par exemple, un calcul de moyenne cumulée :

def moyenne_glissante():
    total, nombre = 0, 0
    moyenne = None
    while True:
        valeur = yield moyenne
        total += valeur
        nombre += 1
        moyenne = total / nombre

m = moyenne_glissante()
next(m)            # aller jusqu'au premier yield
print(m.send(10))  # 10.0
print(m.send(20))  # 15.0

Dans le code d'application, send est rare, mais il revient souvent dans les entretiens d'embauche.

range : est-ce un générateur ?

Non. range est une séquence paresseuse : elle ne stocke pas non plus tous les nombres, mais elle gère l'indexation, len(), le test in, et peut être parcourue autant de fois qu'on veut. Un générateur ne sait rien faire de tout cela.

r = range(5)
print(r[2], len(r), 3 in r)  # 2 5 True
print(list(r), list(r))      # les deux fois [0, 1, 2, 3, 4]

Compréhension de liste : list comprehension

La compréhension de liste (list comprehension) s'écrit [expression for x in collection]. Elle crée une nouvelle liste sans modifier l'originale. On la lit de gauche à droite : d'abord « quoi mettre », ensuite « où le prendre ».

montants = [100, 250, 400]

# boucle classique
avec_marge = []
for m in montants:
    avec_marge.append(m * 1.2)

# la même chose en compréhension
avec_marge = [m * 1.2 for m in montants]
print(avec_marge)  # [120.0, 300.0, 480.0]

Malgré son nom français de « générateur de listes », le résultat est une liste ordinaire déjà complète : tous les éléments sont calculés immédiatement.

Compréhension de liste avec if et if else

C'est là que l'on se trompe le plus souvent. La condition-filtre se place à la fin et écarte des éléments. La condition-choix (opérateur ternaire) se place au début et transforme chaque élément en l'une de deux valeurs, sans rien écarter.

montants = [100, 250, 400]

grands = [m for m in montants if m > 200]                 # [250, 400]
etiquettes = ["élevé" if m > 200 else "faible" for m in montants]
# ['faible', 'élevé', 'élevé']

# filtre et choix ensemble
pair_impair = ["pair" if n % 2 == 0 else "impair" for n in range(6) if n > 2]
# ['impair', 'pair', 'impair']

L'écriture [m for m in montants if m > 200 else 0] est une erreur de syntaxe : le else ne s'utilise qu'avec la condition placée au début.

Compréhensions imbriquées et tableau à deux dimensions

La double boucle s'écrit dans le même ordre que des boucles imbriquées, de haut en bas. Cela évite de se tromper dans l'ordre lorsqu'on aplatit une liste de listes :

groupes = [[1, 2], [3], [4, 5]]
plat = [x for g in groupes for x in g]
# équivalent à :
# for g in groupes:
#     for x in g:
#         plat.append(x)
print(plat)  # [1, 2, 3, 4, 5]

On construit une matrice (un tableau à deux dimensions) avec une compréhension dans une compréhension. L'erreur classique est la multiplication de listes : les lignes deviennent le même objet.

mauvais = [[0] * 3] * 2
mauvais[0][0] = 1
print(mauvais)   # [[1, 0, 0], [1, 0, 0]] — les deux lignes ont changé

grille = [[0] * 3 for _ in range(2)]
grille[0][0] = 1
print(grille)    # [[1, 0, 0], [0, 0, 0]]

# transposition
m = [[1, 2, 3], [4, 5, 6]]
print([[ligne[i] for ligne in m] for i in range(3)])  # [[1, 4], [2, 5], [3, 6]]

Compréhension de dictionnaire et d'ensemble

Le même principe s'applique aux autres collections. Des accolades avec deux-points construisent un dictionnaire (dict comprehension), sans deux-points elles construisent un ensemble (set comprehension).

depenses = [("alimentation", 500), ("taxi", 300), ("alimentation", 200)]

categories = {cat for cat, _ in depenses}
print(categories)  # {'alimentation', 'taxi'} — l'ordre d'un ensemble n'est pas garanti

dernier = {cat: montant for cat, montant in depenses}
print(dernier)     # {'alimentation': 200, 'taxi': 300}

carres = {n: n ** 2 for n in range(4)}  # {0: 0, 1: 1, 2: 4, 3: 9}
inverse = {v: k for k, v in carres.items()}

Remarquez dernier : quand une clé se répète, c'est la dernière valeur qui l'emporte, et la somme 500 disparaît. Si vous devez additionner, la compréhension ne convient pas : écrivez une boucle, ou utilisez collections.Counter ou defaultdict.

Et les compréhensions de tuples ?

Python n'a pas de « compréhension de tuple ». Les parenthèses donnent une expression génératrice, et on obtient un tuple en la passant à tuple() :

t = tuple(n * 2 for n in range(3))  # (0, 2, 4)

Expression génératrice : produire au lieu de stocker

Si l'on remplace les crochets par des parenthèses, on obtient une expression génératrice. Elle ne calcule rien à l'avance et fournit les valeurs une à une. Pour un million d'éléments, la différence de mémoire est énorme :

import sys

en_liste = [n for n in range(1_000_000)]
en_generateur = (n for n in range(1_000_000))
print(sys.getsizeof(en_liste))       # environ 8 Mo
print(sys.getsizeof(en_generateur))  # quelques centaines d'octets

Ces expressions apparaissent le plus souvent comme argument de fonctions d'agrégation. Si le générateur est le seul argument, on peut omettre les parenthèses supplémentaires. any() et all() s'arrêtent d'ailleurs dès la première réponse et ne parcourent pas le reste.

depenses = [("alimentation", 500), ("taxi", 300), ("alimentation", 200)]

total = sum(m for _, m in depenses)              # 1000
il_y_a_grand = any(m > 400 for _, m in depenses)  # True, ne regarde pas au-delà de 500
plus_grande = max(depenses, key=lambda p: p[1])  # ('alimentation', 500)

La règle de choix est simple : si le résultat sert plusieurs fois, si l'on a besoin d'indexation ou de len(), prenez une liste. Si l'on doit parcourir une fois et calculer quelque chose, prenez un générateur.

Portée des variables : la variable ne fuit pas

Question sur laquelle trébuchent même des développeurs expérimentés :

x = 10
carres = [x for x in range(3)]
print(x)  # 10

La variable de boucle d'une compréhension vit dans son propre espace et n'écrase pas la variable extérieure (c'était différent en Python 2). Après une boucle for ordinaire, x vaudrait 2. Depuis la version 3.12 (PEP 709), les compréhensions sont intégrées dans la fonction englobante et s'exécutent plus vite, mais la règle de portée reste la même.

Décomposition * et ** dans les compréhensions : nouveauté de Python 3.15 (PEP 798)

L'astérisque de décomposition s'utilisait depuis longtemps dans un appel de fonction et dans un littéral de liste, mais dans une compréhension c'était une SyntaxError. Python 3.15 lève cette interdiction : *élément signifie « insère ici tous ses éléments ». Cela fonctionne dans les listes, les ensembles et les générateurs, y compris asynchrones.

# Python 3.15+
groupes = [[1, 2], [3], [4, 5]]
plat = [*g for g in groupes]        # [1, 2, 3, 4, 5]
uniques = {*g for g in groupes}     # ensemble
paresseux = (*g for g in groupes)   # générateur paresseux

Le double astérisque fait de même pour les dictionnaires. C'est pratique pour des réglages en couches « valeurs par défaut → fichier → options » : chaque couche remplace les clés de la précédente.

# Python 3.15+
couches = [{"debug": False, "port": 80}, {"port": 8080}, {"debug": True}]
config = {**couche for couche in couches}
# {'debug': True, 'port': 8080}

Deux remarques du cours. L'astérisque ne peut pas être combiné sans parenthèses avec une expression conditionnelle : [*w if w else 0 for w in semaines] est une erreur de syntaxe, il faut mettre la condition entre parenthèses. Et ce code ne fonctionne pas sous Python 3.14 et versions antérieures : si votre bibliothèque doit supporter les anciennes versions, gardez la double boucle for ou itertools.chain.from_iterable.

Générateurs asynchrones

Si une fonction async def contient un yield, on obtient un générateur asynchrone. On le parcourt avec async for, et l'on peut attendre le réseau ou le disque avec await. Il existe aussi des compréhensions asynchrones.

import asyncio

async def tictacs(n):
    for i in range(n):
        await asyncio.sleep(0.1)
        yield i

async def main():
    resultat = [i async for i in tictacs(3)]
    print(resultat)  # [0, 1, 2]

asyncio.run(main())

Erreurs typiques

  • Compréhension à trois étages. Deux for, deux if et un ternaire sur une seule ligne : personne ne la relira dans une semaine. Si cela ne tient pas en une ligne lisible, écrivez une boucle ordinaire : le guide PEP 8 place la lisibilité avant la concision.
  • Second parcours d'un générateur. Un second sum(gen) renvoie 0, car le générateur est déjà épuisé.
  • Compréhension pour un effet de bord. [print(x) for x in xs] construit une liste inutile de None. Pour des actions, utilisez une boucle for.
  • Attendre une liste d'une expression génératrice. (x for x in xs)[0] provoque TypeError : un générateur n'a pas d'indices.

Exercices sur les générateurs

  1. Écrivez un générateur fib() qui produit indéfiniment les nombres de Fibonacci, et prenez les dix premiers avec islice.
  2. En une ligne, obtenez un dictionnaire « mot → longueur » pour les mots de plus de trois lettres.
  3. Calculez la somme des carrés des nombres pairs jusqu'à un million sans créer de liste.
  4. Écrivez un générateur morceaux(seq, n) qui produit des morceaux de la liste de n éléments.

En bref

Un itérateur fournit les éléments un à un via next(), et le moyen le plus simple d'en écrire un est le générateur : une fonction avec yield ou une expression entre parenthèses. Les compréhensions de listes, de dictionnaires et d'ensembles construisent une collection complète en une ligne ; le filtre if se place à la fin, le choix if else au début. Si vous voulez économiser la mémoire ou traiter un flux de données une seule fois, prenez un générateur ; si le résultat sert plusieurs fois, prenez une liste. Compréhensions, générateurs et nouvelle décomposition de Python 3.15 sont mis en pratique dans le cours, à travers un suivi de dépenses.

Questions fréquentes

Peut-on utiliser return dans un générateur ?

Oui. return sans valeur termine simplement le générateur ; return avec une valeur la place dans l'attribut value de l'exception StopIteration, que récupère l'expression yield from.

Comment transformer un générateur en liste ?

Passez-le à list() : list(gen). Ensuite le générateur est épuisé, et toutes les valeurs se retrouvent en mémoire.

Qu'est-ce qui est plus rapide, une compréhension de liste ou une boucle avec append ?

En général la compréhension est un peu plus rapide, car elle n'appelle pas la méthode append à chaque tour. Mais le choix doit se faire selon la lisibilité : la différence est rarement décisive.

Comment savoir qu'un objet est un générateur ?

Utilisez inspect.isgenerator(obj) pour un objet, ou inspect.isgeneratorfunction(f) pour une fonction. Pour vérifier qu'un objet est un itérateur, utilisez isinstance(obj, collections.abc.Iterator).

En quoi une expression génératrice diffère-t-elle d'une compréhension de liste ?

Les crochets construisent immédiatement toute la liste en mémoire, alors que les parenthèses créent un générateur qui calcule les éléments à la demande et ne peut être parcouru qu'une seule fois.

Apprenez ce sujet en vidéo

Ce sujet est traité en détail avec de la pratique dans le cours « Python 3.15: от новичка до профи ».

Voir le cours