Python, de la zero Lecția 4 / 60

Iterators, generators, comprehensions: cum le desparti

Trei concepte inrudite pe care majoritatea celor care scriu Python le amesteca. Diferentele conteaza cand conteaza memoria si cand conteaza laziness-ul.

Dacă ai scris Python mai mult de o săptămână, le-ai folosit pe toate trei. Ai scris for x in something:. Ai scris [x*2 for x in xs]. Poate chiar ai scris yield. Și dacă cineva te-ar întreba „care e diferența dintre un iterable, un iterator, un generator și o comprehension”, probabil ai face ce face toată lumea: ai miji ochii, ai mormăi ceva despre laziness și ai schimba subiectul.

E în regulă pentru cod ocazional. Nu mai e în regulă în ziua în care trebuie să treci un CSV de 50 GB printr-un container cu memorie limitată sau să depanezi un generator care rulează misterios de două ori, fără rezultate a doua oară. Cele patru cuvinte înseamnă patru lucruri diferite. Astăzi le despărțim.

Iterable vs iterator: versiunea în două propoziții

Un iterable e orice poți pune după in într-un for. Liste, tupluri, dicționare, mulțimi, string-uri, fișiere, range-uri, clase proprii care implementează __iter__. Lucrul în sine nu ține poziția; poți face loop pe el de câte ori vrei.

Un iterator e obiectul cu stare, de unică folosință, care chiar parcurge un iterable. Ține minte unde a ajuns. Când se termină, ridică StopIteration. Odată epuizat, gata, e gata: îl repornești obținând unul nou.

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

it = iter(xs)                      # iterator, abia născut
print(next(it))                    # 10
print(next(it))                    # 20
print(next(it))                    # 30
print(next(it))                    # ridică StopIteration

for x in xs: e zahăr sintactic pentru „cheamă iter(xs) o dată, apoi cheamă next() pe el într-un loop până la StopIteration, apoi oprește-te”. Lista xs e iterable-ul. Obiectul returnat de iter(xs) e iterator-ul. Nu sunt același obiect, iar distincția e cea care îți permite să faci loop peste aceeași listă de două ori, fără ca ea să fie „consumată”.

O subtilitate: un iterator e și un iterable (are __iter__, care se returnează pe sine). De aceea funcționează for x in some_generator:. Dar îl poți parcurge o singură dată:

g = (x*2 for x in [1, 2, 3])       # generator expression
print(list(g))                     # [2, 4, 6]
print(list(g))                     # [] - deja epuizat

Dacă ai scris vreodată o funcție care returnează un generator și ai încercat să iterezi peste rezultat de două ori, asta e bug-ul.

Protocolul iterator pe clase proprii

Dacă vrei ca propria clasă să funcționeze într-un for, ai nevoie de două metode:

from typing import Iterator


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

    def __iter__(self) -> Iterator[int]:
        # Returnează un iterator nou de fiecare dată - asta e ce face
        # CountDown un *iterable*, nu 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

E multă ceremonie pentru „numără invers”. Citindu-l, vezi de ce există yield.

Generatoare: iteratoare fără boilerplate

Un generator e un iterator construit dintr-o funcție care are yield în ea. Interpretorul face plumbing-ul __iter__/__next__/StopIteration pentru tine.

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

Același comportament ca la clasa de mai sus. Cam o cincime din cod. Ăsta e motivul pentru care clasele iterator sunt rare în Python-ul modern: le scoți la suprafață doar când starea e cu adevărat complexă (de exemplu, o traversare de arbore care trebuie reluată în mijlocul recursiei sau un iterator care are alte metode în afară de __next__).

Când funcția ajunge la yield, se oprește. Variabilele locale, program counter-ul, stack-ul, toate înghețate. Următorul apel next() continuă din exact acel punct. Când funcția se întoarce (sau ajunge la final), generatorul ridică StopIteration automat.

yield from permite unui generator să delege altuia:

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]

Fără yield from ai scrie for x in range(3): yield x: în regulă, dar mai puțin direct.

Comprehensions: zahăr sintactic pentru construit lucruri

O list comprehension construiește o listă cu un loop compact:

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]

Echivalent cu a scrie loop-ul și apelurile .append() de mână, dar mai scurt și ușor mai rapid (interpretorul îl optimizează). Aceeași sintaxă există pentru mulțimi și dicționare:

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}

Nu există tuple comprehension. Sintaxa (x*2 for x in xs) arată ca una, dar nu e: e o generator expression. Ca să construiești un tuplu dintr-o comprehension scrii tuple(x*2 for x in xs).

Diferența de memorie: list vs generator expression

Asta e singura chestie practică ce contează cel mai mult.

# List comprehension - construiește toată lista în memorie
squares_list = [x * x for x in range(10_000_000)]
# Memorie: ~80 MB pentru o listă de 10 milioane de int. Alocată din start.

