Basi

Generatori in Python: yield, iteratori e list comprehension

Aggiornato: · Skillaria.Top

Un generatore in Python è un oggetto che restituisce i valori uno alla volta, su richiesta, senza tenerli tutti in memoria; si crea con una funzione che usa la parola yield oppure con un'espressione generatrice tra parentesi tonde. Le list, dict e set comprehension sono la scrittura breve per costruire una collezione completa in una riga: [x * 2 for x in xs], {k: v for k, v in pairs}, {x for x in xs}.

Un generatore in Python è un oggetto che restituisce i valori uno alla volta, solo quando vengono richiesti, senza tenere in memoria l'intera sequenza. Si crea in due modi: con una funzione che usa la parola chiave yield oppure con un'espressione generatrice tra parentesi tonde. Accanto ai generatori ci sono le list, dict e set comprehension, cioè la scrittura sintetica per costruire una collezione in una sola riga. Vediamo come funziona yield, in che cosa un generatore si differenzia da un iteratore e come evitare che una comprehension diventi illeggibile.

Iteratori: su che cosa si basa il ciclo for

Per capire i generatori bisogna prima conoscere gli iteratori. Un oggetto iterabile (iterable) è qualcosa su cui si può fare un ciclo: lista, stringa, dizionario, file. Un iteratore è un oggetto che ricorda la posizione corrente e, a ogni chiamata di next(), restituisce l'elemento successivo; quando gli elementi finiscono, solleva StopIteration.

nums = [10, 20, 30]
it = iter(nums)      # ottiene un iteratore dalla lista
print(next(it))      # 10
print(next(it))      # 20
print(next(it))      # 30
next(it)             # StopIteration

Il ciclo for fa esattamente questo: chiama iter(), poi next() fino a StopIteration. Un iteratore personalizzato si può scrivere come classe con i metodi __iter__ e __next__:

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

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

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

Funziona, ma il codice ripetitivo è molto. I generatori fanno lo stesso in tre righe.

Che cos'è un generatore in Python e come funziona yield

Una funzione generatrice è una normale funzione che contiene yield nel suo corpo. Chiamarla non esegue il codice, ma restituisce un oggetto generatore. Ogni next() esegue il corpo fino al yield successivo, restituisce il valore e congela la funzione insieme alle sue variabili locali. Il next() successivo riprende esattamente dal punto in cui si era fermata.

def countdown(start):
    while start > 0:
        yield start
        start -= 1

gen = countdown(3)
print(gen)          # <generator object countdown at 0x...>
print(next(gen))    # 3
print(list(gen))    # [2, 1] — ripresa dal punto di sosta

Quando la funzione arriva alla fine (o a un return), il generatore solleva da solo StopIteration e il ciclo for termina senza problemi. Da qui la differenza principale tra yield e return: return termina la funzione e restituisce un solo valore, yield la sospende e può restituire quanti valori servono.

Generatore e iteratore: le differenze

Ogni generatore è un iteratore: ha __iter__ e __next__. Ma non ogni iteratore è un generatore. La differenza sta nel modo di crearlo e nelle possibilità offerte.

Iteratore (classe)Generatore
Come si creaclasse con __iter__ e __next__funzione con yield oppure (x for x in ...)
Statogestito a mano negli attributiconservato automaticamente nelle variabili locali
StopIterationlo solleva chi scrive il codicecompare da solo all'uscita dalla funzione
Funzioni aggiuntivequelle che scrivetesend(), throw(), close()

Entrambi hanno una proprietà importante in comune: si possono percorrere una sola volta. Un secondo for su un generatore esaurito non restituisce nulla: bisogna crearne uno nuovo.

Generatore infinito e lettura di file grandi

Un generatore non deve per forza terminare. Una sequenza infinita è sicura finché si prende da essa un numero limitato di elementi:

from itertools import islice

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

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

Lo scenario più pratico è l'elaborazione di un file grande riga per riga. I generatori si possono concatenare in una pipeline: in ogni momento in memoria c'è una sola riga, non l'intero file:

