Aprenda sobre Delegação de Geradores

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Neste laboratório, você aprenderá sobre a delegação de geradores em Python usando a instrução yield from. Este recurso, introduzido no Python 3.3, simplifica o código que depende de geradores e corrotinas.

Geradores são funções especiais que podem pausar e retomar a execução, mantendo seu estado entre as chamadas. A instrução yield from oferece uma maneira elegante de delegar o controle a outro gerador, aprimorando a legibilidade e a manutenibilidade do código.

Objetivos:

  • Compreender o propósito da instrução yield from
  • Aprender como usar yield from para delegar a outros geradores
  • Aplicar este conhecimento para simplificar o código baseado em corrotinas
  • Compreender a conexão com a sintaxe moderna async/await

Arquivos com os quais você trabalhará:

  • cofollow.py - Contém funções utilitárias de corrotinas
  • server.py - Contém uma implementação simples de servidor de rede

Compreendendo a instrução yield from

Nesta etapa, vamos explorar a instrução yield from em Python. Esta instrução é uma ferramenta poderosa ao trabalhar com geradores, e simplifica o processo de delegação de operações a outros geradores. Ao final desta etapa, você entenderá o que é yield from, como funciona e como pode lidar com a passagem de valores entre diferentes geradores.

O que é yield from?

A instrução yield from foi introduzida no Python 3.3. Seu principal objetivo é simplificar a delegação de operações a subgeradores. Um subgerador é apenas outro gerador ao qual um gerador principal pode delegar trabalho.

Normalmente, quando você deseja que um gerador produza valores de outro gerador, você precisaria usar um loop. Por exemplo, sem yield from, você escreveria um código como este:

def delegating_generator():
    for value in subgenerator():
        yield value

Neste código, o delegating_generator usa um loop for para iterar sobre os valores produzidos por subgenerator e, em seguida, produz cada valor um por um.

No entanto, com a instrução yield from, o código se torna muito mais simples:

def delegating_generator():
    yield from subgenerator()

Esta única linha de código atinge o mesmo resultado que o loop no exemplo anterior. Mas yield from não é apenas um atalho. Ele também gerencia a comunicação bidirecional entre o chamador e o subgerador. Isso significa que quaisquer valores enviados ao gerador delegador são passados diretamente para o subgerador.

Exemplo Básico

Vamos criar um exemplo simples para ver como yield from funciona na prática.

  1. Primeiro, precisamos abrir o arquivo cofollow.py no editor. Para fazer isso, usaremos o comando cd para navegar até o diretório correto. Execute o seguinte comando no terminal:
cd /home/labex/project
  1. Em seguida, adicionaremos duas funções ao arquivo cofollow.py. A função subgen é um gerador simples que produz os números de 0 a 4. A função main_gen usa yield from para delegar a geração desses números a subgen e, em seguida, produz a string 'Done'. Adicione o seguinte código ao final do arquivo cofollow.py:
def subgen():
    for i in range(5):
        yield i

def main_gen():
    yield from subgen()
    yield 'Done'
  1. Agora, vamos testar essas funções. Abra um shell Python e execute o seguinte código:
from cofollow import subgen, main_gen

## Test subgen directly
for x in subgen():
    print(x)

## Test main_gen that delegates to subgen
for x in main_gen():
    print(x)

Quando você executar este código, deverá ver a seguinte saída:

0
1
2
3
4

0
1
2
3
4
Done

Esta saída mostra que yield from permite que main_gen passe todos os valores gerados por subgen diretamente para o chamador.

Passagem de Valores com yield from

Uma das características mais poderosas de yield from é sua capacidade de lidar com a passagem de valores em ambas as direções. Vamos criar um exemplo mais complexo para demonstrar isso.

  1. Adicione as seguintes funções ao arquivo cofollow.py:
def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            break
        total += value

def caller():
    acc = accumulator()
    yield from acc
    yield 'Total accumulated'

