Iterators e Generators em Python. O que são e por que usar?

Capa do artigo

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(m):
    dados = []
    for n in range(m):
        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, m):
        self.m = m
        self._cursor = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self._cursor < self.m:
            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.

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 vemos é 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 com 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 c in Iterator(4):
    print(c)

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)).

Ta, mas ainda pode ser iterado? Claro, de certa forma sim. Vamos testar o builtin next.

Vejamos 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 básico? Pois é, 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.

Agora vamos descomentar o método lá?

Vamos testar essas linhas básicas:

for c in Iterator(4):
    print(c)

Agora não temos erro, apenas um retorno de números. Surpreende, não? Percebeu que o laço acabou quando da vez anterior era lançado um erro, mas dessa vez não lançou?

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?

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.

Como sabemos, se tivéssemos colocado um return, nosso loop só iria ser executado uma vez, por 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(n):
    print('bloco sendo executado')
    for i in range(n):
        print('entrando no for')
        yield f'yield n {i} 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

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.

Leituras recomendadas

Comentários

Postagens mais visitadas deste blog

Criando uma aplicação gráfica Python com TKinter

POO, o que é? É realmente tão difícil?

Baixando vídeo do YouTube com Python