Python, dalle fondamenta Lezione 9 / 60

collections + dataclass: le piccole strutture dati che ti servono

Counter, defaultdict, deque, namedtuple -- e la dataclass che le sostituisce tutte nella maggior parte dei casi.

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 primitivo
  • defaultdict(set): set vuoto auto per raggruppamenti deduplicati
  • defaultdict(dict): dict annidati; chiave una volta, ottieni un dict, infilaci dentro le cose
  • defaultdict(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) o Counter non appena vedono il loop. Vale la pena chiederglielo. La trappola opposta è più comune: gli assistenti tendono a sovraprodurre @dataclass per 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 un dict[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) e move_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:

  • @dataclass non fa validazione. Se dici id: 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 aspetta int, e ottieni 5. Passa "hello" e ottieni un ValidationError.

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.

Cerca