A função accumulator é uma corrotina que acompanha um total acumulado. Ela produz o total atual e, em seguida, espera receber um novo valor. Se receber None, interrompe o loop. A função caller cria uma instância de accumulator e usa yield from para delegar todas as operações de envio e recebimento a ela.

  1. Teste essas funções em um shell Python:
from cofollow import caller

c = caller()
print(next(c))  ## Start the coroutine
print(c.send(1))  ## Send value 1, get accumulated value
print(c.send(2))  ## Send value 2, get accumulated value
print(c.send(3))  ## Send value 3, get accumulated value
print(c.send(None))  ## Send None to exit the accumulator

Quando você executar este código, deverá ver a seguinte saída:

0
1
3
6
'Total accumulated'

Esta saída mostra que yield from delega totalmente todas as operações de envio e recebimento ao subgerador até que ele seja esgotado.

Agora que você entende o básico de yield from, passaremos para aplicações mais práticas na próxima etapa.

Usando yield from em Corrotinas

Nesta etapa, exploraremos como usar a instrução yield from com corrotinas para aplicações mais práticas. Corrotinas são um conceito poderoso em Python, e entender como usar yield from com elas pode simplificar muito seu código.

Corrotinas e Passagem de Mensagens

Corrotinas são funções especiais que podem receber valores através da instrução yield. Elas são incrivelmente úteis para tarefas como processamento de dados e tratamento de eventos. No arquivo cofollow.py, existe um decorador consumer. Este decorador ajuda a configurar corrotinas, avançando-as automaticamente para o primeiro ponto yield. Isso significa que você não precisa iniciar a corrotina manualmente; o decorador cuida disso para você.

Vamos criar uma corrotina que recebe valores e valida seus tipos. Veja como você pode fazer isso:

  1. Primeiro, abra o arquivo cofollow.py no editor. Você pode usar o seguinte comando no terminal para navegar até o diretório correto:
cd /home/labex/project
  1. Em seguida, adicione a seguinte função receive ao final do arquivo cofollow.py. Esta função é uma corrotina que receberá uma mensagem e validará seu tipo.
def receive(expected_type):
    """
    A corrotina que recebe uma mensagem e valida seu tipo.
    Retorna a mensagem recebida se corresponder ao tipo esperado.
    """
    msg = yield
    assert isinstance(msg, expected_type), f'Expected type {expected_type}'
    return msg

Aqui está o que esta função faz:

  • Ela usa yield sem uma expressão para receber um valor. Quando a corrotina recebe um valor, esta instrução yield o capturará.
  • Ela verifica se o valor recebido é do tipo esperado usando a função isinstance. Se o tipo não corresponder, ela levanta um AssertionError.
  • Se a verificação de tipo passar, ela retorna o valor.
  1. Agora, vamos criar uma corrotina que usa yield from com nossa função receive. Esta nova corrotina receberá e imprimirá apenas inteiros.
@consumer
def print_ints():
    """
    A corrotina que recebe e imprime apenas inteiros.
    Usa yield from para delegar à corrotina receive.
    """
    while True:
        val = yield from receive(int)
        print('Got:', val)
  1. Para testar esta corrotina, abra um shell Python e execute o seguinte código:
from cofollow import print_ints

p = print_ints()
p.send(42)
p.send(13)
try:
    p.send('13')  ## This should raise an AssertionError
except AssertionError as e:
    print(f"Error: {e}")

Você deve ver a seguinte saída:

Got: 42
Got: 13
Error: Expected type <class 'int'>

Compreendendo como yield from funciona com Corrotinas

Quando usamos yield from receive(int) na corrotina print_ints, as seguintes etapas ocorrem:

  1. O controle é delegado à corrotina receive. Isso significa que a corrotina print_ints pausa, e a corrotina receive começa a executar.
  2. A corrotina receive usa yield para receber um valor. Ela espera que um valor seja enviado a ela.
  3. Quando um valor é enviado para print_ints, ele é realmente recebido por receive. A instrução yield from cuida de passar o valor de print_ints para receive.
  4. A corrotina receive valida o tipo do valor recebido. Se o tipo estiver correto, ela retorna o valor.
  5. O valor retornado se torna o resultado da expressão yield from na corrotina print_ints. Isso significa que a variável val em print_ints recebe o valor retornado por receive.

