Python, de la zero Lecția 9 / 60

collections + dataclass: structurile mici de date de care ai nevoie

Counter, defaultdict, deque, namedtuple si dataclass-ul care le inlocuieste pe toate in cele mai multe cazuri.

E un impozit pe care îl plătești în orice limbaj fiindcă nu cunoști biblioteca standard suficient de bine. În Python apare cel mai des în două locuri: oameni care scriu douăzeci de linii de jonglat cu dicționare, când Counter sau defaultdict ar fi rezolvat în două, și oameni care scriu metode __init__ de patru linii pe care @dataclass le-ar fi generat pentru ei.

Lecția asta e despre structurile mici de date. Niciuna nu e palpitantă. Toate sunt timp pe care nu-l mai pierzi cu boilerplate.

Counter: numararea de lucruri 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  — never raises KeyError

Counter e o subclasă de dict care pune implicit 0 pentru cheile care lipsesc. Îi dai orice iterabil de elemente hashable și primești înapoi frecvența fiecăruia. Metoda most_common(n) e feature-ul ucigaș: sortat, descrescător, primele n. Niciodată nu mai scrii sorted(items, key=lambda x: -x[1])[:n].

Exemplu real: să afli ce IP-uri lovesc cel mai des endpoint-ul de erori:

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))

Counter-ele se compun și cu 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})  — only positive counts kept
a & b   # Counter({'a': 1, 'b': 2, 'c': 1})  — min (intersection)
a | b   # Counter({'a': 2, 'b': 2, 'c': 2})  — max (union)

Semantică de multiset. Util pentru lucruri de genul „câte din fiecare token apar în ambele documente”.

defaultdict: chei auto-initializate

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) creează valoarea apelând factory() prima dată când accesezi o cheie lipsă. Cu list primești o listă goală gata pentru .append(). Pattern-ul „grupează lucrurile astea după o cheie” e atât de comun, încât ăsta e de departe cel mai folosit membru al modulului collections.

Alte factory-uri:

  • defaultdict(int), auto-zero, ca un Counter primitiv
  • defaultdict(set), set gol pentru grupare deduplicată
  • defaultdict(dict), dicționare imbricate; cheia o dată, primești un dict, bagi lucruri în el
  • defaultdict(lambda: "unknown"), orice callable care întoarce valoarea implicită

Alternativa fără defaultdict:

groups: dict[str, list[int]] = {}
for user_id, tag in pairs:
    if tag not in groups:
        groups[tag] = []
    groups[tag].append(user_id)

Sau cu setdefault:

groups.setdefault(tag, []).append(user_id)

Ambele funcționează. defaultdict e mai curat odată ce corpul buclei tale are mai mult decât un singur append.

Notă despre asistența AI. Asistenții moderni de cod AI sunt de încredere în recunoașterea pattern-urilor „grupez lucruri” sau „număr lucruri” și sugerează defaultdict(list) sau Counter odată ce văd bucla. Merită prompt-ul. Capcana inversă e mai comună: asistenții tind să supraproducă @dataclass pentru orice date structurate, inclusiv în cazuri unde un dict simplu ar fi fost mai potrivit. Dacă te trezești cu cinci dataclass-uri care țin câte trei câmpuri și nu primesc niciodată metode, întreabă-te dacă un dict[str, Foo] ar fi fost mai onest.

deque: lista pe care o vrei cand faci append si pop

from collections import deque

queue: deque[int] = deque([1, 2, 3])

queue.append(4)        # right side: [1, 2, 3, 4]
queue.appendleft(0)    # left side:  [0, 1, 2, 3, 4]
queue.pop()            # 4, removed from right
queue.popleft()        # 0, removed from left

Un list are append și pop rapide la dreapta și lente la stânga, fiindcă scoaterea din față mută toate celelalte elemente. Un deque (pronunțat „dec”) are operații O(1) la ambele capete.

Folosește-l când:

  • Ai nevoie de o coadă FIFO (append + popleft).
  • Ai nevoie de o stivă LIFO, deși un list merge bine pentru asta.
  • Ai nevoie de o fereastră glisantă cu lungime maximă fixă:
recent: deque[float] = deque(maxlen=100)
for value in stream:
    recent.append(value)  # oldest auto-evicted when len > 100
    if len(recent) == 100:
        moving_avg: float = sum(recent) / 100

maxlen e feature-ul subapreciat. Deque-ul aruncă în liniște cea mai veche intrare când ar depăși limita. Perfect pentru medii mobile, log-uri rulante, „ultimele N evenimente”.

Dacă te trezești făcând lst.pop(0) sau lst.insert(0, x) pe o listă lungă, treci la un deque.

namedtuple: un tuplu cu nume de campuri

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])

p: Point = Point(3, 4)
p.x          # 3
p.y          # 4
p[0]         # 3 — also works as a tuple
x, y = p     # tuple unpacking still works

E un tuplu: imutabil, hashable, comparabil, despachetabil, dar cu acces pe atribute denumite. namedtuple a fost răspunsul timp de ani la „vreau un struct mic, read-only”.

