TransWikia.com

Como o Python trata o comando "yield" internamente?

Stack Overflow em Português Asked on September 27, 2021

Estava lendo sobre o comando yield do Python, e me parece que este comando cria um generator que seria uma especie de lista de dados no qual o retorno do valor ocorre sobre demanda, como se o ultimo “estado” da interação fosse “memorizado” de alguma forma.

Para comprovar isso, veja esta função que retorna três letras:

def letras():
    yield 'A'
    yield 'B'
    yield 'C'

Chamando a função letras() num for para obter os dados:

for letra in letras():
    print(letra)

Perceba que a saída sera:

A
B
C

Agora, se eu modificar a função letras() para ela incrementar o valor de v que é uma variavel global:

def letras():
    global v
    v += 1
    print(v)
    yield 'A'
    yield 'B'
    yield 'C'

e a saída:

1
A
B
C

Veja que v possui o valor 1, isso mostra que a função letras() não memorizou o estado de v, apenas os valores retornados no yield, é como se a função tivesse sido chamada uma única vez. Por consequência, eu ainda não consigo ver de forma clara como é o funcionamento do yield, pois o comportamento da função parece ter sido diferente do que eu esperava e me gerou mais confusão ainda acerca do yield, talvez entender como o Python lida com ele internamente pode ajudar.

Pergunta

Portanto, eu gostaria de saber como o Python trata o comando yield internamente? Ou qual estrutura ou mecanismo o yield usa?

2 Answers

Entendendo o que acontece

O yield internamente faz realmente muita coisa: Pra começar: o fato de uma função ter a palavra chave yield em qualquer parte em seu corpo faz com que ela seja tratada de forma diferente pelo Python. A função deixa de ser uma função e passa a ser uma "generator function".

Essa mudança só é comparável a funções que sejam declaradas explicitamente como assíncronas com a sequência async def em vez de somente def.

O quê acontece é mais ou menos fácil de entender: quando você chama uma função que tenha um yield em seu corpo - mesmo que esse yield nunca vá ser executado - nenhuma linha dessa função é executada de imediato. O que o Python faz é criar um objeto especial do tipo generator que é devolvido como se fosse o "retorno" dessa função.

Um "generator" por sua vez é um objeto que vai ter o método especial __next__, (e também send e throw - falo mais abaixo).

Quando um "generator" é usado do lado direito de um comando for, a própria linguagem vai chamar o método __next__ do generator, uma vez para cada repetição do for.

Da primeira vez que o __next__ é chamado, aí sim, a função começa a ser executada na primeira linha - acontece com ela o que acontece com funções normais - ela recebe os parâmetros que foram passados na chamada inicial, e é executada linha a linha, até chegar no primeiro yield. Nesse momento a execução é "suspensa" - o valor de todas as variáveis locais do generator é salvo, e o valor que foi enviado pelo yield é retornado como resultado da chamada à função __next__. Na próxima chamada ao __next__, o processamento não começa de novo na primeira linha da função, e sim, justamente no ponto em que estava o yield - o processamento continua a partir dali, linha a linha, até o encontro de um novo yield, ou um comando return (ou o final do código da função, que equivale, em Python, a um return None).

Quando o generator chega ao fim, em vez de retornar o valor que está no return - ele gera uma exceção do tipo StpoIteration. O comando for captura automaticamente esse StopIteration e encerra o bloco do for.

Fica mais fácil entender se criarmos uma função com yield dentro, e a usarmos no modo "manual", sem o for:

In [275]: def exemplo(): 
     ...:     print("primeira parte") 
     ...:     yield 0 
     ...:     print("segunda parte") 
     ...:     yield 1 
     ...:     print("parte final") 
     ...:     return 2 
     ...:                                                                                                                        

In [276]: type(exemplo)                                                                                                          
Out[276]: function

In [278]: gen = exemplo()                                                                                                        

In [279]: gen, type(gen)                                                                                                         
Out[279]: (<generator object exemplo at 0x7f5533e2a6d8>, generator)