Usar yield from torna o código mais legível do que se tivéssemos que lidar com a produção e recebimento diretamente. Ele abstrai a complexidade da passagem de valores entre corrotinas.

Criando Corrotinas de Verificação de Tipo Mais Avançadas

Vamos expandir nossas funções utilitárias para lidar com uma validação de tipo mais complexa. Veja como você pode fazer isso:

  1. Adicione as seguintes funções ao arquivo cofollow.py:
def receive_dict():
    """Receive and validate a dictionary"""
    result = yield from receive(dict)
    return result

def receive_str():
    """Receive and validate a string"""
    result = yield from receive(str)
    return result

@consumer
def process_data():
    """Process different types of data using the receive utilities"""
    while True:
        print("Waiting for a string...")
        name = yield from receive_str()
        print(f"Got string: {name}")

        print("Waiting for a dictionary...")
        data = yield from receive_dict()
        print(f"Got dictionary with {len(data)} items: {data}")

        print("Processing complete for this round.")
  1. Para testar a nova corrotina, abra um shell Python e execute o seguinte código:
from cofollow import process_data

proc = process_data()
proc.send("John Doe")
proc.send({"age": 30, "city": "New York"})
proc.send("Jane Smith")
try:
    proc.send(123)  ## This should raise an AssertionError
except AssertionError as e:
    print(f"Error: {e}")

Você deve ver uma saída como esta:

Waiting for a string...
Got string: John Doe
Waiting for a dictionary...
Got dictionary with 2 items: {'age': 30, 'city': 'New York'}
Processing complete for this round.
Waiting for a string...
Got string: Jane Smith
Waiting for a dictionary...
Error: Expected type <class 'dict'>

A instrução yield from torna o código mais limpo e legível. Ela nos permite focar na lógica de alto nível do nosso programa, em vez de ficarmos atolados nos detalhes da passagem de mensagens entre corrotinas.

Empacotando Sockets com Geradores

Nesta etapa, vamos aprender como usar geradores para empacotar operações de socket. Este é um conceito muito importante, especialmente quando se trata de programação assíncrona. A programação assíncrona permite que seu programa lide com múltiplas tarefas de uma vez sem esperar que uma tarefa termine antes de iniciar outra. Usar geradores para empacotar operações de socket pode tornar seu código mais eficiente e mais fácil de gerenciar.

Compreendendo o Problema

O arquivo server.py contém uma implementação simples de servidor de rede usando geradores. Vamos dar uma olhada no código atual. Este código é a base do nosso servidor, e entendê-lo é crucial antes de fazermos quaisquer alterações.

def tcp_server(address, handler):
    sock = socket(AF_INET, SOCK_STREAM)
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(5)
    while True:
        yield 'recv', sock
        client, addr = sock.accept()
        tasks.append(handler(client, addr))

def echo_handler(client, address):
    print('Connection from', address)
    while True:
        yield 'recv', client
        data = client.recv(1000)
        if not data:
            break
        yield 'send', client
        client.send(b'GOT:' + data)
    print('Connection closed')
    client.close()

Neste código, usamos a palavra-chave yield. A palavra-chave yield é usada em Python para criar geradores. Um gerador é um tipo especial de iterador que permite pausar e retomar a execução de uma função. Aqui, yield é usado para indicar quando o servidor está pronto para receber uma conexão ou quando um manipulador de cliente está pronto para receber ou enviar dados. No entanto, as instruções yield manuais expõem o funcionamento interno do loop de eventos ao usuário. Isso significa que o usuário precisa saber como o loop de eventos funciona, o que pode tornar o código mais difícil de entender e manter.

Criando uma Classe GenSocket

Vamos criar uma classe GenSocket para empacotar operações de socket com geradores. Isso tornará nosso código mais limpo e legível. Ao encapsular as operações de socket em uma classe, podemos ocultar os detalhes do loop de eventos do usuário e focar na lógica de alto nível do servidor.

  1. Abra o arquivo server.py no editor:
