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.

Immagine generata con AI
Immagine generata con AI
Riassunto generato automaticamente tramite intelligenza artificiale:

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.

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.

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.

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.

Articolo scritto con il supporto di LLM per la formattazione e la struttura del codice.

Commenti