In [281]: gen.__next__()                                                                                                         
primeira parte
Out[281]: 0

In [282]: gen.__next__()                                                                                                         
segunda parte
Out[282]: 1

In [283]: gen.__next__()                                                                                                         
parte final
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-283-d5d004b357fe> in <module>
----> 1 gen.__next__()

StopIteration: 2

Perceba que nada foi impresso depois da execução da entrada "278" - a chamada exemplo() retorna um "generator", como podemos ver pela representação e o tipo, na entrada "279", e a linha que imprime "primeira parte" só é chamada quando chamamos o __next__ a primeira vez.

Usando a mesma função exemplo em um for, a saída é:

In [284]: for v in exemplo(): 
     ...:     print(v) 
     ...:                                                                                                                        
primeira parte
0
segunda parte
1
parte final

Uma outra informação que é legal: os métodos especiais, com dois __ de prefixo e sufixo muito raramente tem que ser chamados diretamente - em geral esses métodos são chamados pela própria linguagem. Então, em vez de chamarmos direto o .__next__ em uma função, o mais comum é usar a função next do Python e passar o generator como parâmetro.

Então, o comando for do Python, quando usado com uma "generator function" é equivalente à esta sequencia usando while:

In [286]: gen = exemplo()                                                                                                        

In [287]: while True: 
     ...:     try: 
     ...:         v = next(gen) 
     ...:     except StopIteration: 
     ...:         break 
     ...:     print(v) 
     ...:                                                                                                                        
primeira parte
0
segunda parte
1
parte final

(O for do Python é mais esperto que isso ainda por que funciona com outros tipo o de objetos: além de detectar generators, ele funciona também com iterables: objetos que tem o método __iter__, e objetos que tenham os métodos __len__ e __getitem__ e, opcionalmente keys, em conjunto.)

Na sua pergunta você acrescenta variáveis globais à generator function de exemplo: uma variável global é global e seu valor será preservado entre chamadas consecutivas ao mesmo generator ou intercaladas com outras instâncias do generator.

Como o Python distingue um generator de uma generator function:

Como dito acima, é o próprio compilador do Python que transforma uma função em uma "generator function". O tipo de objeto de uma função que contenha um yield continua sendo uma function - como pode ser visto na saída "276" acima. O que o Python faz é que nos flags do objeto __code__ de uma generator function, ela é marcada como tal - isso faz com que o comportamento da linguagem quando ela é chamada seja completamente diferente.

Ou seja, não é "fácil" ver que uma função é uma "generator function" sem olhar seu código e ver o yield lá - mas com os mecanismos de introspecção do Python, podemos ver que o flag de nome "GENERATOR" está setado no atributo .__code__.co_flags da função. O valor desse flag pode ser visto no módulo dis:

In [288]: def exemplo(): 
     ...:     yield 
     ...:                                                                                                                        

In [289]: def contra_exemplo(): 
     ...:     return None 
     ...:                                                                                                                        

In [290]: import dis                                                                                                             

In [291]: dis.COMPILER_FLAG_NAMES                                                                                                
Out[291]: 
{1: 'OPTIMIZED',
 2: 'NEWLOCALS',
 4: 'VARARGS',
 8: 'VARKEYWORDS',
 16: 'NESTED',
 32: 'GENERATOR',
 64: 'NOFREE',
 128: 'COROUTINE',
 256: 'ITERABLE_COROUTINE',
 512: 'ASYNC_GENERATOR'}

In [292]: bool(exemplo.__code__.co_flags & 32)                                                                                   
Out[292]: True

In [293]: bool(contra_exemplo.__code__.co_flags & 32)                                                                            
Out[293]: False

Detalhes internos

Perceba que se você criar mais de um generator a partir da mesma "generator function", e usa-los de forma intercalada, cada um vai ter suas próprias variáveis locais - elas não se misturam:

In [294]: def exemplo3(): 
     ...:     counter = 0 
     ...:     yield counter 
     ...:     counter += 1 
     ...:     yield counter 
     ...:                                                                                                                        

In [295]: gen1 = exemplo3()                                                                                                      

In [296]: gen2 = exemplo3()                                                                                                      

In [297]: next(gen1)                                                                                                             
Out[297]: 0

In [298]: next(gen2)                                                                                                             
Out[298]: 0

In [299]: next(gen2)                                                                                                             
Out[299]: 1

In [300]: next(gen1)                                                                                                             
Out[300]: 1

Onde essas variáveis locais ficam guardadas então? Sempre que executamos um bloco de código em Python, seja uma função normal, seja um generator, seja o corpo de um módulo ou o corpo de uma classe, o Python cria um objeto do tipo Frame. A linguagem expõe esses Frames como objetos de Python bem normais - e você pode, dentro deles achar as variáveis locais e as globais de qualquer bloco de código em execução. Num programa que não faça uso de generators ou funções assíncronas, um novo objeto tipo Frame é criado cada vez que uma função é chamada - e o frame mais recente sempre tem uma referência ao anterior. Isso cria uma "pilha" - que chamamos de "pilha de chamada" em Python. Esses objetos Frame não são muito pequenos ou de criação eficiente, por isso que não usamos muito funções recursivas em Python, exceto em código didático ou onde realmente seja a melhor solução. Um dos atributos de um frame é o .f_back: uma referência direta ao frame anterior - e outra é o f_locals que é um dicionário que espelha as variáveis locais do código em execução (o f_locals no entando só funciona pra leitura dessas variáveis, não pra escrita de seus valores).

Uma função recursiva com alguns prints pode mostrar o uso normal de frames, sem generators:

In [306]: import sys                                                                                                             

In [307]: def exemplo4(count): 
     ...:     if count < 4: 
     ...:         print("entrando") 
     ...:         exemplo4(count + 1) 
     ...:         print("saindo") 
     ...:     else: 
     ...:         print(f"count: {count}") 
     ...:         frame = sys._getframe() 
     ...:         frame_count = count 
     ...:         while frame_count: 
     ...:             print(frame, frame.f_locals["count"]) 
     ...:             frame = frame.f_back 
     ...:             frame_count -= 1 
     ...:              
     ...:                                                                                                                        

In [308]: exemplo4(1)                                                                                                            
entrando
entrando
entrando
count: 4
<frame at 0x564291236028, file '<ipython-input-307-165f77ce3bd1>', line 11, code exemplo4> 4
<frame at 0x564291308638, file '<ipython-input-307-165f77ce3bd1>', line 4, code exemplo4> 3
<frame at 0x56429132ead8, file '<ipython-input-307-165f77ce3bd1>', line 4, code exemplo4> 2
<frame at 0x5642910e4ff8, file '<ipython-input-307-165f77ce3bd1>', line 4, code exemplo4> 1
saindo
saindo
saindo

Quando um generator é pausado com o yield, o Frame de execução dele sai dessa pilha - o Frame do topo da pilha volta a ser o da função que chamou o __next__. O Frame do generator fica então guardado no atributo .gi_frame do próprio generator. O atributo f_locals pode inspecionar o valor das variáveis dentro do mesmo no momento em que o yield foi executado:

In [319]: def exemplo5(): 
     ...:     v = 10 
     ...:     yield v 
     ...:     v += 10 
     ...:     yield v 
     ...:                                                                                                                        

In [320]: gen = exemplo5()                                                                                                       

In [321]: gen.gi_frame.f_locals["v"]                                                                                             
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-321-645eee9080b0> in <module>
----> 1 gen.gi_frame.f_locals["v"]

KeyError: 'v'

In [322]: next(gen)                                                                                                              
Out[322]: 10

In [323]: gen.gi_frame.f_locals["v"]                                                                                             
Out[323]: 10

In [324]: next(gen)                                                                                                              
Out[324]: 20