cd /home/labex/project

Este comando altera o diretório atual para o diretório do projeto onde o arquivo server.py está localizado. Depois de estar no diretório correto, você pode abrir o arquivo em seu editor de texto preferido.

  1. Adicione a seguinte classe GenSocket ao final do arquivo, antes de quaisquer funções existentes:
class GenSocket:
    """
    Um wrapper baseado em gerador para operações de socket.
    """
    def __init__(self, sock):
        self.sock = sock

    def accept(self):
        """Aceita uma conexão e retorna um novo GenSocket"""
        yield 'recv', self.sock
        client, addr = self.sock.accept()
        return GenSocket(client), addr

    def recv(self, maxsize):
        """Recebe dados do socket"""
        yield 'recv', self.sock
        return self.sock.recv(maxsize)

    def send(self, data):
        """Envia dados para o socket"""
        yield 'send', self.sock
        return self.sock.send(data)

    def __getattr__(self, name):
        """Encaminha quaisquer outros atributos para o socket subjacente"""
        return getattr(self.sock, name)

Esta classe GenSocket atua como um wrapper para operações de socket. O método __init__ inicializa a classe com um objeto socket. Os métodos accept, recv e send executam as operações de socket correspondentes e usam yield para indicar quando a operação está pronta. O método __getattr__ permite que a classe encaminhe quaisquer outros atributos para o objeto socket subjacente.

  1. Agora, modifique as funções tcp_server e echo_handler para usar a classe GenSocket:
def tcp_server(address, handler):
    sock = GenSocket(socket(AF_INET, SOCK_STREAM))
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(5)
    while True:
        client, addr = yield from sock.accept()
        tasks.append(handler(client, addr))

def echo_handler(client, address):
    print('Connection from', address)
    while True:
        data = yield from client.recv(1000)
        if not data:
            break
        yield from client.send(b'GOT:' + data)
    print('Connection closed')
    client.close()

Observe como as instruções explícitas yield 'recv', sock e yield 'send', client foram substituídas por expressões yield from mais limpas. A palavra-chave yield from é usada para delegar a execução a outro gerador. Isso torna o código mais legível e oculta os detalhes do loop de eventos do usuário. Agora, o código se parece mais com chamadas de função normais, e o usuário não precisa se preocupar com o funcionamento interno do loop de eventos.

  1. Vamos adicionar uma função de teste simples para demonstrar como nosso servidor seria usado:
def run_server():
    """Inicia o servidor na porta 25000"""
    tasks.append(tcp_server(('localhost', 25000), echo_handler))
    try:
        event_loop()
    except KeyboardInterrupt:
        print("Server stopped")

if __name__ == '__main__':
    print("Starting echo server on port 25000...")
    print("Press Ctrl+C to stop")
    run_server()

Este código é mais legível e fácil de manter. A classe GenSocket encapsula a lógica de produção, permitindo que o código do servidor se concentre no fluxo de alto nível, em vez dos detalhes do loop de eventos. A função run_server inicia o servidor na porta 25000 e lida com a exceção KeyboardInterrupt, que permite ao usuário parar o servidor pressionando Ctrl+C.

Compreendendo os Benefícios

A abordagem yield from oferece vários benefícios:

  1. Código mais limpo: As operações de socket se parecem mais com chamadas de função normais. Isso torna o código mais fácil de ler e entender, especialmente para iniciantes.
  2. Abstração: Os detalhes do loop de eventos são ocultos do usuário. O usuário não precisa saber como o loop de eventos funciona para usar o código do servidor.
  3. Legibilidade: O código expressa melhor o que está fazendo, em vez de como está fazendo. Isso torna o código mais autoexplicativo e mais fácil de manter.
  4. Manutenibilidade: Mudanças no loop de eventos não exigirão mudanças no código do servidor. Isso significa que, se você precisar modificar o loop de eventos no futuro, poderá fazê-lo sem afetar o código do servidor.

Este padrão é um trampolim para a sintaxe moderna async/await, que exploraremos na próxima etapa. A sintaxe async/await é uma maneira mais avançada e limpa de escrever código assíncrono em Python, e entender o padrão yield from o ajudará a fazer a transição para ele com mais facilidade.

