Geradores Python: Aumentando o Desempenho e Simplificando o Código

Imagine que você está trabalhando em um projeto de ciência de dados e sua tarefa é processar um conjunto de dados tão grande que carregá-lo na memória trava sua máquina. Ou você está lidando com uma sequência infinita, como um fluxo de dados ao vivo, onde você não pode armazenar tudo simultaneamente. Esses são os tipos de desafios que fazem os cientistas de dados buscar a cafeteira – e às vezes, o botão de reinicialização.

Neste artigo, aprenderemos sobre os geradores Python e como você pode usá-los para simplificar seu código. Essa ideia requer prática, então, se você é novo no Python e se sentir um pouco perdido neste artigo, experimente nosso curso de Introdução ao Python para construir uma base sólida.

O que são os Geradores Python?

No seu núcleo, os geradores Python são um tipo especial de função ou até mesmo uma expressão compacta que produz uma sequência de valores de forma preguiçosa. Pense nos geradores como uma esteira rolante em uma fábrica: em vez de empilhar todos os produtos em um só lugar e ficar sem espaço, você processa cada item conforme ele desce pela linha. Isso torna os geradores eficientes em termos de memória e uma extensão natural do protocolo iterator do Python, que sustenta muitas das ferramentas integradas do Python, como os loops for e as compreensões.

O segredo por trás dos geradores está na palavra-chave yield. Ao contrário do return, que retorna um único valor e sai da função, o yield produz um valor, pausa a execução da função e salva seu estado. Quando o gerador é chamado novamente, ele continua de onde parou.

Por exemplo, imagine que você está lendo um arquivo de log gigantesco linha por linha. Um gerador pode processar cada linha conforme é lida sem carregar o arquivo inteiro na memória. Essa “avaliação preguiçosa” diferencia os geradores das funções tradicionais e os torna uma ferramenta indispensável para tarefas sensíveis ao desempenho.

Um Exemplo Básico de Gerador Python

Vamos praticar um pouco para pegar a ideia. Aqui está uma função geradora que produz os primeiros n inteiros.  

def generate_integers(n): for i in range(n): yield i # Pausa aqui e retorna i # Usando o gerador for num in generate_integers(5): print(num)
0 1 2 3 4

Criei uma visualização para ajudá-lo a ver o que está acontecendo sob o capô:  

Sintaxe e Padrões de Geradores em Python  

Os geradores podem ser implementados de várias maneiras. Dito isso, existem duas maneiras principais: funções geradoras e expressões geradoras.

Funções geradoras  

Uma função geradora é definida como uma função regular, mas usa a palavra-chave yield em vez de return. Quando chamada, ela retorna um objeto gerador que pode ser iterado.  

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # Usando o gerador counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

A partir do exemplo acima, podemos ver que quando a função count_up_to é chamada, ela retorna um objeto gerador. Cada vez que o loop for solicita um valor, a função é executada até encontrar yield, produzindo o valor atual de count e preservando seu estado entre iterações para que ele possa ser retomado exatamente de onde parou.

Expressões geradoras  

As expressões geradoras são uma maneira compacta de criar geradores. Elas são semelhantes às listas de compreensão, mas com parênteses em vez de colchetes.

# Compreensão de lista (avaliação imediata) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # Expressão geradora (avaliação preguiçosa) squares_gen = (x**2 for x in range(5)) # Usando o gerador for square in squares_gen: print(square)
0 1 4 9 16

Então, qual é a diferença entre uma compreensão de lista e uma expressão geradora? A compreensão de lista cria toda a lista na memória, enquanto a expressão geradora produz valores um de cada vez, economizando memória. Se você não está familiarizado com compreensões de lista, pode ler sobre elas em nosso Tutorial de Compreensão de Lista em Python.

Python gerador vs. iterador

Iteradores tradicionais em Python exigiam classes com métodos explícitos __iter__() e __next__(), o que envolvia muita repetição de código e gerenciamento manual de estado, enquanto funções geradoras simplificam o processo preservando automaticamente o estado e eliminando a necessidade desses métodos—como demonstrado por uma função simples que gera o quadrado de cada número até n.