def read_lines(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            yield line.rstrip("\n")

def only_errors(lines):
    for line in lines:
        if "ERROR" in line:
            yield line

for line in only_errors(read_lines("app.log")):
    print(line)

yield from e send

yield from passa tutti i valori di un altro oggetto iterabile, evitando un ciclo annidato:

def flatten(groups):
    for g in groups:
        yield from g

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

Il metodo send() invia un valore all'interno del generatore: diventa il risultato dell'espressione yield. Prima del primo send il generatore va «avviato» con next(). Così si scrive, per esempio, un calcolatore della media progressiva:

def averager():
    total, count = 0, 0
    avg = None
    while True:
        value = yield avg
        total += value
        count += 1
        avg = total / count

a = averager()
next(a)            # arrivare al primo yield
print(a.send(10))  # 10.0
print(a.send(20))  # 15.0

Nel codice applicativo send si incontra raramente, ma è un argomento frequente nei colloqui di lavoro.

range è un generatore?

No. range è una sequenza pigra: anch'esso non memorizza tutti i numeri, ma supporta l'indicizzazione, len(), la verifica con in e può essere percorso quante volte si vuole. Il generatore non sa fare nulla di tutto ciò.

r = range(5)
print(r[2], len(r), 3 in r)  # 2 5 True
print(list(r), list(r))      # entrambe le volte [0, 1, 2, 3, 4]

List comprehension: la lista in una riga

La list comprehension è la scrittura [espressione for x in collezione]. Crea una nuova lista senza modificare quella di partenza. Si legge da sinistra a destra: prima «che cosa mettere», poi «da dove prenderlo».

amounts = [100, 250, 400]

# ciclo classico
with_markup = []
for a in amounts:
    with_markup.append(a * 1.2)

# la stessa cosa con la comprehension
with_markup = [a * 1.2 for a in amounts]
print(with_markup)  # [120.0, 300.0, 480.0]

Nonostante si parli di «generatore di liste», il risultato è una normale lista già pronta, non un generatore: tutti gli elementi vengono calcolati subito.

Comprehension con if e if else

Qui ci si confonde più spesso. La condizione di filtro va alla fine e scarta gli elementi. La condizione di scelta (operatore ternario) va all'inizio e trasforma ogni elemento in uno dei due valori, senza scartarne nessuno.

amounts = [100, 250, 400]

big = [a for a in amounts if a > 200]                    # [250, 400]
labels = ["molto" if a > 200 else "poco" for a in amounts]
# ['poco', 'molto', 'molto']

# filtro e scelta insieme
parity = ["pari" if n % 2 == 0 else "dispari" for n in range(6) if n > 2]
# ['dispari', 'pari', 'dispari']

La scrittura [a for a in amounts if a > 200 else 0] è un errore di sintassi: else è ammesso solo nella condizione iniziale.

Comprehension annidate e liste bidimensionali

Il doppio for si scrive nello stesso ordine dei cicli annidati, dall'alto verso il basso. Questo aiuta a non confondere l'ordine quando si appiattisce una lista di liste:

groups = [[1, 2], [3], [4, 5]]
flat = [x for g in groups for x in g]
# equivalente a:
# for g in groups:
#     for x in g:
#         flat.append(x)
print(flat)  # [1, 2, 3, 4, 5]

Una matrice bidimensionale si crea con una comprehension dentro un'altra. L'errore classico è moltiplicare la lista: le righe finirebbero per essere lo stesso oggetto.

bad = [[0] * 3] * 2
bad[0][0] = 1
print(bad)   # [[1, 0, 0], [1, 0, 0]] — sono cambiate entrambe le righe

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

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

Dict e set comprehension

Lo stesso schema funziona per le altre collezioni. Le parentesi graffe con i due punti costruiscono un dizionario (dict comprehension), quelle senza due punti costruiscono un insieme (set comprehension).

spent = [("cibo", 500), ("taxi", 300), ("cibo", 200)]

categories = {cat for cat, _ in spent}
print(categories)  # {'cibo', 'taxi'} — l'ordine nel set non è garantito

last = {cat: amount for cat, amount in spent}
print(last)        # {'cibo': 200, 'taxi': 300}

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

Notate last: in caso di chiave ripetuta vince l'ultimo valore, e il 500 si perde. Se bisogna sommare, la comprehension non va bene: servono un ciclo oppure collections.Counter / defaultdict.

E la tuple comprehension?

In Python non esiste una «tuple comprehension» separata. Le parentesi tonde danno un'espressione generatrice, mentre una tupla si ottiene passandola a tuple():

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

Espressione generatrice: produrre invece di memorizzare

Se si sostituiscono le parentesi quadre con quelle tonde, si ottiene un'espressione generatrice. Non calcola nulla in anticipo e restituisce i valori uno per uno. Per un milione di elementi la differenza di memoria è enorme:

import sys

as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list))  # circa 8 MB
print(sys.getsizeof(as_gen))   # un paio di centinaia di byte

Le espressioni generatrici vivono spesso dentro le funzioni di aggregazione. Se il generatore è l'unico argomento, le parentesi aggiuntive non servono. any() e all() inoltre si fermano alla prima risposta e non scorrono il resto:

spent = [("cibo", 500), ("taxi", 300), ("cibo", 200)]

total = sum(a for _, a in spent)            # 1000
has_big = any(a > 400 for _, a in spent)    # True, oltre 500 non guarda
biggest = max(spent, key=lambda p: p[1])    # ('cibo', 500)

La regola di scelta è semplice: se il risultato serve più volte, o servono indicizzazione e len(), prendete una lista. Se bisogna percorrere una volta sola e calcolare qualcosa, prendete un generatore.

Ambito delle variabili: la variabile non esce

Una domanda che mette in difficoltà anche chi programma da tempo:

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

