Nesta aula, vamos aprofundar nosso conhecimento sobre geradores em Python, um dos recursos mais poderosos para processamento de dados sob demanda. Você já conhece o básico: funções com yield que produzem uma sequência de valores sem armazená-los todos na memória. Agora vamos explorar técnicas avançadas como delegação com yield from, expressões geradoras concisas, e uma breve introdução às coroutines, que permitem comunicação bidirecional com o gerador.

Dominar esses conceitos é essencial para escrever código eficiente e elegante, especialmente em cenários de streaming, pipelines de dados e programação assíncrona. Vamos começar entendendo como o yield from simplifica a composição de geradores, depois aprenderemos a criar geradores de forma compacta, e finalmente veremos como geradores podem atuar como coroutines, recebendo valores e comandos externos.

yield from

O yield from é um recurso introduzido no Python 3.3 que permite delegar a iteração a outro gerador (ou qualquer iterável) dentro de um gerador. Ele simplifica a composição de geradores, evitando loops aninhados explícitos e permitindo que o gerador externo encaminhe valores e exceções de forma transparente.

Antes do yield from, para combinar dois geradores, você precisaria de um loop for dentro do gerador. Com yield from, essa combinação fica mais limpa e eficiente, pois ele delega a iteração diretamente, otimizando o fluxo de dados e exceções.

Exemplo básico:

def generator_a():
    yield 1
    yield 2

def generator_b():
    yield 3
    yield 4

def combined():
    yield from generator_a()
    yield from generator_b()

print(list(combined()))  # [1, 2, 3, 4]

Sem yield from, o mesmo código seria:

def combined():
    for x in generator_a():
        yield x
    for x in generator_b():
        yield x

Além de simplificar, yield from também lida com o retorno de valores do gerador interno (através de return valor no gerador interno) e encaminha exceções e chamadas de send() e throw() de forma adequada, o que é crucial para coroutines.

Exemplo com retorno:

def inner():
    yield 10
    return 42

def outer():
    result = yield from inner()
    print(f"Valor retornado: {result}")

for x in outer():
    print(x)  # 10, depois imprime "Valor retornado: 42"

O yield from é amplamente utilizado em bibliotecas de programação assíncrona (como asyncio em versões antigas) e em pipelines de dados, onde é comum combinar múltiplos geradores.

Generator expressions

Generator expressions (ou expressões geradoras) são uma forma compacta de criar geradores, semelhante às list comprehensions, mas usando parênteses em vez de colchetes. Elas são ideais para quando você precisa de uma sequência que pode ser consumida uma única vez, economizando memória.

A sintaxe é: (expressão for item in iterável if condição). A principal diferença para list comprehensions é que a expressão geradora produz um objeto gerador, que não armazena todos os valores de uma vez, apenas os gera sob demanda.

Exemplo:

# List comprehension (cria lista completa)
squares_list = [x**2 for x in range(10)]

# Generator expression (cria gerador)
squares_gen = (x**2 for x in range(10))

print(type(squares_gen))  # <class 'generator'>
print(sum(squares_gen))   # 285 (soma dos quadrados)

As expressões geradoras são especialmente úteis quando você passa um iterável para funções como sum(), max(), min() ou any(), pois evitam a criação de listas intermediárias. Elas também podem ser usadas em chamadas de função sem precisar de parênteses extras, como em sum(x for x in range(10)).

Combinando com funções:

# Soma dos quadrados dos números pares até 100
soma = sum(x**2 for x in range(100) if x % 2 == 0)
print(soma)  # 166650

Uma limitação importante é que um gerador é esgotável: após consumir todos os valores, ele não pode ser reutilizado. Se precisar reutilizar, converta para lista ou crie um novo gerador.

Coroutines clássicas (visão geral)

Coroutines são geradores que podem receber valores e comandos externos durante sua execução. Enquanto um gerador comum apenas produz valores via yield, uma coroutine pode usar yield como expressão, capturando o valor enviado por send(). Isso permite comunicação bidirecional entre o código chamador e a função geradora.