Por que usamos geradores em Python

Ao explicar o que são os geradores Python, também transmiti parte da ideia de por que eles são utilizados. Nesta seção, quero entrar um pouco mais em detalhes. Porque os geradores não são apenas um recurso sofisticado do Python, mas realmente resolvem problemas reais.

Efficiência de memória  

Ao contrário de listas ou arrays, que armazenam todos os seus elementos na memória simultaneamente, os geradores produzem valores sob demanda, então eles mantêm apenas um item na memória por vez.  

Por exemplo, considere a diferença entre o range() do Python 2 e o xrange():  

  •  O range() criava uma lista na memória, o que poderia ser problemático para intervalos grandes.

  •  xrange() agia como um gerador, produzindo valores de forma preguiçosa.

Como o comportamento do xrange() era mais útil, agora, no Python 3, range() também se comporta como um gerador, evitando o consumo excessivo de memória ao armazenar todos os valores simultaneamente.  

Para ilustrar a ideia, vamos comparar o uso de memória ao gerar uma sequência de 10 milhões de números:  

import sys # Usando uma lista numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # Usando um gerador numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

Como você pode ver, o gerador usa quase nenhuma memória em comparação com a lista, e essa diferença é significativa.

Melhorias de desempenho

Com a avaliação preguiçosa, os valores são calculados apenas quando necessário. Isso significa que você pode começar a processar dados imediatamente sem precisar esperar que toda a sequência seja gerada.

Por exemplo, imagine somar os quadrados dos primeiros 1 milhão de números:

# Usando uma lista (avaliação imediata) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # Usando um gerador (avaliação preguiçosa) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

Embora ambas as abordagens forneçam o mesmo resultado, a versão do gerador evita a criação de uma lista massiva, então obtemos o resultado mais rapidamente.

Simplicidade e legibilidade

Os geradores simplificam a implementação de iteradores, eliminando código redundante. Compare um iterador baseado em classe com uma função geradora:

Aqui está o iterador baseado em classe:

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # Uso squares = SquaresIterator(5) for square in squares: print(square)

Aqui está a função geradora:

def squares_generator(n): for i in range(n): yield i ** 2 # Uso squares = squares_generator(5) for square in squares: print(square)

A versão geradora é mais curta, mais fácil de ler e não requer código redundante. É um exemplo perfeito da filosofia do Python: o simples é melhor.

Lidando com sequências infinitas

Finalmente, quero dizer que os geradores são especialmente adequados para representar sequências infinitas, algo que é simplesmente impossível com listas. Por exemplo, considere a sequência de Fibonacci:

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # Uso fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

Este gerador pode produzir números de Fibonacci indefinidamente sem ficar sem memória. Outros exemplos incluem processamento de fluxos de dados ao vivo ou trabalhar com dados de séries temporais.

Conceitos Avançados de Geradores em Python  

Agora, vamos ver algumas ideias mais difíceis. Nesta seção, exploraremos como compor geradores e usar métodos de geradores exclusivos como .send(), .throw() e .close().  

Encadeando geradores juntos

Geradores podem ser combinados. Você pode transformar, filtrar e processar dados modularmente encadeando geradores juntos. 

Vamos dizer que você tem uma sequência infinita de números e deseja elevar ao quadrado cada número e filtrar os resultados ímpares:

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # Componha os geradores numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # Imprima os primeiros 10 quadrados pares for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

O processo envolve a função infinite_sequence gerando números indefinidamente, enquanto o square_numbers fornece o quadrado de cada número, e então o filter_evens filtra os números ímpares para produzir apenas quadrados pares. Nossa trilha de carreira Associado Desenvolvedor Python entra nesse tipo de coisa, para que você possa ver como construir e depurar pipelines complexos usando geradores, bem como iteradores e list comprehensions.

Métodos especiais de geradores

Os geradores vêm com métodos avançados que permitem comunicação bidirecional e término controlado.

send()

O método .send() permite passar valores de volta para um gerador, transformando-o em uma coroutine. Isso é útil para criar geradores interativos ou com estado.

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # Usando o gerador acc = accumulator() next(acc) # Iniciar o gerador print(acc.send(10)) # Saída: 10 print(acc.send(5)) # Saída: 15 print(acc.send(20)) # Saída: 35

