Python, dalle fondamenta Lezione 4 / 60

Iterator, generator, comprehension: smontiamoli pezzo per pezzo

Tre concetti collegati che chi scrive Python tende a confondere. Le differenze contano quando conta la memoria e quando conta la pigrizia.

Se hai scritto Python per più di una settimana, hai usato tutti e tre. Hai scritto for x in something:. Hai scritto [x*2 for x in xs]. Forse hai anche scritto yield. E se qualcuno ti chiedesse “qual è la differenza tra un iterable, un iterator, un generator e una comprehension?”, probabilmente faresti quello che fanno quasi tutti: strizzeresti gli occhi, mormoreresti qualcosa sulla pigrizia, e cambieresti discorso.

Va benissimo per il codice di tutti i giorni. Smette di andare bene il giorno in cui devi far passare un CSV da 50 GB attraverso un container con poca memoria, o devi debuggare un generator che misteriosamente alla seconda esecuzione gira a vuoto senza risultati. Le quattro parole indicano quattro cose diverse. Oggi le smontiamo.

Iterable e iterator: la versione in due frasi

Un iterable è qualunque cosa tu possa mettere dopo in in un ciclo for. Liste, tuple, dizionari, set, stringhe, file, range, classi custom che implementano __iter__. La cosa in sé non tiene traccia della posizione: puoi scorrerla quante volte vuoi.

Un iterator è la cosa stateful e monouso che davvero cammina attraverso un iterable. Tiene traccia di dove si trova. Quando finisce, solleva StopIteration. Una volta esaurito è finito: per ricominciare ne ottieni uno nuovo.

xs: list[int] = [10, 20, 30]      # iterable

it = iter(xs)                      # iterator, appena nato
print(next(it))                    # 10
print(next(it))                    # 20
print(next(it))                    # 30
print(next(it))                    # solleva StopIteration

for x in xs: è zucchero sintattico per “chiama iter(xs) una volta, poi chiama next() in loop finché non arriva StopIteration, poi fermati.” La lista xs è l’iterable. La cosa restituita da iter(xs) è l’iterator. Non sono lo stesso oggetto, e proprio questa distinzione è quello che ti permette di iterare due volte sulla stessa lista senza che si “consumi.”

Una sottigliezza facile da sbagliare: un iterator è anche un iterable (ha __iter__, che restituisce sé stesso). Per questo for x in some_generator: funziona. Ma puoi camminarci sopra una volta sola:

g = (x*2 for x in [1, 2, 3])       # generator expression
print(list(g))                     # [2, 4, 6]
print(list(g))                     # [] già esaurito

Se hai mai scritto una funzione che restituisce un generator e poi hai provato a iterare due volte sul risultato, il bug è questo.

Il protocollo iterator su classi custom

Se vuoi che una tua classe funzioni in un ciclo for, ti servono due metodi:

from typing import Iterator


class CountDown:
    def __init__(self, start: int) -> None:
        self.start = start

    def __iter__(self) -> Iterator[int]:
        # Restituisce un iterator nuovo ogni volta. È questo che rende
        # CountDown stesso un *iterable*, non un iterator.
        return CountDownIterator(self.start)


class CountDownIterator:
    def __init__(self, current: int) -> None:
        self.current = current

    def __iter__(self) -> "CountDownIterator":
        return self

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


for n in CountDown(3):
    print(n)
# 3, 2, 1

È un sacco di cerimonia per “conto alla rovescia.” Leggendolo, capisci perché esiste yield.

Generator: iterator senza il boilerplate

Un generator è un iterator costruito a partire da una funzione che contiene yield. L’interprete fa per te tutto il lavoro idraulico di __iter__/__next__/StopIteration.

from typing import Iterator


def count_down(start: int) -> Iterator[int]:
    while start > 0:
        yield start
        start -= 1


for n in count_down(3):
    print(n)
# 3, 2, 1

Stesso comportamento della classe sopra. Un quinto del codice. È per questo che le classi iterator sono rare nel Python moderno: ne scrivi una solo quando lo stato è davvero complesso (per esempio una visita di albero che deve riprendere a metà ricorsione, o un iterator che ha metodi diversi da __next__).

Quando la funzione incontra yield, si mette in pausa. Variabili locali, program counter, stack: tutto congelato. La chiamata successiva a next() riprende esattamente da quel punto. Quando la funzione fa return (o esce dal corpo), il generator solleva StopIteration automaticamente.

yield from permette a un generator di delegare a un altro:

from typing import Iterator


def numbers() -> Iterator[int]:
    yield from range(3)        # 0, 1, 2
    yield from [10, 20, 30]    # 10, 20, 30
    yield 99                   # 99


print(list(numbers()))
# [0, 1, 2, 10, 20, 30, 99]

Senza yield from scriveresti for x in range(3): yield x: va bene, ma è meno diretto.

Comprehension: zucchero sintattico per costruire roba

Una list comprehension costruisce una lista con un loop compatto:

xs: list[int] = [1, 2, 3, 4, 5]

doubled = [x * 2 for x in xs]
# [2, 4, 6, 8, 10]

evens_doubled = [x * 2 for x in xs if x % 2 == 0]
# [4, 8]

Equivale a scrivere il loop con .append() a mano, ma è più breve e leggermente più veloce (l’interprete la ottimizza). La stessa sintassi esiste per set e dizionari:

unique_lengths = {len(s) for s in ["hi", "hello", "hey"]}
# {2, 5, 3}

