36. Generatori e iterazione lazy
Nel Capitolo 35 abbiamo imparato come funziona l’iterazione in Python tramite iterabili e iteratori. Abbiamo visto che gli iteratori restituiscono valori uno alla volta quando vengono richiesti, il che permette a Python di elaborare sequenze senza caricare tutto in memoria in una sola volta. Ora esploreremo i generatori(generator), il modo più elegante e pratico di Python per creare iteratori.
I generatori sono funzioni(function) che possono mettere in pausa e riprendere la loro esecuzione, producendo valori uno alla volta man mano che vengono richiesti, invece di calcolare tutti i valori in anticipo e memorizzarli in memoria. Questo approccio—chiamato valutazione lazy(lazy evaluation)—significa che i valori vengono generati solo quando servono, rendendolo una delle funzionalità più potenti di Python per scrivere codice efficiente in memoria.
36.1) Cosa sono i generatori e perché sono utili
36.1.1) Il problema di creare grandi liste
Iniziamo comprendendo il problema che i generatori risolvono. Supponiamo che tu debba elaborare una sequenza di un milione di numeri. Ecco l’approccio tradizionale usando una lista(list):
# Creazione di una lista di un milione di quadrati
def get_squares_list(n):
"""Restituisce una lista di quadrati da 0 a n-1."""
squares = []
for i in range(n):
squares.append(i * i)
return squares
# Questo crea una lista con 1.000.000 di numeri in memoria
numbers = get_squares_list(1_000_000)
print(f"First five squares: {numbers[:5]}") # Output: First five squares: [0, 1, 4, 9, 16]Questo approccio ha un problema significativo: crea e memorizza tutti e un milione di numeri in memoria in una sola volta, anche se devi elaborarli solo uno alla volta. Con dataset più grandi o calcoli più complessi, questo può consumare enormi quantità di memoria o addirittura mandare in crash il programma.
36.1.2) Introduzione ai generatori: calcolare i valori su richiesta
Un generatore(generator) è un tipo speciale di funzione(function) che produce valori uno alla volta, solo quando richiesti. Invece di costruire e restituire una lista(list) completa, un generatore calcola ciascun valore quando serve e “ricorda” dove era arrivato tra una chiamata e l’altra.
Ecco la stessa funzionalità implementata come generatore:
# Creazione di un generatore di quadrati
def get_squares_generator(n):
"""Genera quadrati da 0 a n-1, uno alla volta."""
for i in range(n):
yield i * i # yield mette in pausa la funzione e restituisce un valore
# Questo crea un oggetto generatore, non una lista
squares_gen = get_squares_generator(1_000_000)
print(squares_gen) # Output: <generator object get_squares_generator at 0x...>
# Ottieni valori uno alla volta
print(next(squares_gen)) # Output: 0
print(next(squares_gen)) # Output: 1
print(next(squares_gen)) # Output: 4Il generatore non calcola tutti e un milione di quadrati in anticipo. Invece, calcola ciascun quadrato solo quando chiami next() su di esso. Tra una chiamata e l’altra, il generatore “mette in pausa” e ricorda il suo stato (il valore corrente di i).
36.1.3) Efficienza di memoria: il vantaggio chiave
La differenza di memoria tra liste e generatori diventa drastica con grandi dataset. Confrontiamo:
import sys
# Approccio con lista: memorizza tutti i valori
def squares_list(n):
return [i * i for i in range(n)]
# Approccio con generatore: calcola i valori su richiesta
def squares_generator(n):
for i in range(n):
yield i * i
# Confronta l'uso di memoria per 100.000 numeri
list_result = squares_list(100_000)
gen_result = squares_generator(100_000)
print(f"List size in memory: {sys.getsizeof(list_result):,} bytes")
# Output: List size in memory: 800,984 bytes (actual size may vary)
print(f"Generator size in memory: {sys.getsizeof(gen_result)} bytes")
# Output: Generator size in memory: 200 bytes (actual size may vary)La lista consuma oltre 800 KB di memoria, mentre il generatore usa solo 200 byte—indipendentemente da quanti valori produrrà alla fine. Il generatore memorizza solo lo stato della funzione (il valore corrente di i e dove riprendere), non la sequenza effettiva di valori.
36.1.4) Quando i generatori sono utili
I generatori eccellono in diversi scenari comuni:
Elaborazione di file di grandi dimensioni:
def read_large_file(filename):
"""Genera le righe di un file una alla volta."""
with open(filename, 'r') as file:
for line in file:
yield line.strip()
# Elabora un enorme file di log senza caricarlo tutto in memoria
for line in read_large_file('huge_log.txt'):
if 'ERROR' in line:
print(line)Sequenze infinite:
def fibonacci():
"""Genera numeri di Fibonacci indefinitamente."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Genera numeri di Fibonacci per sempre (o finché smetti di richiederli)
fib = fibonacci()
print([next(fib) for _ in range(10)])
# Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]36.1.5) I generatori sono iteratori
Come abbiamo imparato nel Capitolo 35, i generatori sono in realtà un tipo speciale di iteratore(iterator). Implementano automaticamente il protocollo degli iteratori (__iter__() e __next__()), motivo per cui funzionano perfettamente con i cicli(loop) for:
def countdown(n):
"""Genera un conto alla rovescia da n a 1."""
while n > 0:
yield n
n -= 1
# I generatori funzionano direttamente nei cicli for
for num in countdown(5):
print(num)
# Output:
# 5
# 4
# 3
# 2
# 1Quando usi un generatore in un ciclo(loop) for, Python chiama automaticamente next() ripetutamente finché il generatore non è esaurito (solleva StopIteration).
36.2) Creare funzioni generatore con yield
36.2.1) L’istruzione yield: mettere in pausa e riprendere
L’istruzione yield è ciò che rende una funzione(function) un generatore(generator). Quando Python incontra yield, fa qualcosa di speciale: invece di restituire un valore e terminare la funzione, mette in pausa la funzione e restituisce il valore. La volta successiva che chiami next() sul generatore, l’esecuzione riprende subito dopo l’istruzione yield.
Ecco un semplice esempio che dimostra questo comportamento di pausa-e-ripresa:
def simple_generator():
"""Dimostra come yield mette in pausa l'esecuzione."""
print("Starting generator")
yield 1
print("Resuming after first yield")
yield 2
print("Resuming after second yield")
yield 3
print("Generator finished")
gen = simple_generator()
print("Created generator")
# Output:
# Created generator
print(f"First value: {next(gen)}")
# Output:
# Starting generator
# First value: 1
print(f"Second value: {next(gen)}")
# Output:
# Resuming after first yield
# Second value: 2
print(f"Third value: {next(gen)}")
# Output:
# Resuming after second yield
# Third value: 3
try:
next(gen)
except StopIteration:
print("Generator exhausted - no more values")
# Output:
# Generator finished
# Generator exhausted - no more valuesNota come l’esecuzione della funzione sia intercalata con le chiamate a next(). Ogni yield mette in pausa la funzione, e ogni next() la riprende dal punto in cui era rimasta.
36.2.2) Stato del generatore: ricordare le variabili locali
I generatori ricordano tutte le loro variabili locali tra uno yield e l’altro. Questo li rende utili per mantenere lo stato tra chiamate multiple:
def counter(start=0):
"""Genera numeri sequenziali a partire da start."""
current = start
while True:
yield current
current += 1
# Il generatore ricorda 'current' tra gli yield
count = counter(10)
print(next(count)) # Output: 10
print(next(count)) # Output: 11
print(next(count)) # Output: 12
# Ogni generatore ha il proprio stato indipendente
count1 = counter(0)
count2 = counter(100)
print(next(count1)) # Output: 0
print(next(count2)) # Output: 100
print(next(count1)) # Output: 1
print(next(count2)) # Output: 101La variabile current viene preservata ogni volta che il generatore si mette in pausa su un yield e riprende alla successiva chiamata next(). Questo permette al generatore di continuare a contare dal suo ultimo valore. Ogni istanza di generatore mantiene il proprio stato indipendente.
36.2.3) Fare yield nei cicli: il pattern più comune
L’uso più comune dei generatori è fare yield di valori all’interno di un ciclo(loop). Questo pattern genera una sequenza di valori:
def even_numbers(start, end):
"""Genera numeri pari nell'intervallo dato."""
current = start if start % 2 == 0 else start + 1
while current <= end:
yield current
current += 2
# Usa il generatore
evens = even_numbers(1, 20)
print(list(evens))
# Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]Ogni iterazione del ciclo produce un valore, poi continua all’iterazione successiva quando next() viene chiamato di nuovo.
36.2.4) Istruzioni yield multiple
Un generatore può avere più istruzioni yield in punti diversi del suo codice. L’esecuzione scorre attraverso di esse in ordine:
def process_data(data):
"""Genera dati elaborati con messaggi di stato."""
yield "Starting processing..."
cleaned = [item.strip().lower() for item in data]
yield f"Cleaned {len(cleaned)} items"
unique = list(set(cleaned))
yield f"Found {len(unique)} unique items"
for item in sorted(unique):
yield item
# Elabora alcuni dati
data = [" Apple ", "Banana", "apple", "Cherry", "BANANA"]
processor = process_data(data)
for result in processor:
print(result)
# Output:
# Starting processing...
# Cleaned 5 items
# Found 3 unique items
# apple
# banana
# cherryQuesto pattern è utile per generatori che devono eseguire lavoro di setup, produrre informazioni di stato e poi produrre i dati effettivi.
36.3) Espressioni generatore vs list comprehension
36.3.1) Introduzione alle espressioni generatore
Nel Capitolo 34 abbiamo imparato le list comprehension—un modo conciso per creare liste(list). Le espressioni generatore(generator expression) usano una sintassi quasi identica ma creano generatori invece di liste.
Un’espressione generatore è essenzialmente un modo compatto per scrivere una semplice funzione generatore. Confronta questi due approcci equivalenti:
# Funzione generatore
def squares_function(n):
for x in range(n):
yield x * x
# Espressione generatore - fa la stessa cosa
squares_expression = (x * x for x in range(10))
# Entrambe creano oggetti generatore
gen1 = squares_function(10)
gen2 = squares_expression
print(type(gen1)) # Output: <class 'generator'>
print(type(gen2)) # Output: <class 'generator'>
# Entrambe producono gli stessi valori
print(list(squares_function(10))) # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(squares_expression)) # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]La sintassi è quasi identica alle list comprehension. Le differenze sono: usare le parentesi tonde () invece delle parentesi quadre [], e mentre le list comprehension creano liste, le espressioni generatore creano generatori:
# List comprehension - crea l'intera lista in memoria
squares_list = [x * x for x in range(10)]
print(squares_list)
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Espressione generatore - crea un oggetto generatore
squares_gen = (x * x for x in range(10))
print(squares_gen)
# Output: <generator object <genexpr> at 0x...>
# Converti in lista per vedere i valori
print(list(squares_gen))
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]Le espressioni generatore offrono la stessa sintassi concisa delle list comprehension ma con l’efficienza di memoria dei generatori.
36.3.2) Confronto di memoria: quando conta
Per sequenze piccole, la differenza di memoria tra list comprehension ed espressioni generatore è trascurabile. Ma per sequenze grandi, diventa significativa:
import sys
# Sequenza piccola - differenza minima
small_list = [x for x in range(100)]
small_gen = (x for x in range(100))
print(f"Small list: {sys.getsizeof(small_list)} bytes")
# Output: Small list: 920 bytes (actual size may vary)
print(f"Small generator: {sys.getsizeof(small_gen)} bytes")
# Output: Small generator: 192 bytes (actual size may vary)
# Sequenza grande - differenza enorme
large_list = [x for x in range(1_000_000)]
large_gen = (x for x in range(1_000_000))
print(f"Large list: {sys.getsizeof(large_list):,} bytes")
# Output: Large list: 8,448,728 bytes (actual size may vary)
print(f"Large generator: {sys.getsizeof(large_gen)} bytes")
# Output: Large generator: 192 bytes (actual size may vary)La dimensione del generatore rimane costante indipendentemente da quanti valori produrrà—memorizza solo l’espressione e lo stato corrente. La lista, invece, deve memorizzare tutti i valori in memoria, motivo per cui la sua dimensione cresce in proporzione al numero di elementi.
36.3.3) Espressioni generatore nelle chiamate di funzione
Le espressioni generatore sono particolarmente eleganti quando vengono passate direttamente a funzioni(function) che consumano iterabili. Puoi omettere le parentesi extra quando un’espressione generatore è l’unico argomento:
# Calcola la somma dei quadrati senza creare una lista
total = sum(x * x for x in range(100)) # Nota: non servono parentesi extra
print(total)
# Output: 328350
# Trova il massimo dei valori trasformati
numbers = [1, 2, 3, 4, 5]
max_square = max(x * x for x in numbers)
print(max_square)
# Output: 25
# Verifica se un qualunque valore soddisfa una condizione
data = [10, 15, 20, 25, 30]
has_large = any(x > 100 for x in data)
print(has_large)
# Output: FalseQuesto pattern è sia efficiente in memoria sia leggibile. Funzioni(function) come sum(), max(), min(), any() e all() elaborano il generatore un valore alla volta, senza mai creare una lista intermedia.
36.3.4) Filtrare con le espressioni generatore
Le espressioni generatore supportano la stessa logica condizionale delle list comprehension:
# Filtra i numeri pari
numbers = range(20)
evens = (x for x in numbers if x % 2 == 0)
print(list(evens))
# Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
# Trasforma e filtra
words = ["hello", "world", "python", "programming"]
long_upper = (word.upper() for word in words if len(word) > 5)
print(list(long_upper))
# Output: ['PYTHON', 'PROGRAMMING']36.3.5) Quando le espressioni generatore non sono sufficienti
Le espressioni generatore sono concise ed eleganti, ma hanno dei limiti. Usa funzioni generatore quando ti serve:
Logica complessa:
# Troppo complesso per un'espressione generatore
def process_log_lines(filename):
"""Elabora un file di log con logica complessa."""
with open(filename, 'r') as file:
for line in file:
line = line.strip()
if not line or line.startswith('#'):
continue # Salta righe vuote e commenti
parts = line.split('|')
if len(parts) >= 3:
timestamp, level, message = parts[0], parts[1], parts[2]
if level in ('ERROR', 'CRITICAL'):
yield {
'timestamp': timestamp,
'level': level,
'message': message
}Yield multipli o stato:
# Un'espressione generatore non può mantenere stato tra le iterazioni
def running_total(numbers):
"""Genera la somma progressiva dei numeri."""
total = 0
for num in numbers:
total += num
yield total
numbers = [1, 2, 3, 4, 5]
print(list(running_total(numbers)))
# Output: [1, 3, 6, 10, 15]Gestione degli errori:
# Un'espressione generatore non può gestire eccezioni
def safe_divide(numbers, divisor):
"""Genera i risultati delle divisioni, gestendo gli errori."""
for num in numbers:
try:
yield num / divisor
except ZeroDivisionError:
yield float('inf')36.4) Quando usare i generatori invece delle liste
36.4.1) Dataset grandi: il caso d’uso principale
Il motivo più convincente per usare i generatori è quando si lavora con grandi quantità di dati. Se stai elaborando milioni di record, i generatori possono fare la differenza tra un programma che gira senza problemi e uno che va in crash.
Approccio sbagliato - Caricare l’intero file in memoria:
# NON FARE QUESTO con file grandi
def count_errors_bad(filename):
"""Carica l'intero file in memoria - andrà in crash con file grandi."""
with open(filename, 'r') as file:
lines = file.readlines() # Carica l'INTERO file in memoria
error_count = 0
for line in lines:
if 'ERROR' in line:
error_count += 1
return error_count
# If the file is 10 GB, this tries to load 10 GB into memory!Approccio corretto - Usare un generatore:
def read_log_lines(filename):
"""Genera righe da un file di log una alla volta."""
with open(filename, 'r') as file:
for line in file:
yield line.strip()
def count_errors_good(filename):
"""Conta gli errori senza caricare l'intero file in memoria."""
error_count = 0
for line in read_log_lines(filename):
if 'ERROR' in line:
error_count += 1
return error_count
# Questo funziona in modo efficiente anche con file di log di dimensioni in gigabyte
# perché mantiene in memoria solo una riga alla volta
count = count_errors_good('huge_application.log')
print(f"Found {count} errors")L’approccio con generatore elabora una riga alla volta, quindi l’uso di memoria resta costante indipendentemente dalla dimensione del file. Un file da 10 GB usa la stessa quantità di memoria di un file da 10 KB.
36.4.2) Sequenze infinite o di lunghezza sconosciuta
I generatori sono perfetti per sequenze in cui non conosci la lunghezza in anticipo o in cui la sequenza è concettualmente infinita:
def user_input_stream():
"""Genera input dell'utente finché non digita 'quit'."""
while True:
user_input = input("Enter a number (or 'quit'): ")
if user_input.lower() == 'quit':
break
try:
yield int(user_input)
except ValueError:
print("Invalid number, try again")
# Elabora gli input dell'utente man mano che arrivano
total = 0
count = 0
for number in user_input_stream():
total += number
count += 1
print(f"Running average: {total / count:.2f}")Non puoi creare una lista di lunghezza sconosciuta, ma un generatore gestisce questo caso in modo naturale.
36.4.3) Trasformazioni concatenate: costruire pipeline di dati
Quando devi applicare più trasformazioni ai dati, i generatori ti permettono di concatenare operazioni senza creare liste intermedie:
# Trasforma i numeri attraverso più fasi
def generate_numbers(n):
"""Genera numeri da 1 a n."""
for i in range(1, n + 1):
yield i
def square_numbers(numbers):
"""Genera i quadrati dei numeri in input."""
for num in numbers:
yield num * num
def keep_even(numbers):
"""Genera solo numeri pari."""
for num in numbers:
if num % 2 == 0:
yield num
# Concatena i generatori - non vengono create liste intermedie
numbers = generate_numbers(10)
squared = square_numbers(numbers)
even_squares = keep_even(squared)
# Elabora i risultati
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]Ogni fase elabora un valore alla volta, passandolo alla fase successiva. Questo è efficiente in memoria e ti consente di elaborare dataset più grandi della RAM disponibile.
Senza generatori, avresti bisogno di liste intermedie:
# Approccio non con generatore - crea liste intermedie
numbers = list(range(1, 11)) # [1, 2, 3, ..., 10]
squared = [n * n for n in numbers] # [1, 4, 9, ..., 100]
even_squares = [n for n in squared if n % 2 == 0] # [4, 16, 36, 64, 100]
# Con i generatori - nessuna lista intermedia
numbers = (i for i in range(1, 11))
squared = (n * n for n in numbers)
even_squares = (n for n in squared if n % 2 == 0)
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]Per una pipeline con tre fasi che elabora un milione di elementi, l’approccio con liste creerebbe tre liste da un milione di elementi ciascuna. L’approccio con generatori mantiene in memoria solo un valore alla volta.
36.4.4) Quando le liste sono migliori dei generatori
Nonostante i loro vantaggi, i generatori non sono sempre la scelta giusta. Usa le liste quando ti serve:
Iterazioni multiple:
# Lista - puoi iterare più volte
numbers = [1, 2, 3, 4, 5]
print(sum(numbers)) # Output: 15
print(max(numbers)) # Output: 5 (works fine)
# Generatore - puoi iterare una sola volta
numbers_gen = (x for x in range(1, 6))
print(sum(numbers_gen)) # Output: 15
print(max(numbers_gen)) # Output: ValueError: max() iterable argument is emptySe devi elaborare gli stessi dati più volte, usa una lista.
Accesso casuale:
# Serve accedere agli elementi per indice - usa una lista
students = ['Alice', 'Bob', 'Charlie', 'Diana']
print(students[2]) # Output: Charlie
# I generatori non supportano l'indicizzazione
students_gen = (name for name in students)
# students_gen[2] # ERROR: 'generator' object is not subscriptableInformazioni sulla lunghezza:
# Serve conoscere la lunghezza - usa una lista
data = [1, 2, 3, 4, 5]
print(f"Processing {len(data)} items")
# I generatori non hanno una lunghezza
data_gen = (x for x in data)
# len(data_gen) # ERROR: object of type 'generator' has no len()Dataset piccoli:
# Per dataset piccoli, le liste vanno bene e sono più comode
small_data = [x * 2 for x in range(10)]
# Il risparmio di memoria di un generatore qui non è significativo
# e la lista è più flessibile36.4.5) Guida pratica alla decisione
Ecco una guida pratica per scegliere tra generatori e liste:
Usa i generatori quando:
- Stai elaborando file o dataset grandi
- Stai lavorando con stream di dati o input dell’utente
- Stai costruendo pipeline di elaborazione dati
- L’efficienza di memoria è importante
- Hai bisogno di iterare una sola volta
- La sequenza è infinita o molto lunga
Usa le liste quando:
- Il dataset è piccolo (tipicamente < 10.000 elementi)
- Devi iterare più volte
- Ti serve accesso casuale per indice
- Devi conoscere la lunghezza
- Devi passare i dati a codice che si aspetta una lista
36.4.6) Convertire tra generatori e liste
Puoi convertire facilmente tra generatori e liste quando serve:
# Generatore in lista
numbers_gen = (x * 2 for x in range(5))
numbers_list = list(numbers_gen)
print(numbers_list)
# Output: [0, 2, 4, 6, 8]
# Lista in generatore (usando un'espressione generatore)
numbers_list = [1, 2, 3, 4, 5]
numbers_gen = (x for x in numbers_list)Questa flessibilità significa che puoi iniziare con un generatore per efficienza e convertire in lista solo quando ti servono funzionalità specifiche della lista:
# Inizia con un generatore per efficienza di memoria
numbers = (x for x in range(1, 1001))
filtered = (x for x in numbers if x % 7 == 0)
# Converti in lista quando ti servono iterazioni multiple
multiples_of_seven = list(filtered)
# Ora puoi usare funzionalità delle liste
print(f"Count: {len(multiples_of_seven)}")
# Output: Count: 142
print(f"First: {multiples_of_seven[0]}")
# Output: First: 7
print(f"Last: {multiples_of_seven[-1]}")
# Output: Last: 994
# Puoi iterare più volte
total = sum(multiples_of_seven)
average = total / len(multiples_of_seven)
print(f"Average: {average:.1f}")
# Output: Average: 500.5I generatori sono una delle funzionalità più eleganti di Python per scrivere codice efficiente in memoria. Ti permettono di elaborare grandi dataset, costruire pipeline di dati e lavorare con sequenze infinite—il tutto mantenendo il codice pulito e leggibile. Man mano che acquisirai esperienza, svilupperai un intuito su quando i generatori sono lo strumento giusto per il lavoro.