Introduzione
La programmazione funzionale non riguarda soltanto map, filter o le funzioni pure. Un altro concetto molto utile è la valutazione differita, cioè la possibilità di calcolare un risultato solo quando serve davvero. In Python questo approccio è rappresentato soprattutto dai generatori.
Un generatore produce gli elementi uno alla volta, invece di costruire immediatamente l’intera collezione in memoria. Questa caratteristica è preziosa quando si elaborano file grandi, flussi di dati, risultati di query o sequenze potenzialmente infinite.
In un programma tradizionale potremmo creare una lista con migliaia o milioni di elementi, anche se poi ne utilizzeremo soltanto alcuni. Con un generatore, invece, possiamo descrivere una trasformazione funzionale e lasciare che Python calcoli ogni valore soltanto nel momento in cui viene richiesto.
Il meccanismo principale è la parola chiave yield. Una funzione che contiene yield non restituisce subito un valore finale: restituisce un oggetto generatore, che può essere attraversato con un ciclo for o con la funzione next().
Codice completo
from collections.abc import Iterable, Iterator
from typing import Callable
def leggi_righe(percorso: str) -> Iterator[str]:
"""Produce una riga alla volta da un file di testo."""
with open(percorso, encoding="utf-8") as file:
for riga in file:
yield riga.rstrip("n")
def seleziona_righe(
righe: Iterable[str],
predicato: Callable[[str], bool]
) -> Iterator[str]:
"""Restituisce soltanto le righe che rispettano una condizione."""
for riga in righe:
if predicato(riga):
yield riga
def normalizza_righe(righe: Iterable[str]) -> Iterator[str]:
"""Rimuove spazi superflui e converte il testo in minuscolo."""
for riga in righe:
testo = riga.strip().lower()
if testo:
yield testo
def estrai_email(righe: Iterable[str]) -> Iterator[str]:
"""Estrae le righe che contengono un indirizzo email."""
for riga in righe:
if "@" in riga:
yield riga
def limita(iterabile: Iterable[str], massimo: int) -> Iterator[str]:
"""Produce al massimo ´massimo´ elementi."""
for indice, elemento in enumerate(iterabile):
if indice >= massimo:
break
yield elemento
def email_da_log(percorso: str, massimo: int) -> Iterator[str]:
"""Costruisce una pipeline funzionale e valutata in modo differito."""
righe = leggi_righe(percorso)
righe_utili = seleziona_righe(righe, lambda riga: "INFO" in riga)
righe_normalizzate = normalizza_righe(righe_utili)
email = estrai_email(righe_normalizzate)
return limita(email, massimo)
# Il file viene letto soltanto durante l´iterazione.
for indirizzo in email_da_log("app.log", massimo=5):
print(indirizzo)
Spiegazione
La parola chiave yield
Quando Python esegue una funzione con yield, sospende l’esecuzione nel punto in cui il valore viene prodotto. Alla richiesta del valore successivo, l’esecuzione riparte da quel punto. In questo modo il generatore conserva il proprio stato senza dover creare una lista completa.
Consideriamo questo esempio:
def numeri_pari(limite: int):
for numero in range(limite):
if numero % 2 == 0:
yield numero
pari = numeri_pari(10)
print(next(pari)) # 0
print(next(pari)) # 2
print(next(pari)) # 4
La chiamata a numeri_pari(10) non calcola immediatamente tutti i numeri pari. Il calcolo avviene progressivamente quando usiamo next() o quando percorriamo il generatore con for.
Pipeline lazy
Nel codice completo ogni funzione riceve un iterabile e restituisce un altro iteratore. La pipeline legge una riga, la filtra, la normalizza e verifica la presenza di un indirizzo email. Non è necessario caricare tutto il file in memoria.
È importante osservare che una pipeline di generatori non viene eseguita quando viene costruita. L’esecuzione inizia soltanto con questa istruzione:
for indirizzo in email_da_log("app.log", massimo=5):
print(indirizzo)
Inoltre, la funzione limita interrompe il flusso dopo cinque risultati. Se il file contiene milioni di righe, il programma potrebbe leggere soltanto la parte necessaria a trovare i primi cinque elementi validi.
Generatori e memoria
La differenza tra lista e generatore è evidente nel seguente confronto:
# Crea subito tutti i valori in memoria
quadrati_lista = [numero * numero for numero in range(10_000_000)]
# Produce un valore alla volta
quadrati_generatore = (
numero * numero for numero in range(10_000_000)
)
La lista occupa memoria in proporzione al numero di elementi. Il generatore, invece, mantiene soltanto le informazioni necessarie per calcolare il prossimo valore. Questa scelta non rende automaticamente ogni programma più veloce, ma riduce spesso il consumo di memoria e permette di trattare flussi molto grandi.
Best practice
- Usa generatori per dati grandi o consumati una sola volta. Sono adatti a file, stream e sequenze lunghe.
- Preferisci nomi descrittivi. Funzioni come
normalizza_righeoseleziona_righerendono leggibile la pipeline. - Progetta funzioni piccole. Ogni generatore dovrebbe svolgere una sola trasformazione, così può essere testato e riutilizzato.
- Ricorda che un generatore è consumabile. Dopo averlo attraversato una volta, non puoi riutilizzarlo dall’inizio senza crearne uno nuovo.
- Non convertire inutilmente in lista.
list(generatore)elimina il vantaggio della valutazione differita e carica tutti i risultati in memoria. - Usa le annotazioni di tipo.
Iterator[str]comunica chiaramente che la funzione produce stringhe progressivamente. - Gestisci correttamente le risorse. Un blocco
withgarantisce la chiusura del file anche in caso di errore.
Un generatore può essere iterato una sola volta:
valori = (numero * 2 for numero in range(3))
print(list(valori)) # [0, 2, 4]
print(list(valori)) # []
Se servono più attraversamenti, è spesso meglio definire una funzione che crei un nuovo generatore ogni volta, invece di conservare direttamente l’oggetto già consumato.
Riepilogo
I generatori permettono di applicare la programmazione funzionale a flussi di dati con un uso efficiente della memoria. Grazie a yield e alla valutazione differita, le trasformazioni vengono eseguite soltanto quando un risultato è richiesto.
Combinando generatori indipendenti è possibile costruire pipeline leggibili: una funzione legge i dati, un’altra li filtra, una terza li normalizza e una quarta limita o aggrega il risultato. Questo stile favorisce codice modulare, testabile e adatto a input di grandi dimensioni.
Approfondisci con risorse ufficiali
- Python Functional Programming HOWTO: introduzione ufficiale agli strumenti funzionali di Python.
- Documentazione sulle yield expressions: sintassi e comportamento di
yield. - Funzioni integrate iter e next: gestione degli iteratori e accesso progressivo agli elementi.
- Collections ABC: interfacce e annotazioni per iteratori e iterabili.