La variabile del ciclo dentro una comprehension vive nel proprio ambito e non sovrascrive quella esterna (così avviene in Python 3; in Python 2 era diverso). In un normale ciclo for, dopo di esso x varrebbe 2. Dalla versione 3.12 (PEP 709) le comprehension vengono integrate nella funzione che le contiene e sono più veloci, ma la regola di visibilità è rimasta la stessa.

Unpacking * e ** nelle comprehension: novità di Python 3.15 (PEP 798)

L'asterisco per lo unpacking si poteva scrivere da tempo nelle chiamate di funzione e nei letterali di lista, ma dentro una comprehension era un SyntaxError. In Python 3.15 il divieto è stato rimosso: *elemento significa «inserisci qui tutti i suoi elementi». Funziona nelle liste, negli insiemi e nei generatori, anche asincroni.

# Python 3.15+
groups = [[1, 2], [3], [4, 5]]
flat = [*g for g in groups]        # [1, 2, 3, 4, 5]
uniq = {*g for g in groups}        # insieme
lazy = (*g for g in groups)        # generatore pigro

Il doppio asterisco fa lo stesso con i dizionari. È comodo per le configurazioni a strati «predefinite → file → flag»: ogni strato successivo sovrascrive le chiavi del precedente.

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

Due avvertenze dal corso. L'asterisco non si può combinare senza parentesi con un'espressione condizionale: [*w if w else 0 for w in weeks] è un errore di sintassi, la condizione va racchiusa tra parentesi. Inoltre questo codice non gira su Python 3.14 e versioni precedenti: se la libreria supporta anche le versioni vecchie, lasciate il doppio for oppure itertools.chain.from_iterable.

Generatori asincroni

Se in una funzione async def c'è un yield, si ottiene un generatore asincrono. Lo si percorre con async for, e al suo interno si può attendere la rete o il disco con await. Esistono anche le comprehension asincrone.

import asyncio

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

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

asyncio.run(main())

Errori tipici

  • Comprehension su tre piani. Due for, due if e un ternario in un'unica riga nessuno lo legge dopo una settimana. Se non entra in una riga chiara, scrivete un ciclo normale: la PEP 8 mette la leggibilità davanti alla brevità.
  • Secondo passaggio su un generatore. Un secondo sum(gen) restituisce 0, perché il generatore è già esaurito.
  • Comprehension per un effetto collaterale. [print(x) for x in xs] costruisce una lista inutile di None. Per le azioni usate un ciclo for.
  • Aspettarsi una lista da un'espressione generatrice. (x for x in xs)[0] dà TypeError: i generatori non hanno indici.

Esercizi sui generatori

  1. Scrivete un generatore fib() che produca all'infinito i numeri di Fibonacci e prendete i primi 10 con islice.
  2. In una sola riga ottenete un dizionario «parola → lunghezza» per le parole di più di tre lettere.
  3. Calcolate la somma dei quadrati dei numeri pari fino a un milione senza creare una lista.
  4. Scrivete un generatore chunks(seq, n) che produca pezzi di lista di n elementi.

Conclusioni

L'iteratore restituisce gli elementi uno alla volta tramite next(); il generatore è il modo più semplice per scriverne uno: una funzione con yield oppure un'espressione tra parentesi tonde. Le list, dict e set comprehension costruiscono una collezione completa in una riga: il filtro if va alla fine, la scelta if else all'inizio. Se bisogna risparmiare memoria o elaborare un flusso di dati una volta sola, prendete un generatore; se il risultato serve più volte, una lista. Comprehension, generatori e la nuova unpacking di Python 3.15 sono trattati in pratica nel corso, sull'esempio del tracker delle spese.

Domande frequenti

Si può usare return in un generatore?

Sì. return senza valore termina semplicemente il generatore; return con un valore lo inserisce nell'attributo value dell'eccezione StopIteration, e quel valore viene ricevuto dall'espressione yield from.

Come trasformare un generatore in lista?

Passatelo a list(): list(gen). Dopo questa operazione il generatore è esaurito, e tutti i valori finiscono in memoria.

Una list comprehension è più veloce di un ciclo con append?

Di solito sì, di poco, perché non chiama il metodo append a ogni iterazione. Però la scelta va fatta in base alla leggibilità: la differenza raramente è decisiva.

Come verificare che un oggetto sia un generatore?

Usate inspect.isgenerator(obj) per un oggetto o inspect.isgeneratorfunction(f) per una funzione; per verificare che un oggetto sia un iteratore si usa isinstance(obj, collections.abc.Iterator).

In che cosa l'espressione generatrice si differenzia dalla list comprehension?

Le parentesi quadre costruiscono subito l'intera lista in memoria; le tonde creano un generatore che calcola gli elementi su richiesta e può essere percorso una sola volta.

Impara l’argomento in video

Questo argomento è trattato in modo approfondito, con pratica, nel corso «Python 3.15: от новичка до профи».

Vai al corso