De Geradores para Async/Await

Nesta etapa final, exploraremos como o padrão yield from em Python evoluiu para a sintaxe moderna async/await. Entender essa evolução é crucial, pois ajuda você a ver a conexão entre geradores e programação assíncrona. A programação assíncrona permite que seu programa lide com múltiplas tarefas sem esperar que cada uma termine, o que é especialmente útil em programação de rede e outras operações de I/O.

A Conexão entre Geradores e Async/Await

A sintaxe async/await, introduzida no Python 3.5, é construída sobre a funcionalidade de geradores e yield from. Por baixo dos panos, as funções async são implementadas usando geradores. Isso significa que os conceitos que você aprendeu sobre geradores estão diretamente relacionados a como async/await funciona.

Para fazer a transição do uso de geradores para a sintaxe async/await, precisamos seguir estas etapas:

  1. Use o decorador @coroutine do módulo types. Este decorador ajuda a converter funções baseadas em geradores em uma forma que pode ser usada com async/await.
  2. Converta funções que usam yield from para usar async e await em vez disso. Isso torna o código mais legível e expressa melhor a natureza assíncrona das operações.
  3. Atualize o loop de eventos para lidar com corrotinas nativas. O loop de eventos é responsável por agendar e executar tarefas assíncronas.

Atualizando a Classe GenSocket

Agora, vamos modificar nossa classe GenSocket para trabalhar com o decorador @coroutine. Isso permitirá que nossa classe seja usada em um contexto async/await.

  1. Abra o arquivo server.py no editor. Você pode fazer isso executando o seguinte comando no terminal:
cd /home/labex/project
  1. No topo do arquivo server.py, adicione a importação para coroutine. Esta importação é necessária para usar o decorador @coroutine.
from types import coroutine
  1. Atualize a classe GenSocket para usar o decorador @coroutine. Este decorador transforma nossos métodos baseados em geradores em corrotinas awaitable, o que significa que eles podem ser usados com a palavra-chave await.
class GenSocket:
    """
    Um wrapper baseado em gerador para operações de socket
    que funciona com async/await.
    """
    def __init__(self, sock):
        self.sock = sock

    @coroutine
    def accept(self):
        """Aceita uma conexão e retorna um novo GenSocket"""
        yield 'recv', self.sock
        client, addr = self.sock.accept()
        return GenSocket(client), addr

    @coroutine
    def recv(self, maxsize):
        """Recebe dados do socket"""
        yield 'recv', self.sock
        return self.sock.recv(maxsize)

    @coroutine
    def send(self, data):
        """Envia dados para o socket"""
        yield 'send', self.sock
        return self.sock.send(data)

    def __getattr__(self, name):
        """Encaminha quaisquer outros atributos para o socket subjacente"""
        return getattr(self.sock, name)

Convertendo para a Sintaxe Async/Await

Em seguida, vamos converter nosso código do servidor para usar a sintaxe async/await. Isso tornará o código mais legível e expressará claramente a natureza assíncrona das operações.

async def tcp_server(address, handler):
    """
    Um servidor TCP assíncrono usando async/await.
    """
    sock = GenSocket(socket(AF_INET, SOCK_STREAM))
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(5)
    while True:
        client, addr = await sock.accept()
        tasks.append(handler(client, addr))

async def echo_handler(client, address):
    """
    Um manipulador assíncrono para clientes echo.
    """
    print('Connection from', address)
    while True:
        data = await client.recv(1000)
        if not data:
            break
        await client.send(b'GOT:' + data)
    print('Connection closed')
    client.close()

Observe que yield from foi substituído por await, e as funções agora são definidas com async def em vez de def. Essa mudança torna o código mais intuitivo e fácil de entender.

Compreendendo a Transformação