In [325]: gen.gi_frame.f_locals["v"]                                                                                             
Out[325]: 20

Emulando um generator com uma classe:

Nada impede que qualquer classe em Python se comporte exatamente como um generator. Nesse caso, as variáveis internas devem ser guardadas, entre uma iteração e outra, como um atributo da instância - enquanto que o Python salva as variáveis locais guardando o Frame de execução.

Para fazer isso, basta escrever uma classe que tenha o método especial __next__ de forma explícita, e o método __iter__ que será executado antes da primeira chamada ao __next__ pelo for (a classe pode inclusive ser separada em 2 estágios - o objeto retornado por __iter__ pode ser de outra classe ou outra instância, e implementar apenas o __next__). Note que os valores que um generator retorna usando yield devem ser retornados com um return comum desta função.

Então o código Python para gerar os quadrados dos números de 0 até n em Python pode ser escrito como uma "generator function" assim:

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

ou como uma classe assim:

class Squares:
    def __init__(self, n):
        self.n = n
        self.i = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.i >= self.n:
            raise StopIteration()
        result = self.i ** 2
        self.i += 1
        return result

Usando essa classe no modo interativo:

In [331]: for s in Squares(4): 
 ...:     print(s) 
 ...:                                                                                                                        
0
1
4
9

Essas classes não são chamadas de "generators" - esse nome é usado apenas para os objetos criados quando se chama uma função que contenha um yield (as tais "generator functions"). Esse tipo de classe é chamado pelo nome mais genérico de iterável - qualquer objeto que possa produzir um iterador - um iterador, por sua vez, é o nome mais genérico para qualquer objeto que tenha o método __next__.

Outros métodos dos generators e "informações avançadas":

Além do método __next__, os generators também tem os métodos .send e .throw - esses métodos nunca são chamados automaticamente pelo for. Em vez disso, podem ser usados quando se usa um generator de forma "manual" para enviar valores para um generator que já está em execução, ou para causar um erro de um tipo determinado com o .throw - nesse caso, o argumento do throw é um objeto do tipo exceção - e ela é causada no ponto em que está o yield.

Essas funcionalidades são bem pouco usadas de forma explícita em código "do dia a dia", e foram acrescentadas por que com as mesmas, os generators de Python passam a poder ser usados como "co-rotinas". Isso é diferente de funções normais que sempre são "sub-rotinas". As co-rotinas podem ser chamadas em paralelo de forma colaborativa por um sistema especializado.

Uma outra expressão associada é o yield from - ela faz com que um generator possa "fazer yield" de um outro gerador, interno, sem que o valor nunca seja processado por ele mesmo - isso permite, por exemplo, generators recursivos.

Eu tenho um projeto "de brinquedo" em que uso generator functions como "co-rotinas", sem ser programação assíncrona - ele simula um efeito "chapado" de "Matrix" no terminal. Só funciona em um terminal que tiver os códigos ANSI habilitados, o que permite sequências especiais de impressão para posicionar o cursor e alterar a cor das letras - o que ainda não acontece no Windows. O projeto está aqui e funciona bem em Linux e Mac: https://github.com/jsbueno/terminal_matrix/blob/master/matrix.py (e para ativar os códigos ANSI no terminal do Windows, veja algo aqui: https://stackoverflow.com/questions/16755142/how-to-make-win32-console-recognize-ansi-vt100-escape-sequences)

A combinação dos recursos propiciados pelos métodos .send, .throw e pela expressão yield from é o que foi usado para permitir o uso de programação assíncrona no Python: isso é, muitas funções que são executadas numa única thread, mas de forma paralela, passando a execução do programa para uma outra "co-rotina" toda vez que uma chamada tiver que acessar um recurso externo do sistema operacional que vai levar tempo para ser completada (uma requisição de rede, ler dados de um arquivo, uma pausa do tipo time.sleep, etc...).