O conceito de coroutines é fundamental para cooperação entre funções e é a base de muitos frameworks assíncronos. Em Python, as coroutines clássicas (antes da introdução de async/await) eram implementadas com geradores e o método send().

Exemplo básico de coroutine:

def coroutine():
    print("Coroutine iniciada")
    while True:
        value = yield
        print(f"Recebido: {value}")

coro = coroutine()
next(coro)  # Inicia até o primeiro yield
coro.send(10)  # Envia 10
coro.send(20)  # Envia 20
coro.close()   # Fecha a coroutine

Para iniciar uma coroutine, é necessário chamar next() (ou send(None)) para que ela avance até o primeiro yield. Depois, cada send(valor) retoma a execução com aquele valor atribuído à expressão yield.

Além de send(), os geradores têm throw() para lançar exceções dentro do gerador e close() para encerrá-lo. Esses métodos permitem controlar a execução de forma granular.

Exemplo com acumulador:

def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            continue
        total += value

acc = accumulator()
next(acc)  # Inicia
print(acc.send(10))  # 10
print(acc.send(5))   # 15
print(acc.send(3))   # 18

Coroutines clássicas foram amplamente usadas para implementar máquinas de estado e pipelines de processamento. Com a chegada do async/await no Python 3.5, muitas dessas tarefas migraram para corrotinas nativas, mas entender o mecanismo por trás é valioso para compreender programação assíncrona.

Casos de uso

Geradores avançados têm inúmeros casos de uso prático. Vamos explorar alguns cenários onde essas técnicas brilham.

1. Pipelines de processamento de dados

Com geradores, você pode construir pipelines que processam dados em etapas, sem carregar tudo na memória. Por exemplo, ler um arquivo grande linha por linha, filtrar e transformar cada linha, e produzir resultados sob demanda.

def read_lines(filename):
    with open(filename) as f:
        for line in f:
            yield line.strip()

def filter_lines(lines, substring):
    for line in lines:
        if substring in line:
            yield line

def uppercase_lines(lines):
    for line in lines:
        yield line.upper()

pipeline = uppercase_lines(filter_lines(read_lines("dados.txt"), "erro"))
for line in pipeline:
    print(line)

Aqui, cada gerador é uma etapa do pipeline, e o fluxo de dados é eficiente, usando yield from ou loops simples.

2. Geração de sequências infinitas

Geradores podem representar sequências infinitas, como números primos, sem esgotar a memória, pois cada valor é gerado quando solicitado.

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

fib = fibonacci()
for _ in range(10):
    print(next(fib))  # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

3. Streaming de dados em redes

Em aplicações de rede, geradores podem consumir dados de um socket ou de uma resposta HTTP em partes, processando cada chunk conforme chega, sem esperar o download completo.

def stream_chunks(socket):
    while True:
        chunk = socket.recv(1024)
        if not chunk:
            break
        yield chunk

4. Coroutines para máquinas de estado

Coroutines são úteis para implementar máquinas de estado, onde cada estado é um ponto de espera e transições são acionadas por send().

def state_machine():
    state = "inicial"
    while True:
        event = yield state
        if state == "inicial" and event == "iniciar":
            state = "processando"
        elif state == "processando" and event == "concluir":
            state = "final"
        elif state == "final":
            break

sm = state_machine()
print(next(sm))  # inicial
print(sm.send("iniciar"))  # processando
print(sm.send("concluir"))  # final

5. Combinação de geradores com yield from para reuso

Você pode compor geradores complexos a partir de geradores menores, facilitando a manutenção e o teste.

def evens(limit):
    for i in range(limit):
        if i % 2 == 0:
            yield i

def odds(limit):
    for i in range(limit):
        if i % 2 != 0:
            yield i

def interleave(limit):
    yield from evens(limit)
    yield from odds(limit)

