Geradores e iteradores em Python: lazy evaluation na prática

1. Fundamentos de Iteradores em Python

O protocolo de iteração em Python é a base sobre a qual todo o sistema de loops e compreensões de coleções é construído. Um objeto é considerado iterável quando implementa o método __iter__(), que retorna um iterador. O iterador, por sua vez, implementa __next__(), que retorna o próximo elemento ou levanta StopIteration quando não há mais elementos.

Vamos construir um iterador personalizado do zero:

class ContadorInfinito:
    def __init__(self, inicio=0):
        self.valor = inicio

    def __iter__(self):
        return self

    def __next__(self):
        valor_atual = self.valor
        self.valor += 1
        return valor_atual

# Uso
contador = ContadorInfinito(10)
print(next(contador))  # 10
print(next(contador))  # 11

A diferença fundamental entre objetos iteráveis e iteradores é sutil mas crucial. Uma lista é iterável, mas não é um iterador:

lista = [1, 2, 3]
print(hasattr(lista, '__next__'))  # False
print(hasattr(lista, '__iter__'))  # True

iterador = iter(lista)
print(hasattr(iterador, '__next__'))  # True
print(next(iterador))  # 1

2. Geradores: Sintaxe Simplificada para Lazy Evaluation

Geradores são a forma mais elegante de criar iteradores em Python. A palavra-chave yield transforma uma função comum em uma função geradora, que retorna um objeto gerador. Cada chamada a next() executa a função até o próximo yield, suspendendo seu estado entre chamadas.

def gerar_numeros_pares(limite):
    n = 0
    while n < limite:
        yield n
        n += 2

pares = gerar_numeros_pares(10)
print(list(pares))  # [0, 2, 4, 6, 8]

A economia de memória é dramática para grandes volumes:

import sys

# Lista: carrega tudo na memória
lista_grande = [x for x in range(10_000_000)]
print(sys.getsizeof(lista_grande))  # ~80 MB

# Gerador: praticamente zero de memória
gerador_grande = (x for x in range(10_000_000))
print(sys.getsizeof(gerador_grande))  # ~112 bytes

3. Expressões Geradoras vs List Comprehensions

As expressões geradoras usam parênteses em vez de colchetes e produzem valores sob demanda:

# List comprehension: materializa todos os valores
quadrados_lista = [x**2 for x in range(10)]
print(type(quadrados_lista))  # <class 'list'>

# Generator expression: lazy evaluation
quadrados_gerador = (x**2 for x in range(10))
print(type(quadrados_gerador))  # <class 'generator'>

Para processar arquivos enormes, a diferença é crítica:

def processar_arquivo_grande(caminho):
    with open(caminho, 'r') as arquivo:
        linhas = (linha.strip() for linha in arquivo)
        linhas_validas = (linha for linha in linhas if linha.startswith('ERROR'))
        for linha in linhas_validas:
            yield processar_log(linha)

# Uso: nunca carrega o arquivo inteiro na memória
for resultado in processar_arquivo_grande('servidor.log'):
    print(resultado)

4. Padrões Avançados com Geradores

Geradores podem ser encadeados para criar pipelines de processamento elegantes:

numeros = (x for x in range(100))
pares = (x for x in numeros if x % 2 == 0)
quadrados = (x**2 for x in pares)
primeiros_5 = list(quadrados)[:5]
print(primeiros_5)  # [0, 4, 16, 36, 64]

O yield from permite delegar a subgeradores:

def gerar_sequencia():
    yield from range(3)
    yield from 'ABC'

print(list(gerar_sequencia()))  # [0, 1, 2, 'A', 'B', 'C']

Para geradores infinitos, itertools.islice é essencial:

from itertools import islice

def fibonacci_infinito():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

primeiros_10 = list(islice(fibonacci_infinito(), 10))
print(primeiros_10)  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

5. Corrotinas e Envio de Dados com send()

O método send() permite comunicação bidirecional com geradores:

def acumulador():
    total = 0
    while True:
        valor = yield total
        if valor is not None:
            total += valor

acc = acumulador()
next(acc)  # Inicializa o gerador
print(acc.send(10))  # 10
print(acc.send(20))  # 30
print(acc.send(5))   # 35

6. Lazy Evaluation em Processamento de Dados Reais

Um pipeline ETL completo com geradores:

import csv
from itertools import islice

def ler_csv(caminho):
    with open(caminho, 'r') as arquivo:
        leitor = csv.DictReader(arquivo)
        for linha in leitor:
            yield linha

def filtrar_por_data(linhas, ano):
    for linha in linhas:
        if linha['data'].startswith(str(ano)):
            yield linha

def transformar_para_json(linhas):
    for linha in linhas:
        yield {
            'id': int(linha['id']),
            'valor': float(linha['valor']),
            'data': linha['data']
        }

# Pipeline lazy
dados = ler_csv('vendas.csv')
dados_2023 = filtrar_por_data(dados, 2023)
dados_transformados = transformar_para_json(dados_2023)
primeiros_100 = list(islice(dados_transformados, 100))

7. Performance e Armadilhas Comuns

Geradores não são sempre a melhor escolha:

# Problema: acesso repetido esgota o gerador
def dados():
    for i in range(5):
        yield i

gen = dados()
print(sum(gen))  # 10
print(sum(gen))  # 0 (gerador esgotado!)

# Solução: converter para lista se precisar reutilizar
gen = list(dados())
print(sum(gen))  # 10
print(sum(gen))  # 10

8. Integração com Bibliotecas e Frameworks

Geradores são excelentes para streaming em web scraping:

import requests
from bs4 import BeautifulSoup

def scrape_paginas(urls):
    for url in urls:
        resposta = requests.get(url)
        soup = BeautifulSoup(resposta.text, 'html.parser')
        yield soup.title.text

# Processa uma página por vez, sem carregar todas
for titulo in scrape_paginas(['https://exemplo.com/pagina1', 'https://exemplo.com/pagina2']):
    print(titulo)

Em frameworks web como Flask, geradores permitem streaming de respostas:

from flask import Flask, Response
import time

app = Flask(__name__)

def gerar_dados():
    for i in range(10):
        yield f"Dado {i}\n"
        time.sleep(1)

@app.route('/stream')
def stream():
    return Response(gerar_dados(), mimetype='text/plain')

Referências