A transição de geradores com yield from para a sintaxe async/await não é apenas uma simples mudança sintática. Ela representa uma mudança em como pensamos sobre programação assíncrona.

  1. Geradores com yield from:

    • Ao usar geradores com yield from, você explicitamente cede o controle para sinalizar que uma tarefa está pronta. Isso significa que você precisa gerenciar manualmente quando uma tarefa pode continuar.
    • Você também precisa gerenciar manualmente o agendamento de tarefas. Isso pode ser complexo, especialmente em programas maiores.
    • O foco está na mecânica do fluxo de controle, o que pode tornar o código mais difícil de ler e manter.
  2. Sintaxe Async/await:

    • Com a sintaxe async/await, o controle é implicitamente cedido nos pontos await. Isso torna o código mais direto, pois você não precisa se preocupar em ceder explicitamente o controle.
    • O loop de eventos cuida do agendamento de tarefas, então você não precisa gerenciá-lo manualmente.
    • O foco está no fluxo lógico do programa, o que torna o código mais legível e fácil de manter.

Essa transformação permite um código assíncrono mais legível e fácil de manter, o que é especialmente importante para aplicações complexas como servidores de rede.

Programação Assíncrona Moderna

No Python moderno, geralmente usamos o módulo asyncio para programação assíncrona em vez de um loop de eventos personalizado. O módulo asyncio fornece suporte integrado para muitos recursos úteis:

  • Executar múltiplas corrotinas simultaneamente. Isso permite que seu programa lide com múltiplas tarefas ao mesmo tempo.
  • Gerenciar I/O de rede. Ele simplifica o processo de envio e recebimento de dados pela rede.
  • Primitivas de sincronização. Elas ajudam você a gerenciar o acesso a recursos compartilhados em um ambiente concorrente.
  • Agendamento e cancelamento de tarefas. Você pode facilmente agendar tarefas para serem executadas em horários específicos e cancelá-las, se necessário.

Veja como nosso servidor pode parecer usando asyncio:

import asyncio

async def handle_client(reader, writer):
    addr = writer.get_extra_info('peername')
    print(f'Connection from {addr}')

    while True:
        data = await reader.read(1000)
        if not data:
            break

        writer.write(b'GOT:' + data)
        await writer.drain()

    print('Connection closed')
    writer.close()
    await writer.wait_closed()

async def main():
    server = await asyncio.start_server(
        handle_client, 'localhost', 25000
    )

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

if __name__ == '__main__':
    asyncio.run(main())

Este código atinge a mesma funcionalidade que nosso servidor baseado em geradores, mas usa a biblioteca asyncio padrão, que é mais robusta e rica em recursos.

Conclusão

Neste laboratório, você aprendeu sobre vários conceitos importantes:

  1. A instrução yield from e como ela delega para outro gerador. Este é um conceito fundamental para entender como os geradores funcionam.
  2. Como usar yield from com corrotinas para passagem de mensagens. Isso permite que você se comunique entre diferentes partes do seu programa assíncrono.
  3. Empacotar operações de socket com geradores para um código mais limpo. Isso torna seu código relacionado à rede mais organizado e fácil de entender.
  4. A transição de geradores para a sintaxe moderna async/await. Entender essa transição o ajudará a escrever um código assíncrono mais legível e fácil de manter em Python, seja usando geradores diretamente ou a sintaxe moderna async/await.

Resumo

Neste laboratório, você aprendeu sobre o conceito de delegação de geradores em Python, com foco na instrução yield from e suas diversas aplicações. Você explorou como usar yield from para delegar a outro gerador, o que simplifica o código e aprimora a legibilidade. Você também aprendeu sobre a criação de corrotinas com yield from para receber e validar mensagens, e o uso de geradores para empacotar operações de socket para um código de rede mais limpo.

Esses conceitos são essenciais para entender a programação assíncrona em Python. A transição de geradores para a sintaxe moderna async/await representa um avanço significativo no tratamento de operações assíncronas. Para explorar mais a fundo esses conceitos, você pode estudar o módulo asyncio, examinar como as estruturas populares usam async/await e desenvolver suas próprias bibliotecas assíncronas. Entender a delegação de geradores e yield from fornece uma visão mais profunda da abordagem do Python para a programação assíncrona.