Generatori e valutazione differita: flussi funzionali efficienti in Python

by theArchitect
SHARE
Generatori e valutazione differita: flussi funzionali efficienti in Python
© Guida-HTML5.it

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_righe o seleziona_righe rendono 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 with garantisce 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

SHARE