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.
Tópicos
return – Funções comuns
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çofor
ou que podem ser obtidos um de cada vez com a funçãonext()
. 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çõestry
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.