Python Iluminado

Geradores

Introduzidos no PEP 255, as funções geradoras são um tipo especial de função que retorna um lazy iterator. Esses são objetos que você pode percorrer como uma lista. No entanto, ao contrário das listas, os lazy iterators não armazenam seu conteúdo na memória.

Em outras palavras, Geradores são iteradores, porém só podemos iterar sob eles uma vez, pelo fato deles não guardarem todos os valores em memória, eles vão gerando os valores conforme instruímos. Nós usamos os geradores iterando sob eles, seja com um for loop ou passando eles para uma função ou constructo que seja capaz de iterar.

Na maioria das vezes os geradores são implementados como funções, entretanto, eles não retornam um valor com return, eles usam a palavra chave yield, que seria uma forma de ir guardando os valores.

Veja agora um exemplo de uma função geradora.

def gerador():
for e in range(5):
yield e
for item in gerador():
print(item)
# 0
# 1
# 2
# 3
# 4

Veja que nesse caso não há muita utilidade, geradores são melhores para calcular grandes quantidades de resultados (particularmente cálculos que envolvem loops) onde não queremos alocar memória para todos os resultados ao mesmo tempo, trata-se de eficiência.

Agora veja um gerador que é capaz de calcular números fibonacci:

def fib_gen(n):
a = b = 1
for e in range(n):
a, b = b, a + b
yield a
for x in fib_gen(7):
print(x)
# 1
# 1
# 2
# 3
# 5
# 8
# 13

Também podemos definir um gerador que irá gerar uma sequência infinita:

def sequencia_infinita():
num = 0
while True:
yield num
num +=1
for i in sequencia_infinita():
print(i,end=' ')

Este programa irá executar infinitamente até que o paremos, podemos usar o comando CTRL + C para isso. Outra opção que temos é usar o método next(), dessa vez vamos controlar o número de iterações:

gen = sequencia_infinita()
for _ in range(501):
print(next(gen),end=',')

Dessa vez serão gerados números de 0 até 500.

Expressões Geradoras

Python nos permite criar simples geradores usando expressões geradoras, fazendo com que o processo de criação de geradores seja muito mais simples. Assim como as funções lambda criam funções anônimas, expressões geradoras criam funções geradoras anônimas.

A sintaxe da expressão geradora nos lembra as list comprehensions, porém os colchetes são alterados para parênteses. A grande diferença entre os dois é que a compreensão de lista produz a lista inteira de uma só vez, enquanto que a expressão geradora produz um item de cada vez, fazendo dela muito mais eficiente em questões de desempenho e memória.

Para o exemplo, vamos começar inicializando uma lista:

lista = [1, 3, 6, 10, 14]

Elevamos ao cubo cada elemento da lista, usando uma list comprehension:

[x**3 for x in lista] # [1, 27, 216, 1000, 2744]

Veja que de imediato obtemos uma nova lista com todos os elementos elevados ao cubo. Se quisermos alterar para uma expressão geradora, apenas precisamos trocar os colchetes por parênteses:

gerador = (x**3 for x in lista)
print(gerador) # <generator object <genexpr> at 0x7fd0067de9d0>

Dessa vez nos é retornado um objeto generator, que podemos iterar sob ele:

for gen in gerador:
print(gen)
# 1
# 27
# 216
# 1000
# 2744

Observe que a expressão geradora não produziu o resultado que esperávamos de forma imediata, na verdade retornou um objeto gerador, que é capaz de produzir items sob demanda.

Inicializamos agora uma outra lista para vermos como um gerador funciona ao usarmos o método next():

l = [2,3,6,9] #
a = (x**4 for x in l)
print(next(a)) # 16
print(next(a)) # 81
print(next(a)) # 1296
print(next(a)) # 6561
print(next(a))
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# StopIteration

Veja que ao atingirmos o limite da lista, o erro StopIteration ocorre.

As expressões geradoras também podem ser usadas dentro de funções, por exemplo:

numeros = [4,5,6,7]
print(sum(x**4 for x in numeros)) # 4578
print(max(x**4 for x in numeros)) # 2401

Importância dos Geradores

Agora uma questão importante. Por que geradores são usados em Python?

  1. Fácil de implementar, geradores podem ser implementados de maneira clara e concisa, de forma muito mais simples que os iteradores
  2. Eficiência de memória, por produzirem um item por vez e não gerarem toda a sequência de uma só vez, os geradores nos trazem bastante perfomance.
  3. Nos permitem produzir um fluxo infinito de dados, uma vez que esses fluxos não podem ser guardados em memória por serem infinitos, os geradores nos permitem trabalhar com eles, uma vez que itens serão gerados um por vez.

Para entendermos a eficiência de memória de um gerador, vamos compará-lo com uma list comprehension para observarmos a diferença de tamanho entre eles. Vamos começar definindo uma função geradora:

def gen(n):
for i in range(n):
yield i**2

Agora vamos construir uma list comprehension com 100.000 elementos e um gerador com o mesmo número e usaremos a função getsizeof do módulo sys para obtermos a quantidade ocupada de memória do objeto em bytes:

import sys
x = [i**2 for i in range(100_000)]
g = gen(100_000)
print(sys.getsizeof(x)) # 824472
print(sys.getsizeof(g)) # 128

Veja que temos uma diferença substancial, com este exemplo, concluímos que os geradores são extremamente eficientes!