Ci sono due moduli della stdlib che ripagano il tempo che spendi a impararli più velocemente di quasi qualsiasi altra cosa in Python: itertools e functools. Il primo ti dà algebra di iteratori, slice, chain, group, combine, senza riscrivere ogni volta gli stessi loop annidati. Il secondo ti dà strumenti a livello di funzione, caching, currying, dispatch, che sono normali nei linguaggi funzionali e qui un po’ sottoutilizzati.
Questa lezione è il sottoinsieme ad alto rendimento. Non ogni ricetta dei due moduli, ma la dozzina circa di membri ai quali tornerai di nuovo e di nuovo.
itertools: algebra di iteratori
Tutto in itertools restituisce un iteratore, non una lista. Significa che è lazy, è leggero in memoria, e devi ricordarti di avvolgerlo in list(...) se vuoi guardare i valori più di una volta.
chain: concatenare iterabili
from itertools import chain
a: list[int] = [1, 2, 3]
b: list[int] = [4, 5, 6]
c: tuple[int, ...] = (7, 8, 9)
list(chain(a, b, c)) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
chain(*iterables) li attraversa in ordine, uno dopo l’altro. Il grande vantaggio rispetto a a + b + c è che gli input non devono essere dello stesso tipo, e niente viene materializzato finché non itera. C’è anche chain.from_iterable(it) per il caso in cui hai un iterabile di iterabili, appiattendolo di un livello:
pages: list[list[int]] = [[1, 2], [3, 4], [5, 6]]
list(chain.from_iterable(pages)) # [1, 2, 3, 4, 5, 6]
islice: fare slice di un iterabile
Non puoi fare my_iterator[10:20] perché gli iteratori non supportano l’indicizzazione. islice è il sostituto consapevole degli iteratori:
from itertools import islice, count
# count() è infinito, islice ci salva
first_five: list[int] = list(islice(count(), 5)) # [0, 1, 2, 3, 4]
window: list[int] = list(islice(count(), 10, 15)) # [10, 11, 12, 13, 14]
every_other: list[int] = list(islice(count(), 0, 10, 2)) # [0, 2, 4, 6, 8]
La firma rispecchia lo slicing normale: islice(it, stop) o islice(it, start, stop[, step]). A differenza dello slicing delle liste, gli indici negativi non sono ammessi: non c’è una “fine” di un iteratore da cui contare all’indietro.
L’uso reale più comune: paginazione su un generatore. Hai una funzione che produce righe da una sorgente costosa, e vuoi la pagina 3 da 50 elementi ciascuna:
from collections.abc import Iterator
from itertools import islice
def fetch_rows() -> Iterator[dict]:
...
page_size: int = 50
page_num: int = 3
start: int = (page_num - 1) * page_size
page: list[dict] = list(islice(fetch_rows(), start, start + page_size))
tee: dividere in N iteratori indipendenti
from itertools import tee
source = iter([1, 2, 3, 4, 5])
a, b = tee(source, 2)
list(a) # [1, 2, 3, 4, 5]
list(b) # [1, 2, 3, 4, 5]
tee(it, n) restituisce n iteratori che producono ciascuno gli stessi valori dell’originale. Utile quando vuoi attraversare la stessa sequenza in due modi senza materializzarla in una lista, per esempio calcolare una somma e un massimo in un passaggio ciascuno.
L’inghippo: tee funziona bufferizzando i valori che un iteratore ha consumato e un altro non ancora. Se un ramo corre molto più avanti dell’altro, il buffer cresce. Per consumo bilanciato, va bene. Per “consuma tutto a, poi tutto b”, hai effettivamente materializzato l’intera sequenza in memoria, e a quel punto una lista sarebbe stata più semplice.
groupby: raggruppamento sequenziale
from itertools import groupby
data: list[tuple[str, int]] = [
("a", 1), ("a", 2), ("b", 3), ("b", 4), ("a", 5)
]
for key, group in groupby(data, key=lambda x: x[0]):
print(key, list(group))
# a [('a', 1), ('a', 2)]
# b [('b', 3), ('b', 4)]
# a [('a', 5)]
Leggi con attenzione. La chiave "a" appare due volte nell’output, una all’inizio e una alla fine. groupby raggruppa serie consecutive della stessa chiave. Non ordina.
Se vuoi una semantica stile SQL “group by key” dove l’ordine non conta, hai due scelte: ordinare prima, oppure usare defaultdict(list) dalla lezione precedente. groupby è lo strumento giusto quando i dati sono già ordinati, o quando le sequenze consecutive sono esattamente quello che vuoi: serie dello stesso log level, serie dello stesso status code, ecc.
accumulate: totali progressivi
from itertools import accumulate
import operator
values: list[int] = [1, 2, 3, 4, 5]
list(accumulate(values)) # [1, 3, 6, 10, 15], somma progressiva
list(accumulate(values, operator.mul)) # [1, 2, 6, 24, 120], prodotto progressivo
list(accumulate(values, max)) # [1, 2, 3, 4, 5], massimo progressivo
accumulate(it, func=operator.add) produce il risultato cumulativo dell’applicazione di func da sinistra a destra. Il default è l’addizione, che la rende la versione lazy del “totale progressivo”. Con una funzione personalizzata, qualsiasi cosa a forma di fold funziona.
Il keyword opzionale initial (3.8+) ti permette di impostare un valore iniziale:
list(accumulate([1, 2, 3], initial=100)) # [100, 101, 103, 106]
combinations, permutations, product: combinatoria
from itertools import combinations, permutations, product
list(combinations("abc", 2)) # [('a', 'b'), ('a', 'c'), ('b', 'c')]
list(permutations("abc", 2)) # [('a', 'b'), ('a', 'c'), ('b', 'a'), ('b', 'c'), ('c', 'a'), ('c', 'b')]
list(product([1, 2], ["x", "y"])) # [(1, 'x'), (1, 'y'), (2, 'x'), (2, 'y')]
combinations(it, r): tutti i sottoinsiemi di lunghezza r, l’ordine non conta, niente ripetizioni.permutations(it, r): tutti gli ordinamenti di lunghezza r, l’ordine conta, niente ripetizioni.product(*its, repeat=1): prodotto cartesiano. Il sostituto più pulito per cicli for annidati su dimensioni indipendenti.
Sostituire i loop annidati:
# Prima
for env in ["dev", "staging", "prod"]:
for region in ["us", "eu", "ap"]:
for size in ["small", "large"]:
deploy(env, region, size)
# Dopo
for env, region, size in product(
["dev", "staging", "prod"],
["us", "eu", "ap"],
["small", "large"],
):
deploy(env, region, size)
Stesso comportamento, un solo livello di indentazione, più facile aggiungere una quarta dimensione.
count, cycle, repeat: iteratori infiniti
from itertools import count, cycle, repeat
count(10) # 10, 11, 12, 13, ... per sempre
count(0, 0.25) # 0, 0.25, 0.5, 0.75, ... per sempre
cycle("ab") # 'a', 'b', 'a', 'b', ... per sempre
repeat("x", 3) # 'x', 'x', 'x'
repeat("x") # 'x', 'x', 'x', ... per sempre
Accoppiali sempre con qualcosa che si ferma: islice, zip contro un iterabile finito, o una condizione break. Altrimenti hai scritto un loop infinito.
Un uso reale: numerare le righe di log a partire da 1, indipendentemente da quante ce ne siano.
from itertools import count
for n, line in zip(count(1), open("log.txt"), strict=False):
print(f"{n:>5}: {line}", end="")
zip contro l’iteratore del file si ferma naturalmente quando il file finisce.
batched: chunking (3.12+)
from itertools import batched
rows: list[int] = list(range(13))
for chunk in batched(rows, 5):
print(chunk)
# (0, 1, 2, 3, 4)
# (5, 6, 7, 8, 9)
# (10, 11, 12)
batched(it, n) è stato aggiunto in Python 3.12. Prima, ogni codebase Python aveva la sua funzione batched o chunked personalizzata. Ora non ti serve. Utile per raggruppare chiamate API, insert su database, qualsiasi cosa con un limite per richiesta.
functools: strumenti per funzioni
reduce: sì, va ancora bene
from functools import reduce
import operator
reduce(operator.add, [1, 2, 3, 4, 5]) # 15
reduce(operator.mul, [1, 2, 3, 4, 5]) # 120
reduce(lambda a, b: a | b, [{1}, {2}, {3}]) # {1, 2, 3}
reduce(func, iterable[, initial]) piega l’iterabile da sinistra a destra con la funzione binaria. È la versione in linguaggio imperativo del left fold.
Guido ha spostato reduce fuori dai builtin in Python 3 perché, a suo dire, la maggior parte degli usi è più chiara come un loop. Ha ragione la maggior parte delle volte: sum, min, max, e any/all coprono i casi comuni. Ma per fold legittimi che non sono uno di questi, unioni di set, merge di dizionari, monoidi personalizzati, reduce è esattamente lo strumento giusto. Non evitarlo per principio.
# Fa il merge di una lista di dict (le chiavi successive vincono)
from functools import reduce
configs: list[dict[str, str]] = [base, env_overrides, cli_overrides]
final: dict[str, str] = reduce(lambda a, b: {**a, **b}, configs, {})
partial: pre-riempire argomenti
from functools import partial
def power(base: float, exp: float) -> float:
return base ** exp
square: callable = partial(power, exp=2)
cube: callable = partial(power, exp=3)
square(5) # 25
cube(5) # 125
partial(func, *args, **kwargs) restituisce un nuovo callable con alcuni argomenti fissati. Usi comuni:
- Adattare una funzione alla firma di una callback che prende meno argomenti.
- Costruire varianti specializzate senza scrivere funzioni wrapper.
- Pre-configurare un logger, una sessione HTTP, una connessione al database.
import requests
from functools import partial
api_get = partial(requests.get, timeout=10, headers={"User-Agent": "myapp/1.0"})
api_get("https://example.com/users") # timeout e headers già applicati
cache e lru_cache: memoizzazione
from functools import cache, lru_cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
@lru_cache(maxsize=128)
def fetch_user(user_id: int) -> dict:
... # chiamata costosa
@cache (3.9+) è una cache senza limite: ogni chiamata con nuovi argomenti viene ricordata per sempre. @lru_cache(maxsize=N) è la versione limitata: tieni gli N risultati usati più di recente, scarta gli altri.
Usa @cache senza limite quando:
- Lo spazio degli argomenti è piccolo e conosciuto (interi piccoli, stringhe fisse).
- Stai memoizzando una funzione ricorsiva pura.
Usa @lru_cache limitato quando:
- Lo spazio degli argomenti potrebbe crescere senza limiti (user ID, URL, percorsi di file).
- Hai bisogno di un tetto di memoria.
Entrambi richiedono che gli argomenti siano hashabili. Non decorare una funzione che prende una list o un dict come argomento: andrà in crash alla prima chiamata.
singledispatch: overloading di funzione per tipo
from functools import singledispatch
from pathlib import Path
import json
import tomllib
@singledispatch
def load_config(source) -> dict:
raise TypeError(f"Cannot load config from {type(source).__name__}")
@load_config.register
def _(source: Path) -> dict:
text: str = source.read_text()
if source.suffix == ".json":
return json.loads(text)
if source.suffix == ".toml":
return tomllib.loads(text)
raise ValueError(f"Unsupported file type: {source.suffix}")
@load_config.register
def _(source: dict) -> dict:
return source
@load_config.register
def _(source: str) -> dict:
return json.loads(source)
@singledispatch ti permette di scrivere una funzione che fa dispatch sul tipo del suo primo argomento. La funzione base è il fallback. Ogni @func.register aggiunge un’implementazione per un tipo specifico, raccolto tramite l’annotazione di tipo sul parametro.
Chiamare load_config(Path("app.toml")) esegue il ramo Path. Chiamare load_config({"key": "value"}) esegue il ramo dict. load_config(42) solleva TypeError dalla base.
Questa è la risposta pulita di Python a “voglio gestire diversi tipi di input in modo diverso senza una catena di check isinstance”. È un po’ più pesante di un match statement, ma compone: del codice di terze parti può registrare un handler per il proprio tipo senza toccare il tuo.
Nota sull’assistenza AI. Gli assistenti di codice sono bravi a riconoscere quando una catena
if isinstance(...) elif isinstance(...) elif ...potrebbe diventare un@singledispatch. Sono anche bravi a usarlo troppo. Se hai due rami, la catena va bene. Il dispatch ripaga a tre o più, specialmente quando la funzione potrebbe essere estesa in seguito.
wraps: tieni onesto il tuo decoratore
Già coperto nella lezione 5. Promemoria veloce: @functools.wraps(fn) sulla funzione interna di un decoratore copia __name__, __doc__, e qualche altro attributo dalla funzione wrappata al wrapper. Senza, ogni funzione decorata sembra wrapper ai debugger, ai generatori di documentazione e a help().
Quando la stdlib non basta: more-itertools
Il pacchetto PyPI more-itertools è l’estensione da produzione. Cose come chunked, windowed, unique_everseen, partition, take, interleave, ricette che vivono nella documentazione ufficiale di itertools da anni come esempi “potresti scriverlo da solo”, impacchettate così non devi.
from more_itertools import unique_everseen, windowed, partition
list(unique_everseen([1, 2, 1, 3, 2, 4])) # [1, 2, 3, 4], preserva l'ordine di prima vista
list(windowed(range(5), 3)) # [(0,1,2), (1,2,3), (2,3,4)]
evens, odds = partition(lambda n: n % 2, range(10))
Se un collega o un assistente AI suggerisce di scrivere un helper personalizzato che suona a forma di itertools, controlla prima more-itertools.
Mettiamo tutto insieme
Un piccolo esempio che usa pezzi di entrambi i moduli, processare uno stream di eventi in riassunti orari:
from itertools import groupby
from functools import reduce
from collections import Counter
from datetime import datetime
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Event:
timestamp: datetime
user_id: int
action: str
def hour_key(e: Event) -> datetime:
return e.timestamp.replace(minute=0, second=0, microsecond=0)
def summarize(events: list[Event]) -> dict[datetime, Counter[str]]:
events_sorted: list[Event] = sorted(events, key=lambda e: e.timestamp)
grouped = groupby(events_sorted, key=hour_key)
return {
hour: reduce(lambda c, e: c + Counter([e.action]), group, Counter())
for hour, group in grouped
}
sorted per far rispettare ai dati il contratto di sequenze consecutive di groupby. groupby per spezzare per ora. reduce per piegare gli eventi di ciascuna ora in un Counter. Tre moduli, dieci righe, nessuna condizione annidata.
Questo è il toolkit di ordine superiore. Usalo quando rende l’intento più chiaro; affidati a un loop semplice quando non lo fa.
References: itertools — Functions creating iterators, functools — Higher-order functions, more-itertools, PEP 443 — Single-dispatch generic functions. Retrieved 2026-05-01.