Salta al contenuto principale

Generator e yield in Python: pipeline efficienti in memoria

·8 minuti
Indice dei contenuti
Un generator è una sequenza lazy. Produce valori uno alla volta su richiesta invece di materializzare tutto in memoria. Per dataset di grandi dimensioni, API di streaming e pipeline di elaborazione dati, i generator sono il default corretto, non un’ottimizzazione da applicare dopo.

I generator Python sono una delle funzionalita più praticamente utili del linguaggio e una delle più sottoutilizzate dagli ingegneri che hanno imparato Python dai tutorial web. Questo articolo va oltre il “usa yield invece di return” e copre il quadro completo: il protocollo iteratore, le caratteristiche di memoria, yield from, le pipeline di generator e casi d’uso reali di streaming.

yield di base: Generator vs funzioni normali
#

Quando una funzione contiene un’istruzione yield, chiamarla non esegue il corpo della funzione. Restituisce invece un oggetto generator. Il corpo si esegue in modo lazy, mettendosi in pausa a ogni yield e riprendendo alla chiamata successiva di __next__.

basic_generator.py
def count_up_to(maximum: int):
    n = 1
    while n <= maximum:
        yield n
        n += 1

# Chiamare count_up_to() restituisce un oggetto generator. Niente viene eseguito ancora.
gen = count_up_to(3)
print(type(gen))   # <class 'generator'>

# Ogni chiamata a next() riprende l'esecuzione fino al prossimo yield.
print(next(gen))   # 1
print(next(gen))   # 2
print(next(gen))   # 3
# next(gen) solleva StopIteration qui

Confronta con una funzione normale che costruisce una lista:

list_vs_generator.py
def count_up_to_list(maximum: int) -> list[int]:
    result = []
    n = 1
    while n <= maximum:
        result.append(n)
        n += 1
    return result

# Questo alloca memoria per tutti i 10_000_000 interi in una volta.
numbers = count_up_to_list(10_000_000)

# Questo alloca memoria per un intero alla volta.
for n in count_up_to(10_000_000):
    process(n)

Il protocollo __next__ e StopIteration
#

Qualsiasi oggetto con i metodi __iter__ e __next__ è un iteratore. I generator implementano automaticamente questo protocollo. Capirlo è importante quando si scrivono oggetti iterabili personalizzati o si integra con framework che consumano iteratori.

iterator_protocol.py
class CountUpTo:
    """Un iteratore personalizzato che fa cio che count_up_to() fa, senza yield."""

    def __init__(self, maximum: int) -> None:
        self.maximum = maximum
        self.current = 1

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self.current > self.maximum:
            raise StopIteration
        value = self.current
        self.current += 1
        return value

# Un ciclo for è zucchero sintattico sul protocollo iteratore:
# chiama __iter__ una volta, poi chiama __next__ fino a StopIteration.
for n in CountUpTo(3):
    print(n)  # 1, 2, 3

I generator producono lo stesso comportamento con molto meno codice. StopIteration viene sollevato automaticamente quando la funzione ritorna.

Confronto di memoria: file con readlines() vs generator
#

La differenza di memoria tra approcci basati su liste e su generator diventa critica quando si elaborano file di grandi dimensioni.

memory_comparison.py
import tracemalloc

def read_file_list(path: str) -> list[str]:
    with open(path) as f:
        return f.readlines()  # Carica tutte le righe in memoria in una volta

def read_file_generator(path: str):
    with open(path) as f:
        yield from f  # Produce una riga alla volta

# Benchmark su un file con 1M di righe
tracemalloc.start()
lines = read_file_list("big_file.txt")
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"readlines: peak={peak / 1_048_576:.1f} MB")  # ~500 MB per un file da 500 MB

tracemalloc.start()
for line in read_file_generator("big_file.txt"):
    _ = line.strip()
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"generator: peak={peak / 1_048_576:.1f} MB")  # ~0.1 MB indipendentemente dalla dimensione del file

La versione con generator ha un utilizzo costante della memoria perché solo una riga è in memoria alla volta.

Espressioni generator vs list comprehension
#

Le espressioni generator usano le parentesi tonde e sono lazy. Le list comprehension usano le parentesi quadre e sono eager.

