Geradores em Python – Códigos até 1000 vezes mais rápidos

Você sabe a diferença entre uma função “normal” e uma função geradora em Python? Qual a diferença entre o return de uma função usual e o yield de um gerador? Nesse artigo responderemos essas perguntas e ainda nos aprofundaremos em alguns aspectos da linguagem.

Vamos começar declarando uma simples função que recebe uma sequência de números, os eleva ao quadrado e retorna esses quadrados em uma lista. Caso precise relembrar o conceito de iterável, veja este artigo, onde usamos essa mesma função como exemplo.

def eleva_ao_quadrado(iteravel_de_numeros):
    resultado = []
    for numero in iteravel_de_numeros:
        resultado.append(numero**2)
    return resultado

Vamos agora criar uma tupla de números e passar para tal função:

numeros = (1, 2, 3)

eleva_ao_quadrado(numeros)
[1, 4, 9]

OK, funcionamento que esperávamos. Podemos verificar o tipo de eleva_ao_quadrado e de eleva_ao_quadrado(numeros):

type(eleva_ao_quadrado)
function
type(eleva_ao_quadrado(numeros))
list

Perceba que o objeto eleva_ao_quadrado é do tipo function como era de se esperar. E quando o argumento numeros foi passado, o objeto passou a ser list. Afinal, o retorno da função é uma lista. O return finaliza a função entregando o que foi solicitado, a lista.

yield – Funções geradoras

Vamos começar vendo como a própria documentação da linguagem define geradores:

Uma função que retorna um iterador gerador. É parecida com uma função normal, exceto pelo fato de conter expressões yield para produzir uma série de valores que podem ser usados em um laço for ou que podem ser obtidos um de cada vez com a função next(). Normalmente refere-se a uma função geradora, mas pode referir-se a um iterador gerador em alguns contextos. Em alguns casos onde o significado desejado não está claro, usar o termo completo evita ambiguidade.

Acredito que não tenha sido muito esclarecedor, mas vamos melhorar isso. Primeiro, para entender melhor a ideia de um iterador, leia esse artigo onde explico detalhadamente. É importante para o prosseguimento do que veremos aqui. A documentação também define o termo “iterador gerador”:

Um objeto criado por uma função geradora. Cada yield suspende temporariamente o processamento, memorizando o estado da execução local (incluindo variáveis locais e instruções try pendentes). Quando o iterador gerador retorna, ele se recupera do último ponto onde estava (em contrapartida as funções que iniciam uma nova execução a cada vez que são invocadas).

Nessa última definição há aspectos importantes, especialmente a ideia de suspensão temporária. Vamos criar nosso gerador, modificando nossa função. Perceba que apenas foi retirada a criação da lista e o retorno foi substituído por yield numero**2:

def gerador_de_quadrados(iteravel_de_numeros):
    for numero in iteravel_de_numeros:
        yield numero**2

Antes de interagir com esse gerador, vamos traduzir o termo yield. Procurando em diversos dicionários, você encontrará as seguintes traduções:

  • produzir
  • prover
  • rendimento

Juntando tais traduções com as definições, podemos pensar um gerador como sendo uma construção que produz valores sob demanda a cada chamada de next. Veremos agora tal ideia. Comecemos avaliando os tipos:

type(gerador_de_quadrados)
function
gerador_de_quadrados(numeros)
<generator object gerador_de_quadrados at 0x7f4e15114970>
type(gerador_de_quadrados(numeros))
generator
g = gerador_de_quadrados(numeros)
g
<generator object gerador_de_quadrados at 0x7f4e15114a50>

Perceba que o objeto gerador_de_quadrados em si é do tipo função. No entanto, ao passar o argumento se torna do tipo gerador e tal objeto possui uma referência em memória. Observe que não foi retornado valor algum. Segundo a documentação, os valores serão gerados um de cada vez sob demanda:

next(g)
1
next(g)
4
next(g)
9

Realmente é o mesmo comportamento que vimos no artigo sobre iteradores. Assim, é de se esperar uma StopIteration quando todo o iterável passado foi consumido:

next(g)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-13-e734f8aca5ac> in <module>
----> 1 next(g)

StopIteration: 

Expressões geradoras

A mesma função geradora poderia ser escrita em apenas uma linha com o uso de uma expressão geradora:

(numero**2 for numero in numeros)
<generator object <genexpr> at 0x7f4e14017ba0>

Veja que sua sintaxe é similar à uma list comprehension, mas com parênteses no lugar de colchetes. Possui o mesmo comportamento do gerador criado anteriormente:

for quadrado in (numero**2 for numero in numeros):
    print(quadrado)
1
4
9
g = (numero**2 for numero in numeros)
next(g)
1
next(g)
4
next(g)
9
next(g)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-20-e734f8aca5ac> in <module>
----> 1 next(g)

StopIteration: 

Aplicações práticas de geradores

