Geradores
Nesta aula, você aprenderá sobre geradores em Python, uma ferramenta poderosa para criar sequências de dados de forma eficiente, economizando memória. Vamos explorar o uso da palavra-chave yield, a definição de generator functions, a economia de memória que eles proporcionam e como construir pipelines de processamento de dados com eles.
Geradores são um dos recursos mais elegantes e poderosos do Python, permitindo que você trabalhe com sequências de dados de forma lazy, ou seja, produzindo cada elemento apenas quando necessário. Isso é fundamental para lidar com grandes volumes de dados, streams e pipelines, pois evita o armazenamento de todos os valores em memória de uma vez. Nesta aula, vamos mergulhar no conceito de geradores, desde a sintaxe básica com yield até a construção de pipelines de processamento de dados, mostrando como eles podem tornar seu código mais eficiente e legível.
yield
A palavra-chave yield é o coração dos geradores em Python. Quando uma função contém yield, ela se torna uma generator function. A diferença crucial entre return e yield é que yield pausa a execução da função, retorna um valor ao chamador e, na próxima vez que o gerador é solicitado, a execução continua exatamente de onde parou, preservando o estado local. Isso permite que a função produza uma sequência de valores ao longo do tempo, em vez de calcular todos de uma vez e retornar uma lista.
Vamos ver um exemplo simples: uma função que gera os quadrados de números de 1 a n.
def quadrados(n):
for i in range(1, n + 1):
yield i * i
# Uso do gerador
for valor in quadrados(5):
print(valor)
Quando você chama quadrados(5), ela não executa o corpo imediatamente; em vez disso, retorna um objeto gerador. Cada iteração no loop for invoca o gerador, que executa até encontrar o yield, retorna o valor e pausa. No próximo loop, a execução continua após o yield, e assim por diante. Esse comportamento é conhecido como lazy evaluation.
Uma observação importante: geradores são iteráveis de uso único. Depois que você consome todos os valores, o gerador está esgotado e não pode ser reutilizado. Se precisar percorrer novamente, terá que recriar o gerador.
Generator functions
Uma generator function é qualquer função que use yield em seu corpo. Ela se diferencia de uma função normal por retornar um objeto gerador quando chamada, em vez de executar imediatamente. Para criar uma generator function, basta incluir pelo menos um yield na definição. É possível ter múltiplos yield e até combinar yield com return (embora o return apenas encerre o gerador, sem retornar um valor).
Além da sintaxe com yield dentro de uma função, Python oferece uma forma alternativa de criar geradores: as generator expressions. Elas têm uma sintaxe semelhante a list comprehensions, mas usam parênteses em vez de colchetes. Por exemplo:
# Generator expression
quadrados_gen = (x * x for x in range(5))
print(list(quadrados_gen)) # [0, 1, 4, 9, 16]
As generator expressions são mais concisas para geradores simples, mas não podem conter lógica complexa. Para isso, usamos funções geradoras.
Vamos explorar um exemplo mais avançado: um gerador que produz números de Fibonacci infinitamente. Isso é possível porque o gerador não precisa armazenar todos os valores; ele calcula o próximo valor sob demanda.
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Consome os primeiros 10 números
fib = fibonacci()
for _ in range(10):
print(next(fib))
Este gerador é infinito, mas você pode usá-lo com segurança porque só calcula os valores que você pede. Isso é um exemplo de como geradores permitem trabalhar com sequências infinitas de forma prática.
Economia de memória
A principal vantagem dos geradores é a economia de memória. Considere uma função que retorna uma lista de um milhão de números. Se você usar uma lista, toda a sequência fica armazenada na memória, o que pode ser inviável para dados grandes. Com geradores, cada valor é produzido e descartado após o uso, reduzindo drasticamente o uso de memória.
Vamos comparar:
import sys
# Usando lista
lista = [x * x for x in range(1000000)]
print(sys.getsizeof(lista)) # Exemplo: 8448728 bytes
# Usando gerador
gen = (x * x for x in range(1000000))
print(sys.getsizeof(gen)) # Exemplo: 128 bytes
Enquanto a lista consome vários megabytes, o gerador ocupa apenas alguns bytes, pois não armazena os elementos. Isso é crucial ao processar arquivos enormes, dados de streaming ou qualquer situação onde a memória é limitada.
Além disso, geradores melhoram a eficiência de tempo de inicialização: a primeira iteração é mais rápida porque não calcula tudo de antemão. Em contrapartida, se você precisa acessar elementos aleatórios ou percorrer a sequência várias vezes, uma lista pode ser mais adequada, pois permite indexação e reutilização.
Outra aplicação de economia de memória é no processamento de arquivos linha por linha. Em vez de ler o arquivo inteiro para uma lista, você pode usar um gerador para ler e processar cada linha sob demanda.
def ler_arquivo(nome_arquivo):
with open(nome_arquivo, 'r') as f:
for linha in f:
yield linha.strip()
# Uso
for linha in ler_arquivo('dados.txt'):
# processa linha
pass
Pipelines
Geradores são ideais para construir pipelines de processamento de dados, onde você encadeia múltiplas etapas de transformação. Cada gerador pode ser visto como uma etapa que recebe dados de uma fonte e produz dados para a próxima. Isso permite compor operações complexas de forma eficiente e legível, sem criar listas intermediárias que consomem memória.
Vamos criar um pipeline que lê números de um arquivo, filtra os pares, eleva ao quadrado e soma os resultados. Usaremos geradores para cada etapa.
# Fonte: gerador de números a partir de um arquivo
def numeros_do_arquivo(nome_arquivo):
with open(nome_arquivo) as f:
for linha in f:
yield int(linha.strip())
# Etapa de filtro: apenas pares
def pares(nums):
for n in nums:
if n % 2 == 0:
yield n
# Etapa de transformação: quadrado
def quadrados(nums):
for n in nums:
yield n * n
# Pipeline completo
pipeline = quadrados(pares(numeros_do_arquivo('numeros.txt')))
total = sum(pipeline)
print(f"Soma dos quadrados dos pares: {total}")
Observe que cada gerador é uma função que recebe um iterável e produz um novo iterável. Ao encadear, os dados fluem de um para o outro sem criar listas intermediárias. Isso é semelhante ao conceito de streams em outras linguagens.
Você também pode usar a biblioteca padrão itertools para construir pipelines mais sofisticados. Por exemplo, itertools.islice permite limitar um gerador infinito, itertools.chain combina vários iteráveis, e itertools.groupby agrupa elementos. Combinar geradores com itertools é uma prática comum em Python para processamento de dados eficiente.
Boas práticas e observações finais
Ao trabalhar com geradores, lembre-se de que eles são iteráveis de uso único. Se precisar reutilizar a sequência, converta-a para uma lista ou recrie o gerador. Além disso, geradores são ótimos para encapsular lógica de iteração complexa, tornando o código mais modular e testável. Use yield from para delegar a um subgerador, simplificando pipelines aninhados.
Outra dica: em Python, o conceito de corrotinas também usa yield para receber valores (via send), mas isso é um tópico avançado. Nesta aula, focamos em geradores para iteração, que é o uso mais comum.
Referências
- Python Tutorial: Generators
- Python Reference: yield expression
- Python itertools module documentation
- Real Python: Introduction to Python Generators
- Python Wiki: Generators
- PEP 255 – Simple Generators
- PEP 342 – Coroutines via Enhanced Generators
Exercícios
- Escreva uma generator function chamada
pares_ateque receba um númerone produza todos os números pares de 0 até n (inclusive). Teste com n=10. - Crie uma generator expression que produza os cubos dos números de 1 a 5 e calcule a soma desses cubos usando a função
sum(). - Escreva um gerador que produza a sequência de Collatz para um número inicial dado. A sequência é: se o número é par, divida por 2; se ímpar, multiplique por 3 e some 1. Pare quando chegar a 1. Use o gerador para imprimir a sequência para n=7.
- Considere um arquivo 'dados.txt' com um número por linha. Crie um pipeline de geradores que leia os números, filtre os múltiplos de 3 e calcule a média desses números. Use a função
statistics.meanou calcule manualmente. - Implemente um gerador infinito que produza números primos (usando o crivo de Eratóstenes ou teste de primalidade). Use
itertools.islicepara extrair os primeiros 10 primos.
def pares_ate(n):
for i in range(0, n + 1, 2):
yield i
# Teste
print(list(pares_ate(10))) # [0, 2, 4, 6, 8, 10]
cubos = (x ** 3 for x in range(1, 6))
total = sum(cubos)
print(total) # 225
def collatz(n):
while n != 1:
yield n
if n % 2 == 0:
n //= 2
else:
n = 3 * n + 1
yield 1
print(list(collatz(7))) # [7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1]
def ler_numeros(nome_arquivo):
with open(nome_arquivo) as f:
for linha in f:
yield int(linha.strip())
def multiplos_de_tres(nums):
for n in nums:
if n % 3 == 0:
yield n
from statistics import mean
numeros = ler_numeros('dados.txt')
media = mean(multiplos_de_tres(numeros))
print(f"Média dos múltiplos de 3: {media}")
import itertools
def primos():
yield 2
n = 3
while True:
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
break
else:
yield n
n += 2
primeiros = list(itertools.islice(primos(), 10))
print(primeiros) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]