word_lengths = {s: len(s) for s in ["hi", "hello", "hey"]}
# {'hi': 2, 'hello': 5, 'hey': 3}

Non esiste una tuple comprehension. La sintassi (x*2 for x in xs) sembra esserlo ma non lo è: è una generator expression. Per costruire una tupla a partire da una comprehension scrivi tuple(x*2 for x in xs).

La differenza di memoria: list vs generator expression

Questa è la cosa pratica che conta di più.

# List comprehension: costruisce tutta la lista in memoria
squares_list = [x * x for x in range(10_000_000)]
# Memoria: ~80 MB per una lista di 10 milioni di int. Allocata in anticipo.

# Generator expression: non costruisce nulla, produce on demand
squares_gen = (x * x for x in range(10_000_000))
# Memoria: ~200 byte. Solo l'oggetto generator.

Se hai intenzione di consumare ogni elemento e poi vuoi accesso casuale dopo, la lista va bene. Se hai intenzione di consumarli una sola volta, in ordine, il generator è quasi sempre la scelta giusta.

L’esempio classico: lo streaming di un file enorme.

from typing import Iterator


def numeric_columns(path: str, col: int) -> Iterator[float]:
    with open(path, encoding="utf-8") as f:
        next(f)                          # salta l'header
        for line in f:                   # i file sono iterator di righe
            parts = line.rstrip("\n").split(",")
            yield float(parts[col])


total = 0.0
count = 0
for value in numeric_columns("orders_50gb.csv", col=4):
    total += value
    count += 1

print(total / count if count else 0.0)

Quel programma calcola la media di una colonna su un file da 50 GB usando pochi kilobyte di RAM. L’oggetto file è esso stesso un iterator: for line in f: legge una riga alla volta. La generator function fa passare quelle righe attraverso una trasformazione, una alla volta. Niente si materializza tutto insieme. Questa è la forma di ogni data pipeline a memoria limitata che mai scriverai in Python.

Lo stesso codice con una list comprehension proverebbe a caricare tutti i 50 GB in una lista Python. Il container andrebbe in OOM al 30%.

itertools: il toolkit della libreria standard

itertools è un modulo di blocchi base costruiti sui generator. Alcuni che uso ogni settimana:

import itertools

# chain: concatena iterable in modo lazy
combined = itertools.chain([1, 2], [3, 4], [5])
# 1, 2, 3, 4, 5 senza mai costruire una lista combinata

# islice: affetta un iterator senza convertirlo in lista
first_ten = list(itertools.islice(numeric_columns("huge.csv", 4), 10))
# Legge esattamente 10 righe dal file, poi si ferma.

# tee: divide un iterator in N iterator indipendenti
a, b = itertools.tee(numeric_columns("huge.csv", 4), 2)
# a e b si possono consumare indipendentemente, ma tee bufferizza i
# valori che nessuno dei due ha ancora consumato. Se a corre molto più
# avanti di b, ti ritrovi di nuovo a tenere quasi tutti i dati in memoria.

# groupby, accumulate, pairwise (3.10+), batched (3.12+)...

Non sto a elencare tutto il modulo: help(itertools) è quello che ti serve quando hai bisogno di uno strumento. Ma una volta che vedi il pattern (tutto è un generator, tutto si compone), smetti di scrivere a mano i loop per cose tipo “itera a coppie” o “prendi un elemento ogni n.”

Quale usare e quando

Un albero decisionale grezzo:

  • Ti serve una collezione finita su cui indicizzare o iterare più volte? Lista o list comprehension.
  • Ti serve trasformare-e-iterare-una-volta su qualcosa di potenzialmente enorme? Generator expression o generator function.
  • Ti serve un comportamento oltre __next__, per esempio un iterator con un metodo reset() o stato extra? Scrivi una classe.
  • Stai componendo trasformazioni standard? Prima itertools, codice custom solo quando niente calza.

L’errore da evitare: scrivere [x for x in big_thing if cond] e poi iterarci sopra subito una volta sola. Quella è una generator expression travestita: togli le parentesi quadre, risparmi memoria.

Trappole comuni

I generator sono monouso. Se devi camminare due volte sugli stessi dati, o li metti in una lista, o richiami la generator function una seconda volta (ottenendo così un generator nuovo).

Late binding nelle closure dentro le comprehension. L’espressione di una comprehension viene valutata in modo lazy nelle generator expression: la variabile di ciclo in una list comprehension va bene, ma in una generator expression l’iterable è legato al momento della creazione, mentre il resto è lazy. La trappola classica:

gens = [(x * i for x in range(3)) for i in range(3)]
# Ogni generator cattura `i` per riferimento. Quando iteri,
# `i` vale 2. Tutti e tre i generator producono multipli di 2.

Se vuoi davvero che ogni generator catturi il valore corrente di i, passalo come argomento di default o usa una factory function.

StopIteration dentro un generator lo termina silenziosamente. Dalla PEP 479 (Python 3.7+), una StopIteration che sfugge dall’interno di un generator viene convertita in RuntimeError invece di chiudere misteriosamente l’iterazione. Cambio salutare, ma vale la pena saperlo se leggi codice vecchio che si appoggiava al comportamento precedente.

Iterator, generator e comprehension non sono più tutti confusi insieme. Prossima lezione: i decorator. Il pattern che avvolge metà del codice Python di terze parti che importi.


Citations (retrieved 2026-05-01):

Cerca