A programação assíncrona, sua sintaxe e seu uso são um tópicos que pode dar nó na cabeça mesmo de programadores avançados - obviamente não cabe descrever tudo nesta resposta - mas vale a pena mencionar que até a versão 3.4 do Python, quando foi introduzida o módulo asyncio na linguagem, a forma de se fazer programação assíncrona em Python era com o uso de generators e yield from, e a execução, pausa e continuação das co-rotinas era (e ainda é), controlada pelo loop de eventos do asyncio que usa os métodos __next__, send e throw. A partir do Python 3.5 foi introduzida uma sintaxe separada para as funções assíncronas - o async def, await, e outras como async for, etc... - mas os mecanismos internos que o Python usa são os mesmíssimos que são usados para os generators.

Resumindo:

Uma função que contenha um yield ou um yield from é uma "generator function". Quando ela é chamada não é executada imediatamente- ela retorna um objeto do tipo "generator". Objetos do tipo "generator" tem um método __next__ que quando chamado executa o código da função original até encontrar um yield - nesse momento a função é "pausada" - sua execução "para onde está" e a execução do programa retorna para o ponto onde o método __next__ foi chamado - diretamente, ou de forma implícita com o comando for. Quando o __next__ for chamado de novo, o generator é "despausado" - e a execução continua no ponto do yield. Se em vez do método .__next__ o método .send do generator for chamado, o valor passado como parâmetro do .send é o valor que o yield assume dentro do código do generator (senão o yield vale None). Há também o método .throw: o parâmetro para o mesmo deve ser uma exceção - o Python faz com que aquela exceção aconteça no ponto em que está o yield

Outras perguntas com mais informações sobre o funcionamento do Yield:

Python geradores assincronos

Python palavra reservada yield

Para que serve o Yield?

Correct answer by jsbueno on September 27, 2021

De fato quando tem um yield na função ela não retorna aquele valor, ela retorna um gerador. Este gerador é um objeto que guarda o estado necessário para seu controle, então ele sabe onde havia parado e assim pode continuar na próxima vez que for chamado.

O for tem um mecanismo próprio que administra isto. Se chamar na mão precisa cuidar do acesso ao gerador na mão. A função next() é usada para manipular o gerador.

Execute este código:

def letras():
    yield 'A'
    yield 'B'
    yield 'C'
gerador = letras()
print(next(gerador))
print(next(gerador))
print(next(gerador))
print(next(letras()))
print(next(letras()))

Coloquei no GitHub para referência futura.

Esta variável gerador é que guarda o gerador e portando o estado da execução, então toda vez que invocar a função ele vai olhar onde o gerador está e irá incrementar 1 no seu estado interno. Como ele faz isto é detalhe de implementação, mas basicamente guarda uma lista de dados e um contador. Esta lista pode ser um controle das linhas a serem executadas. Em Python padrão deve mudar pouco porque já há um mecanismo interno da VM que controla a pilha de execução, então é só encapsular isto em um objeto para controle do gerador.

Perceba que se chamar a função sem guardar o estado ela sempre começa com um gerador novo.

Você não vê no for mas um gerador é criado dentro dele que vai do começo ao fim, o for é uma abstração para o padrão de projeto chamado Iterador. E a função é construída para criar o objeto gerador, por ser uma abstração você não vê o estado sendo criado.

Experimenta chamar 4 vezes o next(gerador) ali em vez de três. O iterador lançará um exceção porque não tem mais dados para avaliar.

Os objetos não pegam iteradores implicitamente, por isso tem um método que retorna o iterador, e como isto é muito comum tem um padrão para pegar este iterador. Objetos que podem ser iteráveis possuem o método __iter__() e o objeto iterador deve possuir sua própria implementação da função __next__(). Os objetos nativos do Python já possuem isto.

No caso de uma função há um objeto interno que é capaz de ser iterável que é criado para lidar com o andamento da função e onde ele está, mas não é diferente de ter o estado e a capacidade de entregar o iterador e quem é o próximo item.

Answered by Maniero on September 27, 2021

Add your own answers!

Ask a Question

Get help from others!

© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP