Iterators e Generators? Que coisa estranha, não? Parecem coisas de outro mundo, mas eu te garanto que não são e ainda afirmo, você sempre usou ao menos uma dessas funcionalidades sem nem perceber.
De forma geral, ambos funcionam como um modo de entregar dados sob demanda sem que a memória seja usada excessivamente e de forma um tanto quanto fluída.
É, eu sei que você não entendeu nada, na verdade nem eu. Mas vamos simplificar isso, vamos a um exemplo.
Suponhamos que temos uma função que gera uma lista de determinado tamanho, com números inteiros aleatórios entre 0 e 10 e precisamos iterar sobre esse retorno, nosso código ficaria tipo isso:
import random
def gera_dados(limite):
dados = []
for numero in range(limite):
dados.append(random.randint(0, 10))
return dados
Essa função vai funcionar? Sim, vai. Mas como pode-se ver, até completar o número de iterações que queremos, ela só vai armazenando tudo em memória, e se pensarmos em solicitar um grande número de dados, vai haver um uso talvez inaceitável de memória que pode levar a travamentos por exemplo.
Mas como podemos refatorar esse código de forma que fique mais eficiente? Ok, chegou a hora boa.
Já vou avisando, iterators pode parecer assustador mas não é, te garanto.
Com iterators teríamos algo assim:
import random
class Iterator:
def __init__(self, limite):
self.limite = limite
self._cursor = 0
def __iter__(self):
return self
def __next__(self):
if self._cursor < self.limite:
self._cursor += 1
return random.randint(0, 10)
raise StopIteration
Código gigantesco e assustador, não acha? Mas calma, logo você vai se apaixonar por ele, provavelmente.
Agora com nossa pequena classe podemos consumir os dados "sob demanda". Vamos ver na prática o funcionamento? Vamos dar um print nela passando um número qualquer.
No nosso primeiro exemplo, se dessemos um print em seu retorno teríamos uma lista qualquer com alguns números inteiros, mas e se fizéssemos o mesmo nesse caso? Veríamos uma lista também? Parece que não, o que veremos é algo tipo isso:
<__main__.Iterator object at 0x7f7aeb6c6910>
Vamos entender direito?
Quando chamamos a classe assim aleatoriamente, o Python apenas chama o dunder repr padrão que retorna por exemplo, onde a classe ta, o nome dela e o seu endereço em memória. Se você leu meu post anterior deve ter entendido essa parte.
Como fazemos uma iteração? Fazemos como com uma lista, que por sinal é um exemplo de iterator.
Quando fazemos um for in ou um destructuring/unpacking, o Python vai lá e chama nosso querido dunder iter, que é quem sinaliza que aquilo pode ser iterado. Sugestivo, não? E também, esse método geralmente retorna apenas a própria instância, mas não vamos falar sobre isso agora.
Depois disso, nossa "querida linguagem" vai lá no retorno do iter e pega o dunder next... que explicação meia boca, né? Vamos tentar de novo.
De início vamos comentar o método iter? Ok.
Com ele comentado, vamos tentar executar o seguinte:
for item in Iterator(4):
print(item)
Parece que tivemos algo tipo isso:
Traceback (most recent call last):
File "main.py", line 25, in <module>
for c in Iterator(4):
TypeError: 'Iterator' object is not iterable
Nosso método iter
nem tinha nada, e declaramos nosso método next
direitinho, então porque diz ali que nosso objeto não é iterável? Vejamos...
Exatamente por isso. Quando fazemos um for
, ele primeiro busca o método iter
, como já falamos antes.
Vai dar o mesmo erro se fizermos um
iter(Iterator)
, afinal, é o primeiro passo na execuçao de umfor..in
Ta, mas ainda pode ser iterado? Claro, de certa forma sim.
Vamos testar o builtin next.
data = Iterator(4)
print(next(data))
print(next(data))
print(next(data))
print(next(data))
print(next(data))
Veremos algo assim:
5
0
4
7
Traceback (most recent call last):
File "main.py", line 33, in <module>
print(next(data))
File "main.py", line 23, in __next__
raise StopIteration
StopIteration
Percebeu que ainda pode ser iterado, e que na quinta chamada temos um errinho? Pois é. Mas nada está perdido e esse erro a gente mesmo declarou mais pra cima.
Nossa função next()
vai por baixo dos panos chamar o dunder next de nosso iterator, por isso não deu errado, ou será que deu?
Não, nada está errado. Esse pequeno erro quando declarado e lançado durante um laço for, por exemplo, o interpretador ignora ele. Ele ta aí mais como um aviso tipo: Ok interpretador, não temos mais nada para retornar, por favor encerre o loop.
Agora vamos descomentar o método lá?
Vamos testar essas linhas:
for item in Iterator(4):
print(item)
Agora não temos erro, apenas um retorno de números. Surpreendente, não?
Percebeu que na vez anterior era lançado um erro, mas dessa vez não lançou e o laço acabou quando justamente nesse ponto?
Por baixo dos panos vai acontecer algo como o seguinte: o interpretador chama o método iter, e vai chamando o next de seu retorno até dar o erro StopIteration.
Tudo certo dessa vez, né? Vimos que é útil, que é mais performático e que a memória vai nos agradecer por não fazê-la memorizar um monte de dados de uma vez só para eventualmente a gente recuperar, se precisarmos.
Se você não gostou de como um iterador é escrito, talvez não esteja só.
Vamos ver agora como um generator é escrito?
Vamos implementar a mesma ideia, porém em generator
def data_generator(m):
for i in range(m):
yield random.randint(0, 10)
Sim, isso é um generator, e sim, tem uma performance ótima, tal qual um iterator.
Surpreendente, não?
Você já conhecia a palavrinha yield? Se não, vamos ver como funciona?
Ela tem um funcionamento semelhante ao tão famoso return, porém ao contrário do segundo, ela não interrompe o fluxo do código.
Como sabemos, se tivéssemos colocado um return, nosso loop só iria ser executado uma vez, pois ele retorna algo e ao mesmo tempo "desliga o bloco". Como o yield é igual, porém diferente. Ele também retorna um dado, mas continua a execução normalmente. Vamos ver um outro exemplo?
def yield_teste(limite):
print('bloco sendo executado')
for num in range(limite):
print('entrando no for')
yield f'yield num {num} chamado'
print('saindo do for\n')
print('saindo do bloco todo')
Agora vamos tentar executar para entender direitinho.
Vamos fazer isso:
test = yield_teste(2)
Uaau, recebemos nosso primeiro print
, certo? Huum. Não, não mesmo. Mas por quê isso acontece? Porque por baixo dos panos o Python cria na verdade um iterator e não o chama automaticamente.
Vamos ver isso na prática?
Testaremos isso:
print(iter(test))
Se você lembra bem, isso vai chamar o dunder iter do objeto.
O retorno que temos? É algo assim:
<generator object yield_teste at 0x7fc800f90cf0>
Percebeu que nosso generator é apenas um iterator simplificado?
Agora que já entendemos isso, vamos ver os retornos.
test = yield_teste(2)
# Não retorna nada
print(next(test))
# bloco sendo executado
# entrando no for
# yield n 0 chamado
# saindo do for
print(next(test))
# entrando no for
# yield n 1 chamado
# saindo do for
print(next(test))
# saindo do bloco todo
# Traceback (most recent call last):
# File "main.py", line 54, in <module>
# print(next(test))
# StopIteration
Percebeu que nosso primeiro print só foi chamado com nosso, e após o primeiro next
, o print logo após o primeiro yield
só foi chamado no segundo next
? Pois é... retorna mas não bloqueia.
E se você lembra bem de iterators, isso pode ser iterado em um laço for.
Pois é, generators são, de forma simplificada, apenas um meio funcional de criar iterators.
Genial, não?
Agora que você conhece sobre eles, não afogue mais a memória em dados que provavelmente nem vão ser usados.
Se chegou até aqui, eu lhe agradeço pelo tempo e paciência. Bons estudos e espero nos vermos de novo.