Geradores são um dos recursos mais elegantes e eficientes do Python. Eles permitem que você itere sobre sequências de dados sem precisar armazená-las completamente na memória. Isso é especialmente útil quando você lida com grandes volumes de dados, como arquivos enormes ou streams de dados infinitos. Nesta aula, vamos mergulhar no conceito de geradores, entender como a palavra-chave yield transforma funções comuns em geradores, e ver como aplicar isso na prática para economizar memória e construir pipelines de processamento.

Vamos começar do básico: o que é um gerador, como criar um, e depois avançar para técnicas mais sofisticadas, como pipelines. Ao final, você terá uma compreensão sólida de quando e como usar geradores em seus projetos.

yield

A palavra-chave yield é o coração dos geradores. Quando uma função contém yield, ela se torna uma generator function. Ao chamar essa função, você não obtém um valor único, mas sim um objeto gerador que pode ser iterado. Cada vez que o iterador avança, a função executa até encontrar yield, que produz um valor e pausa a execução, mantendo o estado da função (variáveis locais, posição no código, etc.). Na próxima iteração, a execução continua logo após o yield.

Isso é diferente de uma função comum que usa return: a função comum termina e perde todo o estado. O gerador, por outro lado, é uma máquina de estados que pode ser pausada e retomada. Vamos ver um exemplo simples:

def contador(maximo):
    n = 0
    while n < maximo:
        yield n
        n += 1

# Usando o gerador
for numero in contador(5):
    print(numero)

Neste código, contador é uma generator function. Quando você chama contador(5), ela retorna um objeto gerador, mas o corpo da função não é executado imediatamente. A execução começa quando você itera sobre ele (no for). A cada iteração, a função executa até o yield n, retorna o valor de n e pausa. Na próxima iteração, ela retoma de onde parou, incrementa n e continua o loop.

É importante notar que yield pode ser usado em qualquer função, mas a função se torna um gerador automaticamente. Você também pode usar yield para receber valores, usando o método send(), mas isso é um tópico mais avançado que vamos abordar brevemente.

Generator functions

Generator functions são funções que usam yield para produzir uma série de valores. Elas são definidas como funções normais, mas em vez de return, usam yield. A sintaxe é idêntica, mas o comportamento é diferente. Vamos explorar alguns exemplos.

Um exemplo clássico é a sequência de Fibonacci:

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

# Imprime os 10 primeiros números de Fibonacci
fib = fibonacci()
for _ in range(10):
    print(next(fib))

Observe que o gerador fibonacci é infinito: ele nunca para, mas você controla quantos valores deseja consumir. Isso é possível porque o gerador não armazena todos os valores; ele gera um por vez.

Outra característica das generator functions é que elas podem ter múltiplos yield em diferentes pontos do código:

def pares_e_impares(n):
    for i in range(n):
        if i % 2 == 0:
            yield f"par: {i}"
        else:
            yield f"ímpar: {i}"

for valor in pares_e_impares(5):
    print(valor)

Além disso, você pode usar yield from para delegar a iteração para outro gerador. Isso é útil para compor geradores:

def sub_gerador():
    yield 1
    yield 2

def principal():
    yield 0
    yield from sub_gerador()
    yield 3

for v in principal():
    print(v)  # 0, 1, 2, 3

As generator functions são extremamente flexíveis e podem ser usadas para criar sequências personalizadas, ler arquivos linha por linha, gerar números aleatórios, etc. Elas são uma alternativa mais limpa e econômica do que criar listas e retorná-las.

Economia de memória

O principal benefício dos geradores é a economia de memória. Quando você cria uma lista com todos os valores, eles são armazenados na memória de uma vez. Com um gerador, cada valor é produzido sob demanda e descartado após o uso. Isso é crucial quando você lida com grandes conjuntos de dados, como logs de servidor, arquivos de texto enormes ou sequências numéricas infinitas.

Considere o exemplo de ler um arquivo grande linha por linha. Se você usar readlines(), ele carrega todas as linhas na memória. Com um gerador, você pode ler uma linha de cada vez:

def ler_linhas(nome_arquivo):
    with open(nome_arquivo, 'r') as arquivo:
        for linha in arquivo:
            yield linha.strip()

# Uso: processa cada linha sem carregar o arquivo inteiro
for linha in ler_linhas('grande.txt'):
    print(linha)

Outra aplicação comum é gerar números primos. Calcular todos os primos até um número alto e armazená-los em uma lista seria inviável. Com um gerador, você pode gerar primos indefinidamente:

def gerador_primos():
    primos = []
    n = 2
    while True:
        for p in primos:
            if n % p == 0:
                break
        else:
            primos.append(n)
            yield n
        n += 1

# Gera os 20 primeiros primos
primos = gerador_primos()
for _ in range(20):
    print(next(primos))

A economia de memória também se reflete em tempo de execução: às vezes, gerar valores sob demanda é mais rápido do que construir uma lista grande primeiro. No entanto, há um trade-off: se você precisar acessar os valores várias vezes, talvez seja melhor armazená-los em uma lista. Mas para iteração única, os geradores são ideais.