Aqui está como funciona:

  • O gerador começa com next(acc) para inicializá-lo.

  • Cada chamada para .send(value) passa um valor para o gerador, que é atribuído a value na declaração yield.

  • O gerador atualiza seu estado (total) e produz o novo resultado.

throw()

O método .throw() permite que você levante uma exceção dentro do gerador, o que pode ser útil para tratamento de erros ou sinalização de condições específicas.  

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # Usando o gerador gen = resilient_generator() print(next(gen)) # Saída: 0 print(next(gen)) # Saída: 1 print(gen.throw(ValueError)) # Saída: "Erro ocorreu!"

Aqui está como este funciona: 

  • O gerador geralmente é executado até que .throw() seja chamado.  

  •  A exceção é gerada dentro do gerador, que pode tratá-la usando um bloco try-except.

close()

O método .close() interrompe um gerador levantando uma exceção GeneratorExit. Isso é útil para limpar recursos ou parar geradores infinitos.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # Usando o gerador counter = infinite_counter() print(next(counter)) # Saída: 0 print(next(counter)) # Saída: 1 counter.close() # Saída: "Gerador fechado!"

E aqui está como isso funciona:

  • O gerador é executado até que .close() seja chamado.

  • A exceção GeneratorExit é gerada, permitindo que o gerador faça uma limpeza ou registre uma mensagem antes de terminar.

Aplicações do Mundo Real em Ciência de Dados  

Espero que você esteja começando a apreciar que os geradores são úteis. Nesta seção, vou tentar destacar os casos de uso para que você possa visualizar como eles realmente funcionam para você no seu dia a dia. 

Processamento de conjuntos de dados grandes  

Um dos desafios mais comuns em ciência de dados é trabalhar com conjuntos de dados muito grandes para caber na memória. Os geradores fornecem uma maneira de processar esses dados linha por linha.  

Imagine que você tenha um arquivo CSV de 10 GB contendo dados de vendas e precise filtrar registros para uma região específica. Veja como você pode usar um pipeline de geradores para alcançar isso:

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # Pipeline de geradores file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # Processar os dados filtrados for record in filtered_data: print(record)

Aqui está o que está acontecendo:

  1. read_large_csv lê o arquivo linha por linha, produzindo cada linha como um dicionário.

  2. filter_by_region filtra as linhas com base na região especificada.

  3. O pipeline processa dados incrementalmente, evitando a sobrecarga de memória.

Esta abordagem beneficia fluxos de extração, transformação e carga, onde os dados devem ser limpos e transformados antes da análise. Você verá esse tipo de coisa em nosso curso ETL e ELT em Python.

Streaming e pipelines  

Às vezes, os dados chegam como um fluxo contínuo. Pense em dados de sensores, feeds ao vivo ou redes sociais.

Suponha que você está trabalhando com dispositivos IoT que geram leituras de temperatura a cada segundo. Você deseja calcular a temperatura média em uma janela deslizante de 10 leituras:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # Simular dados do sensor def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # Pipeline do gerador sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # Imprimir a média a cada segundo for avg in averages: print(f"Average temperature: {avg:.2f}")

Aqui está a explicação: 

  1. sensor_data_stream simula uma sequência infinita de leituras do sensor.  

  2. sliding_window_average mantém uma janela deslizante das últimas 10 leituras e fornece a média delas.  

  3. O pipeline processa dados em tempo real, tornando-o ideal para monitoramento e análises.

Casos de uso adicionais

Os geradores também são usados em situações em que o tamanho dos dados é imprevisível ou quando eles continuam chegando/são infinitos.  

Raspagem de web

Ao raspar sites, muitas vezes você não sabe quantas páginas ou itens precisará processar. Os geradores permitem lidar com essa imprevisibilidade de forma elegante:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # Simular a busca e análise de uma página print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # Função hipotética para obter a próxima página # Uso scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

Tarefas de simulação

Nas simulações, como métodos de Monte Carlo ou desenvolvimento de jogos, os geradores podem representar sequências infinitas ou dinâmicas:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # Uso simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