# Generator expression - nu construiește nimic, livrează la cerere
squares_gen = (x * x for x in range(10_000_000))
# Memorie: ~200 de octeți. Doar obiectul generator.

Dacă vei consuma fiecare element și vrei acces aleator mai târziu, lista e ok. Dacă le vei consuma o singură dată, în ordine, generator-ul e aproape întotdeauna alegerea corectă.

Exemplul clasic: streaming-ul unui fișier mare.

from typing import Iterator


def numeric_columns(path: str, col: int) -> Iterator[float]:
    with open(path, encoding="utf-8") as f:
        next(f)                          # sări peste header
        for line in f:                   # fișierele sunt iteratoare de linii
            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)

Programul ăsta calculează media unei coloane dintr-un fișier de 50 GB folosind câțiva kiloocteți de RAM. Obiectul fișier e el însuși un iterator: for line in f: citește o linie la un moment dat. Funcția generator trece acele linii printr-o transformare, una câte una. Nimic nu se materializează. Asta e forma fiecărui pipeline de date cu limită de memorie pe care îl vei scrie vreodată în Python.

Același cod cu o list comprehension ar încerca să încarce toți cei 50 GB într-o listă Python. Container-ul tău ar muri OOM la 30%.

itertools: trusa standard

itertools e un modul de blocuri de construcție bazate pe generatoare. Câteva pe care le folosesc săptămânal:

import itertools

# chain: concatenează iterabile lazy
combined = itertools.chain([1, 2], [3, 4], [5])
# 1, 2, 3, 4, 5 - fără să construiască vreodată o listă combinată

# islice: feliază un iterator fără să-l convertești în listă
first_ten = list(itertools.islice(numeric_columns("huge.csv", 4), 10))
# Citește exact 10 linii din fișier, apoi se oprește.

# tee: împarte un iterator în N iteratoare independente
a, b = itertools.tee(numeric_columns("huge.csv", 4), 2)
# a și b pot fi consumate independent - dar tee buffer-izează valorile
# pe care nici unul nu le-a consumat încă. Dacă a o ia mult înaintea
# lui b, te întorci la a ține majoritatea datelor în memorie.

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

Nu enumerez tot modulul: help(itertools) e ce-ți trebuie când ai nevoie de o unealtă. Dar odată ce vezi pattern-ul (totul e un generator, totul se compune), încetezi să mai scrii loop-uri manuale pentru lucruri ca „iterează în perechi” sau „ia fiecare al n-lea element”.

Când să folosești care

Un arbore de decizie aproximativ:

  • Ai nevoie de o colecție finită pe care o vei indexa sau itera de mai multe ori? Folosește o listă sau o list comprehension.
  • Ai nevoie să transformi-și-iterezi-o-dată peste ceva potențial mare? Generator expression sau funcție generator.
  • Ai nevoie de comportament dincolo de __next__, să zicem un iterator cu o metodă reset() sau cu stare suplimentară? Scrie o clasă.
  • Compui transformări standard? itertools mai întâi, cod custom doar când nimic nu se potrivește.

Greșeala de evitat: să scrii [x for x in big_thing if cond] și apoi să iterezi imediat peste el o dată. Aia e o generator expression deghizată: scoate parantezele drepte, salvează memoria.

Capcane comune

Generatoarele sunt de unică folosință. Dacă ai nevoie să parcurgi aceleași date de două ori, ori le stochezi într-o listă, ori chemi funcția generator de două ori (ceea ce îți dă un generator nou de fiecare dată).

Late binding în closures interioare comprehension-urilor. Expresia unei comprehension e evaluată lazy pentru generator expressions: variabila de loop dintr-o list comprehension e ok, dar într-o generator expression iterable-ul e legat la momentul creării, în timp ce restul e lazy. Capcana clasică:

gens = [(x * i for x in range(3)) for i in range(3)]
# Fiecare generator captează `i` prin referință. Până să iterezi,
# `i` e 2. Toate cele trei generatoare livrează aceiași multipli de 2.

Dacă vrei chiar ca fiecare generator să captureze valoarea curentă a lui i, pasează-o ca argument default sau folosește o funcție factory.

StopIteration în interiorul unui generator îl termină tăcut. De la PEP 479 (Python 3.7+), un StopIteration care scapă din interiorul unui generator e convertit în RuntimeError în loc să încheie misterios iterația. Schimbare bună, dar merită știută dacă citești cod mai vechi care se baza pe comportamentul vechi.

Asta a fost despre iterators, generators și comprehensions, nu mai sunt amestecate. Lecția următoare: decorators. Pattern-ul care învelește jumătate din codul Python third-party pe care îl imporți.


Citations (consultat 2026-05-01):

Caută