C’è una tassa che paghi in qualunque linguaggio per non conoscere abbastanza la standard library. In Python si presenta più spesso in due posti: persone che scrivono venti righe di giochetti su dict che Counter o defaultdict avrebbero gestito in due, e persone che scrivono metodi __init__ da quattro righe che @dataclass avrebbe generato per loro.
Questa lezione è sulle piccole strutture dati. Nessuna è entusiasmante. Tutte sono tempo che smetti di spendere in boilerplate.
Counter: contare cose hashable
from collections import Counter
words: list[str] = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts: Counter[str] = Counter(words)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1})
counts.most_common(2) # [('apple', 3), ('banana', 2)]
counts["apple"] # 3
counts["mango"] # 0 -- non solleva mai KeyError
Counter è una sottoclasse di dict che mette di default le chiavi mancanti a 0. Lo nutri con qualunque iterabile di elementi hashable e ottieni la frequenza di ognuno. Il metodo most_common(n) è la feature da urlo: ordinato, decrescente, top n. Mai più sorted(items, key=lambda x: -x[1])[:n].
Esempio reale: capire quali IP colpiscono di più il tuo endpoint di errore:
from collections import Counter
from pathlib import Path
errors: Counter[str] = Counter()
for line in Path("access.log").read_text().splitlines():
if " 500 " in line:
ip: str = line.split()[0]
errors[ip] += 1
print(errors.most_common(10))
I Counter compongono anche con operatori aritmetici:
a: Counter[str] = Counter("aabbc")
b: Counter[str] = Counter("abbcc")
a + b # Counter({'b': 4, 'c': 3, 'a': 3})
a - b # Counter({'a': 1}) -- si tengono solo i conteggi positivi
a & b # Counter({'a': 1, 'b': 2, 'c': 1}) -- min (intersezione)
a | b # Counter({'a': 2, 'b': 2, 'c': 2}) -- max (unione)
Semantica multiset. Utile per cose come “quanti di ogni token appaiono in entrambi i documenti”.
defaultdict: chiavi auto-inizializzate
from collections import defaultdict
groups: defaultdict[str, list[int]] = defaultdict(list)
for user_id, tag in [(1, "a"), (2, "a"), (1, "b"), (3, "a")]:
groups[tag].append(user_id)
# defaultdict(<class 'list'>, {'a': [1, 2, 3], 'b': [1]})
defaultdict(factory) crea il valore chiamando factory() la prima volta che accedi a una chiave mancante. Con list, ottieni una lista vuota pronta per .append(). Il pattern “raggruppa queste cose per qualche chiave” è così comune che questo è di gran lunga il membro più usato del modulo collections.
Altre factory:
defaultdict(int): auto-zero, come un Counter primitivodefaultdict(set): set vuoto auto per raggruppamenti deduplicatidefaultdict(dict): dict annidati; chiave una volta, ottieni un dict, infilaci dentro le cosedefaultdict(lambda: "unknown"): qualunque callable che restituisca il valore di default
L’alternativa senza defaultdict:
groups: dict[str, list[int]] = {}
for user_id, tag in pairs:
if tag not in groups:
groups[tag] = []
groups[tag].append(user_id)
O con setdefault:
groups.setdefault(tag, []).append(user_id)
Funzionano entrambi. defaultdict è più pulito quando il corpo del loop ha più di un append.
Nota sull’assistenza AI. Gli assistenti AI moderni di coding sono affidabilmente bravi a riconoscere i pattern “sto raggruppando cose” o “sto contando cose” e a suggerire
defaultdict(list)oCounternon appena vedono il loop. Vale la pena chiederglielo. La trappola opposta è più comune: gli assistenti tendono a sovraprodurre@dataclassper qualunque dato strutturato, inclusi casi in cui un semplice dict sarebbe stato più semplice. Se ti ritrovi con cinque dataclass che contengono tre campi ciascuna e non ottengono mai metodi, considera se undict[str, Foo]sarebbe stato più onesto.
deque: la lista che vuoi quando fai append e pop
from collections import deque
queue: deque[int] = deque([1, 2, 3])
queue.append(4) # lato destro: [1, 2, 3, 4]
queue.appendleft(0) # lato sinistro: [0, 1, 2, 3, 4]
queue.pop() # 4, rimosso da destra
queue.popleft() # 0, rimosso da sinistra
Una list ha append e pop veloci all’estremità destra e lenti a quella sinistra, perché rimuovere dal fronte sposta ogni altro elemento. Una deque (pronunciato “deck”) ha operazioni O(1) a entrambe le estremità.
Usala quando:
- Ti serve una coda FIFO (
append+popleft). - Ti serve uno stack LIFO — in realtà una list va benissimo per questo.
- Ti serve una sliding window con una lunghezza massima fissa:
recent: deque[float] = deque(maxlen=100)
for value in stream:
recent.append(value) # il più vecchio viene auto-evacuato quando len > 100
if len(recent) == 100:
moving_avg: float = sum(recent) / 100
maxlen è la feature sottovalutata. La deque scarta silenziosamente la voce più vecchia quando supererebbe il limite. Perfetto per medie mobili, log a rotazione, “ultimi N eventi”.
Se ti ritrovi a fare lst.pop(0) o lst.insert(0, x) su una lista lunga, passa a una deque.
namedtuple: una tupla con nomi di campo
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p: Point = Point(3, 4)
p.x # 3
p.y # 4
p[0] # 3 -- funziona anche come tupla
x, y = p # l'unpacking di tupla funziona ancora
È una tupla — immutabile, hashable, comparabile, unpackable — ma con accesso ad attributi per nome. namedtuple è stata per anni la risposta a “voglio una piccola struct di sola lettura”.
In Python moderno (3.7+), la risposta è di solito @dataclass(frozen=True), a meno che non ti serva specificamente il comportamento di tupla:
- Unpacking per posizione
- Confronto
==per valore di tupla - Lavorare con codice che si aspetta una sequenza (una riga di database, un’API basata su unpacking)
Per tutto il resto, dataclass è più flessibile. C’è anche typing.NamedTuple per la stessa idea con type hint cotti dentro la sintassi della classe:
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
Stesso comportamento a runtime di collections.namedtuple, più piacevole da scrivere.
OrderedDict: per lo più ridondante dalla 3.7
Prima di Python 3.7, dict non garantiva l’ordine di inserimento. OrderedDict sì. Dalla 3.7, il dict regolare preserva l’ordine di inserimento come garanzia del linguaggio. Quindi OrderedDict è per lo più storico.
Ha ancora due feature uniche:
move_to_end(key)emove_to_end(key, last=False)per riposizionare le voci.- Il confronto
==considera l’ordine.OrderedDict([("a", 1), ("b", 2)]) != OrderedDict([("b", 2), ("a", 1)]), ma con dict regolari sarebbero uguali.
Per il 99% del codice: usa solo dict. OrderedDict esiste per quando ti servono quelle due feature.
@dataclass: la struct moderna
Questa è quella grossa. @dataclass è stata aggiunta in Python 3.7 (PEP 557) e sostituisce un’enorme fetta di boilerplate.
Prima:
class Order:
def __init__(self, id: int, customer: str, total: float, paid: bool = False) -> None:
self.id = id
self.customer = customer
self.total = total
self.paid = paid
def __repr__(self) -> str:
return f"Order(id={self.id!r}, customer={self.customer!r}, total={self.total!r}, paid={self.paid!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Order):
return NotImplemented
return (self.id, self.customer, self.total, self.paid) == \
(other.id, other.customer, other.total, other.paid)
Dopo:
from dataclasses import dataclass
@dataclass
class Order:
id: int
customer: str
total: float
paid: bool = False
Il decoratore ispeziona le annotazioni della classe e genera __init__, __repr__ ed __eq__ automaticamente. Scrivi quello che è davvero unico della classe — i campi, i loro tipi, i loro default — e salti il resto.
o = Order(id=1, customer="Marco", total=49.99)
print(o) # Order(id=1, customer='Marco', total=49.99, paid=False)
o.paid = True
o == Order(1, "Marco", 49.99, True) # True
Le varianti che vale la pena conoscere
frozen=True — immutabile, hashable. Usalo per value object, chiavi di dizionario, qualunque cosa che non dovrebbe mutare.
@dataclass(frozen=True)
class Coord:
x: float
y: float
c = Coord(1.0, 2.0)
c.x = 5.0 # FrozenInstanceError
points: set[Coord] = {Coord(1, 2), Coord(3, 4)} # funziona perché frozen è hashable
slots=True (3.10+) — genera __slots__, risparmiando memoria e velocizzando leggermente l’accesso agli attributi. Utile quando hai milioni di istanze. Il trade-off è niente assegnazione dinamica di attributi al di fuori dei campi dichiarati.
@dataclass(slots=True)
class Tick:
timestamp: float
price: float
volume: int
kw_only=True (3.10+) — forza tutti i campi a essere solo per parola chiave alla costruzione. Utile quando una classe ha molti campi e la costruzione posizionale sarebbe illeggibile.
@dataclass(kw_only=True)
class HttpRequest:
url: str
method: str = "GET"
headers: dict[str, str] | None = None
timeout: float = 30.0
# Si chiama come: HttpRequest(url="...", method="POST")
Puoi anche marcare singoli campi come kw-only con il sentinel KW_ONLY: vedi la documentazione quando ti serve.
Default mutabili: l’unica trappola
Non fare così:
@dataclass
class Shopping:
items: list[str] = [] # ValueError alla definizione della classe
Python lo cattura per te, perché ogni istanza condividerebbe la stessa lista. Usa field(default_factory=list):
from dataclasses import dataclass, field
@dataclass
class Shopping:
items: list[str] = field(default_factory=list)
notes: dict[str, str] = field(default_factory=dict)
La factory viene chiamata fresca per ogni nuova istanza.
Pydantic vs dataclass
Una domanda comune. Entrambi ti permettono di dichiarare campi con tipi. La differenza:
@dataclassnon fa validazione. Se diciid: int, otterrai qualunque cosa il chiamante passi. Passa"5"e ottieni una stringa con un type hint che mente.- Pydantic valida e fa coercion. Passa
"5"a un modello Pydantic che si aspettaint, e ottieni5. Passa"hello"e ottieni unValidationError.
Usa Pydantic quando:
- Stai parsando input non fidato (body HTTP, file JSON, file di configurazione).
- Sei al confine di un’API e vuoi garanzie.
- Vuoi generazione di JSON schema, serializzazione, validatori sui campi.
Usa @dataclass quando:
- Stai modellando stato interno e il tuo codice è l’unico produttore.
- Non vuoi una dipendenza di terze parti.
- Non ti serve validazione; i type hint qui sono documentazione, non enforcement.
In un servizio tipico: Pydantic per i modelli di richiesta e risposta, dataclass per tutto l’interno. attrs è la libreria di terze parti più vecchia che ha ispirato entrambi — ancora eccellente, ancora ampiamente usata, ma se parti da zero oggi la scelta è @dataclass (stdlib) o Pydantic (validazione).
Esempio reale: modello di risposta API
from dataclasses import dataclass, field
from datetime import datetime, timezone
from collections import Counter, defaultdict
@dataclass(frozen=True, slots=True)
class LogEntry:
timestamp: datetime
level: str
service: str
message: str
@dataclass
class LogSummary:
period_start: datetime
period_end: datetime
total: int = 0
by_level: Counter[str] = field(default_factory=Counter)
by_service: defaultdict[str, list[str]] = field(
default_factory=lambda: defaultdict(list)
)
def summarize(entries: list[LogEntry]) -> LogSummary:
if not entries:
now: datetime = datetime.now(tz=timezone.utc)
return LogSummary(period_start=now, period_end=now)
summary = LogSummary(
period_start=min(e.timestamp for e in entries),
period_end=max(e.timestamp for e in entries),
total=len(entries),
)
for e in entries:
summary.by_level[e.level] += 1
if e.level == "ERROR":
summary.by_service[e.service].append(e.message)
return summary
LogEntry è frozen e slotted perché ce ne sono milioni e non cambiano. LogSummary è mutabile perché lo costruiamo a poco a poco. Counter per le frequenze dei livelli. defaultdict(list) per raggruppare i messaggi di errore per servizio. Type hint dappertutto, niente boilerplate, la struttura dei dati visibile a colpo d’occhio.
Questo è l’obiettivo: meno codice, più senso per riga.
Questo conclude il Modulo 2 — Padronanza della standard library. Il Modulo 3 riprende con iteratori, generator, e il toolkit itertools che fa sembrare il codice Python meno una sequenza di for-loop e più una pipeline.
Riferimenti: collections — Container datatypes, dataclasses — Data Classes, typing.NamedTuple, PEP 557 — Data Classes. Recuperato il 2026-05-01.