Comparação de Memória e Velocidade

Devido à forma como funcionam, os geradores se destacam em cenários onde a eficiência de memória é crítica, mas (você pode se surpreender ao saber) nem sempre são a opção mais rápida. Vamos comparar geradores com listas para entender suas compensações.

Anteriormente, mostramos como os geradores eram melhores do que listas em termos de memória. Esta foi a parte em que comparamos o uso de memória ao gerar uma sequência de 10 milhões de números. Vamos agora fazer uma comparação de velocidade:

import time # Compreensão de lista start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # Expressão de gerador start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

Embora um gerador economize memória, neste caso, na verdade, é mais lento do que a lista. Isso ocorre porque, para esse conjunto de dados menor, há o overhead de pausar e retomar a execução.

A diferença de desempenho é negligenciável para conjuntos de dados pequenos, mas para conjuntos de dados grandes, a economia de memória dos geradores muitas vezes supera a pequena penalidade de velocidade.

Problemas que Surgem

Por fim, vamos analisar alguns erros ou problemas comuns:

Os geradores são exauríveis

Uma vez que um gerador é esgotado, ele não pode ser reutilizado. Você precisará recriá-lo se quiser iterar novamente.

gen = (x for x in range(5)) print(list(gen)) # Saída: [0, 1, 2, 3, 4] print(list(gen)) # Saída: [] (o gerador está esgotado)

A avaliação preguiçosa pode ser complicada 

Já que os geradores produzem valores sob demanda, erros ou efeitos colaterais podem não aparecer até que o gerador seja iterado.  

Você pode usar geradores em excesso 

Para conjuntos de dados pequenos ou tarefas simples, o custo de usar um gerador pode não compensar a economia de memória. Considere este exemplo onde estou materializando dados para múltiplas iterações.

# Expressão geradora gen = (x**2 for x in range(10)) # Materializar em uma lista squares = list(gen) # Reutilizar a lista print(sum(squares)) # Saída: 285 print(max(squares)) # Saída: 81

Escolhendo Quando Usar Geradores

Para recapitular, fornecerei algumas regras muito gerais sobre quando usar geradores. Use para:

  • Conjuntos de dados grandes: Use geradores ao trabalhar com conjuntos de dados muito grandes para caber na memória.
  • Sequências infinitas: Use geradores para representar sequências infinitas, como fluxos de dados ao vivo ou simulações.
  • Pipelines: Use geradores para construir pipelines modulares de processamento de dados que transformam e filtram dados incrementalmente.

Quando materializar dados (converter em uma lista)

  • Conjuntos de dados pequenos: Não use geradores se a memória não for um problema e você precisa de acesso rápido a todos os elementos; em vez disso, use uma lista.
  • Múltiplas iterações: Não use geradores se precisar iterar sobre os mesmos dados várias vezes; em vez disso, materialize-os em uma lista para evitar recriar o gerador.

Conclusão e Principais Pontos a Serem Lembrados

Ao longo deste artigo, exploramos como os geradores podem ajudá-lo a enfrentar desafios do mundo real em ciência de dados, desde o processamento de grandes conjuntos de dados até a construção de pipelines de dados em tempo real. Continue praticando. A melhor maneira de dominar os geradores é usá-los em seu próprio trabalho. Para começar, tente substituir uma compreensão de lista por uma expressão de gerador ou refatorar um loop em uma função geradora.

Depois de dominar o básico, você pode explorar tópicos novos e mais avançados que se baseiam no conceito de gerador:

  • Coroutines: Use .send() e .throw() para criar geradores que podem receber e processar dados, permitindo a comunicação bidirecional.

  • Programação assíncrona: Combine geradores com a biblioteca asyncio do Python para construir aplicações eficientes e não bloqueantes.  

  • Concorrência: Aprenda como geradores podem implementar multitarefa cooperativa e concorrência leve.  

Continue aprendendo e torne-se um especialista. Faça nossa trilha de carreira de Desenvolvedor Python ou nossa trilha de habilidades de Programação Python hoje. Clique no link abaixo para começar.

Source:
https://www.datacamp.com/tutorial/python-generators