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__.
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 quiConfronta con una funzione normale che costruisce una lista:
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.
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, 3I 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.
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 fileLa 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.
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 iterabileQuando 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.
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.
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)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.
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"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.
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.
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 responseLa 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 esauritoNon 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 eccezioneUsare una lista dove un generator è sufficiente
[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()
.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.