Pipelines

Pipelines são uma técnica poderosa para processamento de dados em etapas, onde cada etapa é um gerador que consome dados do gerador anterior e produz novos dados. Isso é semelhante ao conceito de pipe em Unix, onde a saída de um comando é a entrada do próximo. Em Python, podemos construir pipelines encadeando geradores.

Vamos criar um pipeline que processa uma lista de números: primeiro filtra os pares, depois eleva ao quadrado e, por fim, soma os resultados. Cada etapa será um gerador:

def filtrar_pares(iteravel):
    for x in iteravel:
        if x % 2 == 0:
            yield x

def quadrado(iteravel):
    for x in iteravel:
        yield x ** 2

def somar(iteravel):
    total = 0
    for x in iteravel:
        total += x
    return total

# Construindo o pipeline
numeros = [1, 2, 3, 4, 5, 6]
pares = filtrar_pares(numeros)
quadrados = quadrado(pares)
resultado = somar(quadrados)
print(resultado)  # 4 + 16 + 36 = 56

Note que somar não é um gerador, mas uma função que consome o gerador quadrados. O pipeline é eficiente porque os dados fluem de um gerador para outro sem armazenar listas intermediárias.

Você também pode usar yield from para simplificar pipelines, mas o princípio é o mesmo. Pipelines são extremamente úteis em análise de dados, processamento de logs, ETL, e qualquer situação onde você precise transformar dados em várias etapas.

Um exemplo mais realista: processar um arquivo de log, filtrar linhas que contêm "ERROR", extrair a mensagem, e contar ocorrências de cada tipo de erro. Isso pode ser feito com geradores encadeados, economizando memória mesmo para arquivos gigantes.

Boas práticas e observações finais

Algumas boas práticas ao usar geradores:

  • Use geradores para sequências grandes ou infinitas, e quando a iteração for única.
  • Não tente acessar índices ou fazer fatiamento em geradores; se precisar, converta para lista, mas com cuidado.
  • Prefira yield from para delegar a outro gerador, pois é mais limpo.
  • Lembre-se de que geradores são consumíveis: depois de esgotados, não podem ser reiniciados. Se precisar reutilizar, recrie o gerador.
  • Use a biblioteca itertools que oferece muitos geradores prontos, como count, cycle, islice, etc.

Os geradores são uma ferramenta essencial no arsenal de um programador Python. Eles promovem código mais limpo, eficiente e escalável. Agora é hora de praticar!

Exercícios

  1. Exercício 1: Crie uma generator function chamada contagem_regressiva que recebe um número n e gera os números de n até 0 (inclusive). Teste com n=5.
  2. ✓ Resposta:
    def contagem_regressiva(n):
        while n >= 0:
            yield n
            n -= 1
    
    for valor in contagem_regressiva(5):
        print(valor)  # 5, 4, 3, 2, 1, 0
    
  3. Exercício 2: Escreva um gerador que produza os números da sequência de Fibonacci até um valor máximo (não infinito). Use yield.
  4. ✓ Resposta:
    def fibonacci_ate(maximo):
        a, b = 0, 1
        while a <= maximo:
            yield a
            a, b = b, a + b
    
    for valor in fibonacci_ate(100):
        print(valor)  # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
    
  5. Exercício 3: Use um gerador para ler um arquivo dados.txt e imprimir apenas as linhas que contêm a palavra "Python". Assuma que o arquivo existe.
  6. ✓ Resposta:
    def linhas_com_palavra(nome_arquivo, palavra):
        with open(nome_arquivo, 'r') as arquivo:
            for linha in arquivo:
                if palavra in linha:
                    yield linha.strip()
    
    for linha in linhas_com_palavra('dados.txt', 'Python'):
        print(linha)
    
  7. Exercício 4: Crie um pipeline que receba uma lista de números, filtre os múltiplos de 3, eleve ao cubo e some os resultados. Use geradores para as etapas de filtro e transformação.
  8. ✓ Resposta:
    def multiplos_de_3(iteravel):
        for x in iteravel:
            if x % 3 == 0:
                yield x
    
    def cubo(iteravel):
        for x in iteravel:
            yield x ** 3
    
    numeros = [1, 2, 3, 4, 5, 6, 7, 8, 9]
    resultado = sum(cubo(multiplos_de_3(numeros)))
    print(resultado)  # 27 + 216 + 729 = 972
    
  9. Exercício 5: Implemente um gerador que produza as combinações de 2 elementos de uma lista, sem repetição (ex.: para [1,2,3], deve gerar (1,2), (1,3), (2,3)).
  10. ✓ Resposta:
    def combinacoes_2(lista):
        n = len(lista)
        for i in range(n):
            for j in range(i+1, n):
                yield (lista[i], lista[j])
    
    for comb in combinacoes_2([1, 2, 3, 4]):
        print(comb)  # (1,2), (1,3), (1,4), (2,3), (2,4), (3,4)
    

Referências