În Python modern (3.7+), răspunsul e de obicei @dataclass(frozen=True) în loc, decât dacă ai nevoie strict de comportamentul de tip tuplu:

  • Despachetare după poziție
  • Comparație == după valoarea de tuplu
  • Lucru cu cod care așteaptă o secvență (un rând de bază de date, un API bazat pe despachetare)

Pentru orice altceva, dataclass e mai flexibil. Există și typing.NamedTuple pentru aceeași idee, cu hint-urile de tip integrate în sintaxa de clasă:

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

Comportament identic la runtime cu collections.namedtuple, mai plăcut de scris.

OrderedDict: in mare parte redundant din 3.7

Înainte de Python 3.7, dict nu garanta ordinea de inserare. OrderedDict da. Din 3.7, dict-ul obișnuit păstrează ordinea de inserare ca garanție de limbaj. Așa că OrderedDict e în mare parte istoric.

Mai are două feature-uri unice:

  • move_to_end(key) și move_to_end(key, last=False) pentru repoziționarea intrărilor.
  • Comparația == ține cont de ordine. OrderedDict([("a", 1), ("b", 2)]) != OrderedDict([("b", 2), ("a", 1)]), dar cu dict-uri obișnuite ar fi egale.

Pentru 99% din cod: folosește dict. OrderedDict există când ai nevoie de cele două feature-uri.

@dataclass: struct-ul modern

Ăsta e mare. @dataclass a fost adăugat în Python 3.7 (PEP 557) și înlocuiește o porție uriașă de boilerplate.

Înainte:

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)

După:

from dataclasses import dataclass

@dataclass
class Order:
    id: int
    customer: str
    total: float
    paid: bool = False

Decoratorul inspectează adnotările clasei și generează automat __init__, __repr__ și __eq__. Scrii ce e de fapt unic în clasă: câmpurile, tipurile lor, valorile implicite, și sari peste rest.

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

Variantele care merita stiute

frozen=True, imutabil, hashable. Folosește pentru obiecte de valoare, chei de dicționar, orice n-ar trebui să mute.

@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)}  # works because frozen is hashable

slots=True (3.10+), generează __slots__, salvând memorie și accelerând ușor accesul la atribute. Util când ai milioane de instanțe. Compromisul e că nu poți atribui dinamic atribute în afara câmpurilor declarate.

@dataclass(slots=True)
class Tick:
    timestamp: float
    price: float
    volume: int

kw_only=True (3.10+), forțează toate câmpurile să fie keyword-only la construcție. Bun când o clasă are multe câmpuri și construcția pozițională ar fi ilizibilă.

@dataclass(kw_only=True)
class HttpRequest:
    url: str
    method: str = "GET"
    headers: dict[str, str] | None = None
    timeout: float = 30.0

# Must call as: HttpRequest(url="...", method="POST")

Poți marca și câmpuri individuale ca kw-only cu sentinela KW_ONLY, vezi documentația când ai nevoie.

Valori implicite mutabile: singura capcana

Nu face asta:

@dataclass
class Shopping:
    items: list[str] = []   # ValueError on class definition

Python prinde asta pentru tine, fiindcă fiecare instanță ar împărți aceeași listă. Folosește 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)

Factory-ul se apelează din nou pentru fiecare instanță nouă.

Pydantic vs dataclass

Întrebare comună. Ambele te lasă să declari câmpuri cu tipuri. Diferența:

  • @dataclass nu face validare. Dacă spui id: int, primești orice îți pasează apelantul. Dă-i "5" și primești un string cu un type hint care minte.
  • Pydantic validează și convertește. Pasează "5" la un model Pydantic care așteaptă int și primești 5. Pasează "hello" și primești un ValidationError.

Folosește Pydantic când:

  • Parsezi input neîncrezibil (HTTP body, JSON, fișier de configurare).
  • Ești la o graniță de API și vrei garanții.
  • Vrei generare de schemă JSON, serializare, validatori pe câmpuri.

Folosește @dataclass când:

  • Modelezi stare internă, iar codul tău e singurul producător.
  • Nu vrei o dependență terță.
  • Nu ai nevoie de validare; type hint-urile sunt documentație aici, nu enforcement.

Într-un serviciu tipic: Pydantic pentru modelele de request și response, dataclass pentru tot ce e intern. attrs e biblioteca terță mai veche care le-a inspirat pe ambele, încă excelentă, încă folosită pe scară largă, dar dacă pornești de la zero azi alegerea e @dataclass (stdlib) sau Pydantic (validare).

Exemplu real: model de raspuns 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 e frozen și slotted, fiindcă sunt milioane de instanțe și nu se schimbă. LogSummary e mutabil, fiindcă îl construim treptat. Counter pentru frecvențele de nivel. defaultdict(list) pentru gruparea mesajelor de eroare după serviciu. Type hint-uri peste tot, fără boilerplate, structura datelor e vizibilă dintr-o ochire.

Ăsta e scopul: mai puțin cod, mai mult sens pe linie.


Asta încheie Modulul 2, Stăpânirea bibliotecii standard. Modulul 3 reia cu iteratori, generatoare și kit-ul itertools, care face codul Python să se simtă mai puțin ca o secvență de bucle for și mai mult ca un pipeline.

Referințe: collections — Container datatypes, dataclasses — Data Classes, typing.NamedTuple, PEP 557 — Data Classes. Consultat 2026-05-01.

Caută