print(list(interleave(10)))  # [0, 2, 4, 6, 8, 1, 3, 5, 7, 9]

Esses casos demonstram como geradores avançados permitem escrever código expressivo, eficiente e modular.

Boas práticas e observações finais

Ao trabalhar com geradores avançados, considere as seguintes práticas:

  • Prefira generator expressions para operações simples e de uso único, em vez de list comprehensions, para economizar memória.
  • Use yield from para delegar a outros geradores, tornando o código mais legível e evitando loops desnecessários.
  • Lembre-se de que geradores são de uso único. Se precisar reutilizar os dados, converta para lista ou crie um novo gerador.
  • Em coroutines, sempre inicialize com next() ou send(None) antes de enviar valores.
  • Use try/finally ou contextlib.closing() para garantir que recursos sejam liberados quando o gerador for fechado.
  • Para pipelines complexos, considere usar a biblioteca itertools, que oferece ferramentas para combinar geradores de forma eficiente.

Dominar esses conceitos eleva seu nível de proficiência em Python, permitindo soluções elegantes para problemas de processamento de dados e concorrência.

Referências

Exercícios

  1. Combinação com yield from: Escreva uma função geradora interleave(a, b) que recebe dois iteráveis e produz alternadamente os elementos de cada um, usando yield from para delegar a iteração. Por exemplo, list(interleave([1,2,3], ['a','b','c'])) deve retornar [1, 'a', 2, 'b', 3, 'c'].

    ✓ Resposta:
    def interleave(a, b):
        for x, y in zip(a, b):
            yield x
            yield y
    
    # Usando yield from para delegar a iteração de cada elemento?
    # Uma solução com yield from:
    def interleave(a, b):
        for pair in zip(a, b):
            yield from pair
    
    print(list(interleave([1,2,3], ['a','b','c'])))  # [1, 'a', 2, 'b', 3, 'c']
  2. Generator expression com condição: Crie uma generator expression que produza os quadrados dos números de 1 a 20 que são divisíveis por 3. Use-a para calcular a soma desses quadrados.

    ✓ Resposta:
    soma = sum(x**2 for x in range(1, 21) if x % 3 == 0)
    print(soma)  # 819
  3. Coroutine acumuladora: Escreva uma coroutine average() que recebe números via send() e retorna a média atual de todos os números recebidos até o momento. Dica: use um acumulador e um contador. Teste enviando 10, 20 e 30.

    ✓ Resposta:
    def average():
        total = 0
        count = 0
        while True:
            value = yield total / count if count else 0
            if value is not None:
                total += value
                count += 1
    
    avg = average()
    next(avg)  # inicializa
    print(avg.send(10))  # 10.0
    print(avg.send(20))  # 15.0
    print(avg.send(30))  # 20.0
  4. Pipeline com yield from: Crie uma função geradora read_and_filter(filename, keyword) que lê um arquivo linha por linha e produz apenas as linhas que contêm a palavra-chave, usando yield from para delegar a leitura. Assuma que existe um arquivo de teste.

    ✓ Resposta:
    def read_lines(filename):
        with open(filename) as f:
            yield from f
    
    def filter_lines(lines, keyword):
        for line in lines:
            if keyword in line:
                yield line.strip()
    
    for line in filter_lines(read_lines("dados.txt"), "erro"):
        print(line)
  5. Sequência infinita com generator expression: Explique por que uma generator expression como gen = (x for x in range(10)) não produz uma sequência infinita, e como você criaria um gerador infinito de números pares usando uma função geradora. Escreva o código para o gerador infinito e mostre como consumir os 5 primeiros elementos.

    ✓ Resposta: Uma generator expression tem um iterável finito, então não é infinita. Para criar um gerador infinito, usamos uma função geradora com loop infinito. Código:
    def even_numbers():
        n = 0
        while True:
            yield n
            n += 2
    
    gen = even_numbers()
    for _ in range(5):
        print(next(gen))  # 0, 2, 4, 6, 8