comprehension_vs_expression.py
import sys

# List comprehension: alloca 8 MB per 1M di interi
squares_list = [x * 2 for x in range(1_000_000)]
print(sys.getsizeof(squares_list))  # ~8_697_464 byte (~8.3 MB)

# Espressione generator: alloca ~200 byte indipendentemente dalla dimensione del range
squares_gen = (x * 2 for x in range(1_000_000))
print(sys.getsizeof(squares_gen))   # ~200 byte

# Usa un'espressione generator quando il risultato viene consumato una volta sola.
# Usa una lista quando hai bisogno di accesso casuale, len(), o iterazioni multiple.
total = sum(x * 2 for x in range(1_000_000))  # sum() accetta qualsiasi iterabile
Suggerimento

Quando si passa un’espressione generator come unico argomento a una funzione come sum(), any(), all() o max(), si possono omettere le parentesi interne: sum(x*2 for x in range(n)) è Python idiomatico.

yield from per la delega di generator
#

yield from delega a un sub-generator (o qualsiasi iterabile), inoltrandone valori, eccezioni e il valore di ritorno in modo trasparente. È il modo corretto per comporre i generator.

yield_from.py
import os
from collections.abc import Generator

def walk_files(root: str) -> Generator[str, None, None]:
    """Produce ricorsivamente tutti i path dei file sotto root."""
    for entry in os.scandir(root):
        if entry.is_dir(follow_symlinks=False):
            yield from walk_files(entry.path)  # Delega alla chiamata ricorsiva
        else:
            yield entry.path

# Senza yield from, servirebbe:
# for path in walk_files(entry.path):
#     yield path
# yield from non è solo zucchero sintattico -- propaga correttamente anche .send() e .throw()

for path in walk_files("/var/log"):
    print(path)

Pipeline di generator: comporre stadi di elaborazione
#

I generator si compongono naturalmente in pipeline. Ogni stadio riceve un iterabile, lo elabora in modo lazy e produce i risultati. L’intera pipeline consuma O(1) memoria indipendentemente dalla dimensione dei dati.

pipeline.py
from collections.abc import Generator, Iterable
import csv

def read_lines(path: str) -> Generator[str, None, None]:
    with open(path) as f:
        yield from f

def parse_csv(lines: Iterable[str]) -> Generator[dict, None, None]:
    reader = csv.DictReader(lines)
    yield from reader

def filter_active(rows: Iterable[dict]) -> Generator[dict, None, None]:
    for row in rows:
        if row.get("status") == "active":
            yield row

def normalize(rows: Iterable[dict]) -> Generator[dict, None, None]:
    for row in rows:
        yield {
            "id":    int(row["id"]),
            "email": row["email"].lower().strip(),
            "name":  row["name"].strip(),
        }

def process_file(path: str) -> Generator[dict, None, None]:
    lines   = read_lines(path)
    parsed  = parse_csv(lines)
    active  = filter_active(parsed)
    yield from normalize(active)

# L'intera pipeline è lazy. Nessun dato viene letto finché non si itera.
for record in process_file("users.csv"):
    save_to_database(record)
Nota

Ogni generator nella pipeline mantiene un elemento in memoria alla volta. Un file CSV da 10 GB passa attraverso questa pipeline con approssimativamente lo stesso footprint di memoria di un file da 10 KB.

Il metodo send(): generator come coroutine
#

I generator supportano la comunicazione bidirezionale tramite .send(value). Prima che async/await fosse introdotto in Python 3.5, questo era il meccanismo principale per le coroutine.

send_method.py
from collections.abc import Generator

def accumulator() -> Generator[float, float, str]:
    """Riceve valori via send(), produce la media progressiva."""
    total = 0.0
    count = 0
    value = yield 0.0  # Yield iniziale per inizializzare il generator
    while value is not None:
        total += value
        count += 1
        value = yield total / count
    return f"Media finale su {count} elementi"

gen = accumulator()
next(gen)            # Inizializza il generator (avanza fino al primo yield)
print(gen.send(10))  # 10.0
print(gen.send(20))  # 15.0
print(gen.send(30))  # 20.0
try:
    gen.send(None)   # Segnala la fine dell'input
except StopIteration as e:
    print(e.value)   # "Media finale su 3 elementi"
