Introduzione
Molte API REST non restituiscono tutti i risultati in una sola risposta. Per limitare il carico del server, i dati vengono suddivisi in pagine: ogni chiamata restituisce un numero limitato di elementi e un riferimento alla pagina successiva.
In questo scenario, le comprensioni di lista sono utili quando vogliamo trasformare subito una collezione già disponibile. I generatori, invece, sono ideali per leggere le pagine una alla volta, producendo i risultati solo quando servono. In questo modo possiamo:
- ridurre l´uso della memoria;
- interrompere l´elaborazione appena troviamo ciò che cerchiamo;
- separare il recupero dei dati dalla loro trasformazione;
- scrivere codice più leggibile e riutilizzabile.
Nel tutorial costruiremo un esempio completo usando una funzione che simula un´API paginata. L´obiettivo sarà recuperare gli utenti attivi e trasformarli in un formato più semplice.
Codice completo
from collections.abc import Iterator
from typing import Any
# Dati simulati: in un´applicazione reale arriverebbero da una richiesta HTTP.
UTENTI = [
{"id": 1, "nome": "Anna", "email": "[email protected]", "attivo": True},
{"id": 2, "nome": "Luca", "email": "[email protected]", "attivo": False},
{"id": 3, "nome": "Marta", "email": "[email protected]", "attivo": True},
{"id": 4, "nome": "Paolo", "email": "[email protected]", "attivo": True},
{"id": 5, "nome": "Sara", "email": "[email protected]", "attivo": False},
]
def richiesta_api(pagina: int, dimensione: int = 2) -> dict[str, Any]:
"""
Simula la risposta di un´API paginata.
La chiave ´next_page´ vale None quando non ci sono altre pagine.
"""
inizio = (pagina - 1) * dimensione
fine = inizio + dimensione
risultati = UTENTI[inizio:fine]
pagina_successiva = pagina + 1 if fine < len(UTENTI) else None
return {
"results": risultati,
"next_page": pagina_successiva,
}
def utenti_paginati() -> Iterator[dict[str, Any]]:
"""
Generatore che recupera una pagina alla volta.
Ogni yield restituisce un singolo utente.
"""
pagina = 1
while pagina is not None:
risposta = richiesta_api(pagina)
# Il generatore sospende qui l´esecuzione dopo ogni elemento.
yield from risposta["results"]
pagina = risposta["next_page"]
def utenti_attivi(utenti: Iterator[dict[str, Any]]) -> Iterator[dict[str, Any]]:
"""
Filtra gli utenti attivi senza creare una lista intermedia.
"""
for utente in utenti:
if utente.get("attivo", False):
yield utente
def formato_contatto(utente: dict[str, Any]) -> dict[str, str]:
"""
Trasforma un utente nel formato utile al resto dell´applicazione.
"""
return {
"nome": utente["nome"],
"email": utente["email"],
}
# Il generatore resta lazy: i dati vengono elaborati durante il ciclo.
contatti_attivi = (
formato_contatto(utente)
for utente in utenti_attivi(utenti_paginati())
)
for contatto in contatti_attivi:
print(f´{contatto["nome"]}: {contatto["email"]}´)
# Una comprensione di lista materializza subito tutti i risultati.
lista_contatti = [
formato_contatto(utente)
for utente in utenti_paginati()
if utente.get("attivo", False)
]
print(lista_contatti)
# Un generatore è utile anche per fermarsi al primo risultato desiderato.
primo_contatto = next(
(
formato_contatto(utente)
for utente in utenti_paginati()
if utente["nome"].startswith("M")
),
None,
)
print(primo_contatto) Spiegazione
Simulare una risposta paginata
La funzione richiesta_api() rappresenta una chiamata HTTP. Riceve il numero della pagina e restituisce un dizionario con due informazioni:
results, cioè gli elementi presenti nella pagina corrente;next_page, cioè il numero della pagina successiva oppureNone.
In un progetto reale, questa funzione potrebbe utilizzare una libreria come requests o httpx. La struttura del generatore, però, rimarrebbe sostanzialmente uguale.
Usare yield from per appiattire le pagine
La funzione utenti_paginati() contiene un ciclo while che segue il riferimento alla pagina successiva. L´istruzione:
yield from risposta["results"] restituisce uno alla volta tutti gli elementi della pagina. Dopo ogni elemento, il generatore si mette in pausa. Alla richiesta successiva continua dal punto in cui era stato interrotto.
Questo comportamento è diverso da una funzione che costruisce una lista completa. Non è necessario caricare contemporaneamente tutte le pagine in memoria.
Filtrare con un generatore
utenti_attivi() contiene un normale ciclo con yield. Per ogni utente verifica la proprietà attivo e restituisce soltanto gli elementi validi.
Questa scelta evita di scrivere una lista intermedia come:
attivi = [utente for utente in tutti_gli_utenti if utente["attivo"]] La comprensione precedente è corretta, ma crea immediatamente una nuova lista. Il generatore è preferibile quando il numero di risultati può crescere o quando i dati devono essere consumati una sola volta.
Comprensione di lista e generator expression
Nel codice troviamo due sintassi simili:
# Comprensione di lista
contatti = [
formato_contatto(utente)
for utente in utenti
if utente["attivo"]
]
# Espressione generatrice
contatti = (
formato_contatto(utente)
for utente in utenti
if utente["attivo"]
) La prima restituisce subito un oggetto list. La seconda restituisce un generatore, che calcola ogni valore solo quando viene richiesto.
La funzione next() mostra uno dei vantaggi principali dell´approccio lazy: possiamo cercare il primo elemento che soddisfa una condizione senza processare necessariamente tutti i dati.
Best practice
- Usa generatori per dati potenzialmente numerosi: sono adatti a pagine API, file, stream e risultati prodotti progressivamente.
- Usa comprensioni di lista quando ti serve una collezione completa: ad esempio quando devi indicizzare, ordinare o attraversare più volte i risultati.
- Separa le responsabilità: una funzione dovrebbe occuparsi del recupero, un´altra del filtraggio e un´altra della trasformazione.
- Gestisci gli errori di rete: in un´API reale considera timeout, codici HTTP non validi e risposte JSON malformate.
- Evita richieste inutili: se ti serve solo un elemento, usa
next()su un generatore invece di creare una lista completa. - Ricorda che i generatori sono consumabili: dopo averli attraversati, non puoi riutilizzarli dall´inizio senza crearne uno nuovo.
- Non abusare delle espressioni troppo compatte: se una comprensione contiene molte condizioni o trasformazioni, è spesso più chiaro usare una funzione dedicata.
Quando si lavora con API reali, è inoltre opportuno aggiungere un limite massimo alle pagine elaborate, registrare eventuali errori e rispettare i limiti di frequenza imposti dal servizio.
Riepilogo
Le comprensioni di lista e i generatori risolvono problemi simili, ma hanno caratteristiche diverse. Una comprensione costruisce immediatamente una lista, mentre un generatore produce i valori in modo differito.
Per consumare un´API paginata, il pattern più efficace consiste nel creare un generatore che segue le pagine e restituisce un elemento alla volta. Su questo generatore possiamo applicare filtri e trasformazioni, mantenendo il codice modulare e limitando la memoria utilizzata.
In particolare, yield from semplifica la restituzione degli elementi contenuti in ogni pagina, mentre una generator expression permette di concatenare trasformazioni senza materializzare risultati intermedi.