Mais importante que saber da existência de geradores é saber aplicá-los em casos reais. A PEP 255, que implementa geradores na linguagem Python, apresenta o racional que levou à implementação, mas pode ser um tanto quanto abstrata para iniciantes por tratar de callbacks e corrotinas. Aqui vou tentar apresentar usos mais acessíveis, mas não menos importantes, que os apresentados na PEP.

Comecemos criando uma lista de nomes e uma de cursos que simbolizam alunos em uma instituição qualquer:

nomes = ['Amerício', 'Califórnio', 'Copernício', 'Disprósio', 'Einstênio']
cursos = ['Geografia', 'Cinema' ,'Astronomia', 'Letras - Grego', 'Física']

Imagine que se deseja criar um cadastro, unificando em uma mesma estrutura de dados os nomes dos alunos e seus cursos, além de associar um número de matrícula sequencial a cada aluno.

Na célula a seguir são feitas duas implementações. Uma na forma de função tradicional, armazenando cada aluno como um dicionário dentro de uma lista e retornando essa lista. A outra gerando cada dicionário de aluno, ou seja, sem armazenar em nenhuma estrutura. Se o armazenamento for desejado, deve ser realizado por quem consome o gerador:

def cadastro_aluno(nomes, cursos):
    cadastro = []
    for i, (nome, curso) in enumerate(zip(nomes, cursos)):
        aluno = {
            'matrícula': i,
            'nome': nome,
            'curso': curso,
        }
        cadastro.append(aluno)
    return cadastro


def gerador_aluno(nomes, cursos):    
    for i, (nome, curso) in enumerate(zip(nomes, cursos)):
        aluno = {
            'matrícula': i,
            'nome': nome,
            'curso': curso,
        }        
        yield aluno

Vamos começar pela função, vendo o resultado de passar as variáveis nomes e cursos como argumentos e vendo quanto tempo esse processamento demora. Já vimos nesse artigo que o IPython e o Jupyter Notebook têm funções próprias (“mágicas” na documentação) que permitem fazer algumas análises interessantes. Uma delas é a %timeit, que permite medir o tempo que um dado comando demora. Como esse artigo está sendo escrito em um Jupyter Notebook, podemos usar essa função.

cadastro_aluno(nomes, cursos)
[{'matrícula': 0, 'nome': 'Amerício', 'curso': 'Geografia'},
 {'matrícula': 1, 'nome': 'Califórnio', 'curso': 'Cinema'},
 {'matrícula': 2, 'nome': 'Copernício', 'curso': 'Astronomia'},
 {'matrícula': 3, 'nome': 'Disprósio', 'curso': 'Letras - Grego'},
 {'matrícula': 4, 'nome': 'Einstênio', 'curso': 'Física'}]
%timeit cadastro_aluno(nomes, cursos)
1.02 µs ± 21.2 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Observe que é pouco tempo, na casa de 1 microsegundo. Vamos ver agora o gerador. Primeiro, verifiquemos que o gerador não retorna valor algum, apenas um objeto em memória:

gerador_aluno(nomes, cursos)
<generator object gerador_aluno at 0x7f4e15114e40>

E que caso queiramos todos os resultados de uma vez, precisamos consumir o gerador colocando-o, por exemplo, numa lista:

list(gerador_aluno(nomes, cursos))
[{'matrícula': 0, 'nome': 'Amerício', 'curso': 'Geografia'},
 {'matrícula': 1, 'nome': 'Califórnio', 'curso': 'Cinema'},
 {'matrícula': 2, 'nome': 'Copernício', 'curso': 'Astronomia'},
 {'matrícula': 3, 'nome': 'Disprósio', 'curso': 'Letras - Grego'},
 {'matrícula': 4, 'nome': 'Einstênio', 'curso': 'Física'}]

Mas, se o gerador apenas cria uma referência em memória, sem efetivamente criar uma estrutura de dados, quanto tempo isso demora?

%timeit gerador_aluno(nomes, cursos)
161 ns ± 1.35 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

Veja que caímos para a ordem de nanosegundos! Afinal, apenas uma referência foi criada e cada registro de aluno será criado sob demanda:

%timeit next(gerador_aluno(nomes, cursos))
534 ns ± 31.5 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Se apenas uma referência foi criada e não uma estrutura de dados, então isso deve ter reflexo também no espaço ocupado e aqui que começa a ficar mais interessante ainda. Para analisar o espaço ocupado por cada objeto, usaremos a biblioteca pympler:

from pympler.asizeof import asizeof
asizeof(cadastro_aluno(nomes, cursos))
2400
asizeof(gerador_aluno(nomes, cursos))
1416

Os resultados estão em bytes. Como a função retorna uma lista com os registros, na penúltima célula estamos vendo o espaço ocupado por essa lista, enquanto que na última célula apenas o espaço da referência gerada.

Caso não tenha ficado claro, vamos fazer um exemplo mais demandante de recursos. Modificando um pouco a lógica da função e do gerador anteriores, podemos fazer com que recebam um inteiro que represente quantos alunos queiram ser guardados ou gerados, respectivamente. E esses alunos, em nosso exemplo, serão gerados aleatoriamente com base nas duas listas criadas anteriormente.