Nota

In Python moderno (3.5+), async def e await hanno sostituito le coroutine basate su generator per la concorrenza. Usa send() solo quando hai specificamente bisogno di una coroutine di elaborazione dati stateful che produce risultati intermedi, non per la concorrenza I/O.

Sequenze infinite
#

I generator possono produrre sequenze infinite perché non materializzano mai più di un valore alla volta.

infinite_sequences.py
import itertools
from collections.abc import Generator

# itertools.count: sequenza aritmetica infinita
for n in itertools.islice(itertools.count(start=0, step=2), 5):
    print(n)  # 0, 2, 4, 6, 8

# itertools.cycle: ripete una sequenza finita all'infinito
for color in itertools.islice(itertools.cycle(["red", "green", "blue"]), 7):
    print(color)

# Generator di Fibonacci infinito personalizzato
def fibonacci() -> Generator[int, None, None]:
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Prendi i primi 10 numeri di Fibonacci
first_ten = list(itertools.islice(fibonacci(), 10))
print(first_ten)  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Caso d’uso reale: export streaming di grandi API
#

Il caso d’uso di produzione canonico: un endpoint API che esporta dati troppo grandi per essere bufferizzati in memoria.

streaming_export.py
import csv
import io
from collections.abc import Generator
from django.http import StreamingHttpResponse
from django.db.models import QuerySet

def iter_csv_rows(queryset: QuerySet) -> Generator[bytes, None, None]:
    """Produce righe CSV da un queryset Django senza caricare tutto in memoria."""
    buffer = io.StringIO()
    writer = csv.writer(buffer)

    # Riga di intestazione
    writer.writerow(["id", "email", "created_at"])
    buffer.seek(0)
    yield buffer.read().encode("utf-8")
    buffer.truncate(0)
    buffer.seek(0)

    # Righe dati: QuerySet.iterator() usa un server-side cursor
    for user in queryset.only("id", "email", "created_at").iterator(chunk_size=2000):
        writer.writerow([user.id, user.email, user.created_at.isoformat()])
        buffer.seek(0)
        yield buffer.read().encode("utf-8")
        buffer.truncate(0)
        buffer.seek(0)

def export_users_csv(request):
    queryset = User.objects.filter(active=True).order_by("id")
    response = StreamingHttpResponse(
        iter_csv_rows(queryset),
        content_type="text/csv",
    )
    response["Content-Disposition"] = 'attachment; filename="users.csv"'
    return response

La StreamingHttpResponse di Django invia dati al client man mano che vengono prodotti. Un export da 5 milioni di righe si completa senza mai tenere in memoria piu di chunk_size righe.

Errori comuni
#

Iterare un generator due volte

Un generator è esaurito dopo un singolo passaggio. Se si itera su di esso una seconda volta, non si ottiene nulla. Se si ha bisogno di iterare più volte, convertirlo prima in una lista: items = list(my_generator()). Essere consapevoli del trade-off di memoria.

gen = (x * 2 for x in range(5))
print(list(gen))  # [0, 2, 4, 6, 8]
print(list(gen))  # []  -- gia esaurito
Non gestire StopIteration nei cicli manuali con next()

Chiamare next() su un generator esaurito solleva StopIteration. Usare la forma a due argomenti next(gen, default) per fornire un valore sentinella, o iterare con un ciclo for che gestisce l’eccezione automaticamente.

gen = count_up_to(2)
print(next(gen, None))  # 1
print(next(gen, None))  # 2
print(next(gen, None))  # None -- nessuna eccezione
Usare una lista dove un generator è sufficiente
Costruire una lista solo per iterarci sopra una volta è uno spreco. [process(x) for x in items] quando la lista non viene mai più usata dovrebbe essere (process(x) for x in items) passata alla funzione consumatrice, o un ciclo for con effetti collaterali. L’allocazione della lista è overhead puro.
Dimenticare di inizializzare i generator basati su send()
Prima di chiamare .send(value) su un generator, bisogna avanzarlo fino al primo yield con next(gen) o gen.send(None). Chiamare .send(valore-non-None) su un generator appena creato solleva TypeError. Un pattern comune è un decoratore @coroutine che inizializza automaticamente il generator.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati