Generatori
Scopri come funzionano i generatori in Python: sfrutta la lazy evaluation e la parola chiave yield per elaborare grandi flussi di dati in streaming, creare pipeline efficienti e ottimizzare l'uso della memoria RAM.
I generatori Python utilizzano la valutazione pigra, producendo valori uno alla volta su richiesta mediante la parola chiave `yield`. Questa capacità consente loro di mantenere lo stato interno tra le chiamate, riprendendo l'esecuzione da dove era stata interrotta.
Introduzione
Un generatore (generator) in Python è una funzione o un'espressione che restituisce uno speciale tipo di iteratore chiamato generator iterator. A differenza delle normali strutture dati, un generatore non calcola e non conserva tutti i suoi valori in memoria in anticipo: genera ogni valore solo quando viene richiesto. Questo comportamento viene definito lazy evaluation (valutazione pigra).
Generatori e iteratori
Un generatore è un particolare tipo di iteratore, cioè un oggetto che permette di attraversare una sequenza di valori uno alla volta mediante il protocollo di iterazione (i metodi __iter__() e __next__()). La differenza sostanziale è che un iteratore standard, come una lista, contiene già tutti i suoi elementi in memoria:
numeri = [1, 2, 3, 4, 5]
Un generatore, invece, produce gli elementi solo quando servono:
def numeri():
yield 1
yield 2
yield 3
In questo caso i valori non vengono creati tutti contemporaneamente, ma vengono prodotti progressivamente durante l'iterazione. Il generatore mantiene il proprio stato interno, rendendo possibile la ripresa dell'esecuzione dopo ogni valore prodotto.
La parola chiave yield
Una funzione generatrice si comporta come una normale funzione, ma contiene una o più espressioni yield. La parola chiave yield:
- restituisce temporaneamente un valore;
- sospende l'esecuzione della funzione;
- conserva lo stato corrente (variabili locali, punto di esecuzione, gestione delle eccezioni);
- permette di riprendere l'esecuzione dal punto in cui era stata interrotta.
Esempio base:
def conta():
yield 1
yield 2
yield 3
Creiamo il generatore:
generatore = conta()
A questo punto la funzione non è ancora stata eseguita: è stato creato un oggetto generatore. Possiamo ottenere i valori uno alla volta con la funzione next():
print(next(generatore))
print(next(generatore))
print(next(generatore))
Quando non ci sono più valori disponibili, Python solleva l'eccezione StopIteration. Per questo motivo, è quasi sempre preferibile iterare con un ciclo for, che gestisce automaticamente l'eccezione.
Sospensione e ripresa dello stato
La caratteristica più importante dei generatori è che mantengono il proprio stato interno tra una chiamata e l'altra. Quando Python incontra uno yield:
- restituisce il valore corrente;
- mette in pausa l'esecuzione;
- salva tutte le informazioni necessarie per riprendere successivamente.
Queste informazioni includono il punto di esecuzione raggiunto, le variabili locali e lo stato delle istruzioni try. Quando il generatore viene richiamato nuovamente, riparte esattamente dal punto della sospensione.
Esempio con loop e variabile di stato:
def contatore():
numero = 0
while numero < 3:
yield numero
numero += 1
Utilizzo:
for valore in contatore():
print(valore)
Output:
0
1
2
La variabile numero mantiene il proprio valore tra un yield e il successivo, dimostrando come lo stato venga preservato.
Metodi avanzati dei generatori
Oltre a next(), l'oggetto generatore espone un piccolo insieme di metodi che permettono una comunicazione bidirezionale con il chiamante, non solo la produzione passiva di valori.
Send: inviare un valore al generatore
Il metodo send(valore) riprende l'esecuzione del generatore e "inietta" un valore nel punto esatto in cui l'espressione yield era sospesa. Quel valore diventa il risultato dell'espressione yield stessa.
def contatore_incrementale():
totale = 0
while True:
incremento = yield totale
if incremento is not None:
totale += incremento
Utilizzo:
gen = contatore_incrementale()
print(next(gen))
print(gen.send(5))
print(gen.send(10))
Il primo next() è sempre necessario per portare il generatore al primo punto di sospensione, prima di poter usare send().
Throw: sollevare un'eccezione all'interno del generatore
Il metodo throw(eccezione) permette di sollevare un'eccezione esattamente nel punto in cui il generatore è sospeso, così che possa gestirla con un blocco try/except interno.
def generatore_robusto():
try:
while True:
yield "valore normale"
except ValueError:
yield "eccezione gestita"
Close: terminare un generatore
Il metodo close() interrompe definitivamente il generatore sollevando internamente GeneratorExit. È utile per liberare risorse quando un generatore non deve più essere consumato, e viene spesso invocato automaticamente dal garbage collector o dai blocchi finally.
yield, send(), throw() e close() costituisce il protocollo completo su cui, storicamente, sono state costruite le prime forme di coroutine in Python, prima dell'introduzione della sintassi async/await. Generatori e memoria
Il vantaggio principale dei generatori è il basso utilizzo di memoria. Una lista crea e conserva tutti gli elementi contemporaneamente:
numeri = [x for x in range(1000000)]
Tutti i valori vengono memorizzati in un'unica struttura. Un generatore, invece, produce gli elementi solo quando richiesti:
numeri = (x for x in range(1000000))
In questo secondo caso Python mantiene solo le informazioni necessarie per generare il valore successivo. Questo rende i generatori particolarmente utili per:
- grandi quantità di dati;
- lettura di file molto grandi;
- elaborazioni in streaming;
- sequenze potenzialmente infinite.
Verificare la differenza con sys.getsizeof
La differenza di ingombro in memoria non è solo teorica: può essere misurata direttamente con il modulo sys.
import sys
lista = [x for x in range(10000)]
generatore = (x for x in range(10000))
print(sys.getsizeof(lista))
print(sys.getsizeof(generatore))
L'oggetto generatore occupa uno spazio costante e trascurabile, indipendentemente dal numero di elementi che potenzialmente può produrre, mentre la lista cresce proporzionalmente al numero di elementi contenuti.
sys.getsizeof() dipendono dalla versione di Python e dall'architettura della macchina: eseguire il codice localmente per ottenere i numeri reali prima di citarli in un contesto pubblicato. Generator expression
Oltre alle funzioni con yield, Python permette di creare generatori tramite una sintassi simile alle list comprehension.
Lista (valutazione immediata):
quadrati = [x * x for x in range(5)]
Generatore (valutazione pigra):
quadrati = (x * x for x in range(5))
La differenza principale è che [] crea immediatamente una lista, mentre () crea un generatore che calcola i valori quando servono.
Generatori infiniti
Poiché i generatori producono valori su richiesta, possono rappresentare anche sequenze senza una fine definita.
Esempio di sequenza infinita:
def numeri_infiniti():
numero = 0
while True:
yield numero
numero += 1
Questo generatore può continuare indefinitamente senza occupare memoria proporzionale al numero di valori prodotti. Per interrompere l'iterazione, si può utilizzare un break o un'istruzione condizionale all'esterno.
Costruire pipeline di elaborazione
I generatori si prestano naturalmente alla creazione di pipeline di elaborazione dati, dove ogni stadio trasforma o filtra il flusso in arrivo. Ad esempio:
def lettore(file_path):
with open(file_path) as f:
for riga in f:
yield riga.strip()
def filtra_righe(iterable, parola_chiave):
for riga in iterable:
if parola_chiave in riga:
yield riga
def trasforma_maiuscolo(iterable):
for riga in iterable:
yield riga.upper()
Utilizzo:
flusso = lettore("dati.txt")
filtrato = filtra_righe(flusso, "errore")
maiuscolo = trasforma_maiuscolo(filtrato)
for riga in maiuscolo:
print(riga)
Ogni passaggio produce un generatore, e i dati fluiscono senza mai caricare l'intero file in memoria.
Yield from per la composizione
Python permette di delegare la generazione di valori a un sub-generatore o a qualsiasi iterabile tramite l'istruzione yield from. Questo semplifica la composizione di generatori più complessi.
Esempio:
def sottogeneratore():
yield 10
yield 20
def generatore_principale():
yield 1
yield from sottogeneratore()
yield 30
Utilizzo:
for v in generatore_principale():
print(v)
yield from si occupa automaticamente di iterare il sub-generatore e di propagare eventuali eccezioni, rendendo il codice più pulito e manutenibile.
yield from è disponibile a partire da Python 3.3 (introdotta dalla PEP 380). Riferimento di versione da confermare prima della pubblicazione. Generatori come context manager
Uno degli usi più professionali dei generatori sfrutta esattamente la loro capacità di sospendersi e riprendersi: il decoratore @contextmanager del modulo contextlib permette di trasformare un generatore in un context manager utilizzabile con l'istruzione with.
from contextlib import contextmanager
@contextmanager
def apri_risorsa(nome):
print("apertura di", nome)
try:
yield nome
finally:
print("chiusura di", nome)
Utilizzo:
with apri_risorsa("connessione_db") as risorsa:
print("uso di", risorsa)
Il codice prima dello yield viene eseguito all'ingresso del blocco with, mentre il codice nel blocco finally viene eseguito automaticamente all'uscita, anche in presenza di eccezioni. Questo pattern è largamente usato per gestire in modo sicuro file, connessioni di rete, lock e transazioni.
Integrazione con itertools
Il modulo itertools fornisce una serie di strumenti progettati per lavorare con iteratori e generatori, permettendo di costruire cicli di elaborazione efficienti.
Islice: prelevare una porzione di un generatore
from itertools import islice
def numeri_infiniti():
numero = 0
while True:
yield numero
numero += 1
primi_cinque = islice(numeri_infiniti(), 5)
print(list(primi_cinque))
Cycle: ripetere una sequenza all'infinito
from itertools import cycle, islice
colori = cycle(["rosso", "verde", "blu"])
print(list(islice(colori, 7)))
Tee: duplicare un iteratore in più flussi indipendenti
from itertools import tee
originale = (x for x in range(5))
copia_a, copia_b = tee(originale, 2)
print(list(copia_a))
print(list(copia_b))
Queste funzioni si combinano perfettamente con i generatori per creare trasformazioni dati complesse con un impatto minimo sulla memoria.
Generatori e programmazione asincrona
Sebbene non siano direttamente utilizzati per la concorrenza, i generatori hanno ispirato il modello delle coroutine in Python, evolutosi poi nelle parole chiave async/await. La capacità di sospendere e riprendere l'esecuzione è alla base sia dei generatori che delle funzioni asincrone.
Python offre anche i cosiddetti async generator, che combinano async def con yield per produrre valori in un contesto asincrono, consumabili con async for.
async def genera_dati_asincroni():
for i in range(3):
yield i
Utilizzo tipico all'interno di codice asincrono:
async def consuma():
async for valore in genera_dati_asincroni():
print(valore)
Comprendere i generatori aiuta quindi ad affrontare con maggiore consapevolezza anche temi più avanzati come la programmazione asincrona e il coordinamento di flussi di dati in tempo reale.
Un'insidia comune, l'esaurimento del generatore
Un generatore può essere iterato una sola volta. Una volta consumati tutti i valori, o interrotta l'iterazione, il generatore è esaurito e non può essere "riavvolto".
quadrati = (x * x for x in range(5))
print(list(quadrati))
print(list(quadrati))
Il secondo list(quadrati) restituisce una sequenza vuota, perché il generatore ha già prodotto tutti i suoi valori. Per iterare più volte sugli stessi dati è necessario ricreare il generatore da capo, oppure, se serve conservare i valori per riutilizzarli, valutare l'uso di una struttura dati tradizionale come una lista.
Confronto tra funzioni normali e generatori
| Funzione normale | Generatore |
|---|---|
Restituisce un valore con return | Produce valori con yield |
Termina completamente dopo il return | Sospende e riprende l'esecuzione |
| Perde lo stato locale dopo la chiamata | Mantiene lo stato tra le chiamate |
| Calcola tutti i risultati subito | Calcola i risultati quando servono |
| Può richiedere molta memoria | Usa poca memoria |
| Può essere richiamata ripetutamente | Si esaurisce dopo una sola iterazione completa |
Quando usare i generatori
I generatori sono particolarmente indicati quando:
- si devono elaborare grandi quantità di dati;
- non è necessario avere tutti i valori contemporaneamente;
- si vuole creare una pipeline di elaborazione efficiente;
- si lavora con dati prodotti progressivamente (streaming);
- si desidera scrivere codice più leggibile e modulare per sequenze complesse;
- si deve gestire in modo sicuro l'apertura e la chiusura di una risorsa, tramite il pattern context manager.
Grazie alla loro capacità di produrre valori su richiesta mantenendo lo stato interno, i generatori rappresentano uno degli strumenti più potenti di Python per scrivere codice efficiente, orientato all'elaborazione sequenziale e facilmente manutenibile.