import random


def cadastro_aluno_aleatorio(quantidade):
    cadastro = []
    for i in range(quantidade):
        aluno = {
            'matrícula': i,
            'nome': random.choice(nomes),
            'curso': random.choice(cursos),
        }
        cadastro.append(aluno)
    return cadastro


def gerador_aluno_aleatorio(quantidade):    
    for i in range(quantidade):
        aluno = {
            'matrícula': i,
            'nome': random.choice(nomes),
            'curso': random.choice(cursos),
        }        
        yield aluno

Vejamos um exemplo com 5 alunos aleatórios:

cadastro_aluno_aleatorio(5)
[{'matrícula': 0, 'nome': 'Amerício', 'curso': 'Cinema'},
 {'matrícula': 1, 'nome': 'Einstênio', 'curso': 'Letras - Grego'},
 {'matrícula': 2, 'nome': 'Amerício', 'curso': 'Letras - Grego'},
 {'matrícula': 3, 'nome': 'Copernício', 'curso': 'Astronomia'},
 {'matrícula': 4, 'nome': 'Einstênio', 'curso': 'Letras - Grego'}]
gerador_aluno_aleatorio(5)
<generator object gerador_aluno_aleatorio at 0x7f4dfeade430>
list(gerador_aluno_aleatorio(5))
[{'matrícula': 0, 'nome': 'Einstênio', 'curso': 'Geografia'},
 {'matrícula': 1, 'nome': 'Amerício', 'curso': 'Astronomia'},
 {'matrícula': 2, 'nome': 'Califórnio', 'curso': 'Letras - Grego'},
 {'matrícula': 3, 'nome': 'Disprósio', 'curso': 'Geografia'},
 {'matrícula': 4, 'nome': 'Einstênio', 'curso': 'Astronomia'}]

Veja que os resultados são diferentes e pode haver repetição, pois agora são aleatórios.

Mas o que acontece se pedirmos tipo… 1 milhão de alunos?

%timeit cadastro_aluno_aleatorio(1_000_000)
880 ms ± 7.31 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit gerador_aluno_aleatorio(1_000_000)
144 ns ± 4.69 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

Veja que a função saiu da casa de microsegundos para a de milisegundos. Afinal, agora tem que gerar toda a lista com esses registros. No entanto, o gerador permanece na casa de nanosegundos, pois nada foi efetivamente criado ou armazenado, apenas o objeto gerador foi armazenado em memória e isso leva pouco tempo para ser feito.

E como fica o espaço ocupado por cada objeto?

asizeof(cadastro_aluno_aleatorio(1_000_000))
272698416

É até difícil compreender esse número em bytes, vamos convertê-lo para megabytes:

_ / 2**20  # conversão para megabytes
260.0654754638672

Uma lista com 1 milhão de entradas, cada uma um dicionário, ocupando 260 MB. Você imaginou esse número?

Vamos ver o gerador:

asizeof(gerador_aluno_aleatorio(1_000_000))
440

Veja que é um espaço irrisório. Afinal, nada efetivamente foi armazenado que não o objeto gerador. Agora, se efetivamente consumirmos esse gerador, armazenando cada resultado em, por exemplo, uma lista, teremos os mesmos valores anteriores:

%timeit list(gerador_aluno_aleatorio(1_000_000))
854 ms ± 21.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
asizeof(list(gerador_aluno_aleatorio(1_000_000))) / 2**20
259.63890075683594

Agora, não necessariamente precisamos consumir todo o gerador de uma vez. Essa é a beleza de geradores, podemos consumir sob demanda e, portanto, ter melhor gerenciamento dos recursos computacionais. Tanto de tempo de execução quanto de armazenamento.

Aqui no site vou mostrar futuramente usos e vou linkar aqui, mas você já pode imaginar alguns: consumo de um grande banco de dados em pequenas partes; leitura de grandes arquivos por partes; possibilidade de criar séries de valores virtualmente infinitas etc.

Conclusão

Por esse artigo é isso. Espero que tenha compreendido e aprendido algo novo. Passamos por diversos conceitos, como iteráveis, iteradores, funções, geradores e fizemos um pouco de análise de tempo e espaço ocupado. Muito valor agregado, tenho certeza.

Aproveite para acompanhar o Ciência Programada nas redes sociais.

Gostou desse artigo? Ele faz parte do Python Drops, um conjunto de posts mais curtos voltados para fundamentos falando sobre alguns aspectos da linguagem Python e de programação em geral. Você pode ler mais desses artigos buscando a tag “drops” aqui no site. Até a próxima!

Observação: o tempo gerado pela %timeit pode variar de máquina para máquina, não necessariamente você obterá os mesmos valores. No entanto, a diferença de ordens de grandeza deve se manter.

Compartilhe:

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Rolar para cima