Aprende sobre la Delegación de Generadores

Beginner

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

Introducción

En este laboratorio (lab), aprenderás sobre la delegación de generadores en Python utilizando la declaración yield from. Esta característica, introducida en Python 3.3, simplifica el código que depende de generadores y corutinas.

Los generadores son funciones especiales que pueden pausar y reanudar la ejecución, conservando su estado entre llamadas. La declaración yield from ofrece una forma elegante de delegar el control a otro generador, mejorando la legibilidad y mantenibilidad del código.

Objetivos:

  • Comprender el propósito de la declaración yield from
  • Aprender cómo usar yield from para delegar a otros generadores
  • Aplicar este conocimiento para simplificar el código basado en corutinas
  • Comprender la conexión con la sintaxis moderna de async/await

Archivos con los que trabajarás:

  • cofollow.py - Contiene funciones de utilidad de corutinas
  • server.py - Contiene una implementación simple de servidor de red

Comprender la declaración yield from

En este paso, vamos a explorar la declaración yield from en Python. Esta declaración es una herramienta poderosa cuando se trabaja con generadores, y simplifica el proceso de delegar operaciones a otros generadores. Al final de este paso, entenderás qué es yield from, cómo funciona y cómo puede manejar el paso de valores entre diferentes generadores.

¿Qué es yield from?

La declaración yield from se introdujo en Python 3.3. Su propósito principal es simplificar la delegación de operaciones a subgeneradores. Un subgenerador es simplemente otro generador al que un generador principal puede delegar trabajo.

Normalmente, cuando quieres que un generador devuelva valores de otro generador, tendrías que usar un bucle. Por ejemplo, sin yield from, escribirías código como este:

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

En este código, el delegating_generator utiliza un bucle for para iterar sobre los valores producidos por subgenerator y luego devuelve cada valor uno por uno.

Sin embargo, con la declaración yield from, el código se vuelve mucho más simple:

def delegating_generator():
    yield from subgenerator()

Esta única línea de código logra el mismo resultado que el bucle en el ejemplo anterior. Pero yield from no es solo un atajo. También gestiona la comunicación bidireccional entre el llamador y el subgenerador. Esto significa que cualquier valor enviado al generador delegador se pasa directamente al subgenerador.

Ejemplo básico

Vamos a crear un ejemplo simple para ver cómo funciona yield from en acción.

  1. Primero, necesitamos abrir el archivo cofollow.py en el editor. Para hacer esto, usaremos el comando cd para navegar al directorio correcto. Ejecuta el siguiente comando en la terminal:
cd /home/labex/project
  1. A continuación, agregaremos dos funciones al archivo cofollow.py. La función subgen es un generador simple que devuelve los números del 0 al 4. La función main_gen utiliza yield from para delegar la generación de estos números a subgen y luego devuelve la cadena 'Done'. Agrega el siguiente código al final del archivo cofollow.py:
def subgen():
    for i in range(5):
        yield i

def main_gen():
    yield from subgen()
    yield 'Done'
  1. Ahora, vamos a probar estas funciones. Abre una shell de Python y ejecuta el siguiente 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)

Cuando ejecutes este código, deberías ver la siguiente salida:

0
1
2
3
4

0
1
2
3
4
Done

Esta salida muestra que yield from permite que main_gen pase todos los valores generados por subgen directamente al llamador.

Paso de valores con yield from

Una de las características más poderosas de yield from es su capacidad para manejar el paso de valores en ambas direcciones. Vamos a crear un ejemplo más complejo para demostrar esto.

  1. Agrega las siguientes funciones al archivo 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'

La función accumulator es una corutina que lleva la cuenta de un total acumulado. Devuelve el total actual y luego espera para recibir un nuevo valor. Si recibe None, detiene el bucle. La función caller crea una instancia de accumulator y utiliza yield from para delegar todas las operaciones de envío y recepción a ella.

  1. Prueba estas funciones en una shell de 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

Cuando ejecutes este código, deberías ver la siguiente salida:

0
1
3
6
'Total accumulated'

Esta salida muestra que yield from delega completamente todas las operaciones de envío y recepción al subgenerador hasta que se agota.

Ahora que entiendes los conceptos básicos de yield from, pasaremos a aplicaciones más prácticas en el siguiente paso.

Usar yield from en corutinas

En este paso, exploraremos cómo usar la declaración yield from con corutinas para aplicaciones más prácticas. Las corutinas son un concepto poderoso en Python, y entender cómo usar yield from con ellas puede simplificar en gran medida tu código.

Corutinas y paso de mensajes

Las corutinas son funciones especiales que pueden recibir valores a través de la declaración yield. Son increíblemente útiles para tareas como el procesamiento de datos y el manejo de eventos. En el archivo cofollow.py, hay un decorador consumer. Este decorador ayuda a configurar las corutinas avanzándolas automáticamente hasta el primer punto yield. Esto significa que no tienes que iniciar manualmente la corutina; el decorador se encarga de ello por ti.

Vamos a crear una corutina que reciba valores y valide su tipo. Así es como puedes hacerlo:

  1. Primero, abre el archivo cofollow.py en el editor. Puedes usar el siguiente comando en la terminal para navegar al directorio correcto:
cd /home/labex/project
  1. A continuación, agrega la siguiente función receive al final del archivo cofollow.py. Esta función es una corutina que recibirá un mensaje y validará su tipo.
def receive(expected_type):
    """
    A coroutine that receives a message and validates its type.
    Returns the received message if it matches the expected type.
    """
    msg = yield
    assert isinstance(msg, expected_type), f'Expected type {expected_type}'
    return msg

Esto es lo que hace esta función:

  • Utiliza yield sin una expresión para recibir un valor. Cuando se envía un valor a la corutina, esta declaración yield lo capturará.
  • Comprueba si el valor recibido es del tipo esperado utilizando la función isinstance. Si el tipo no coincide, genera un AssertionError.
  • Si la comprobación de tipo es exitosa, devuelve el valor.
  1. Ahora, vamos a crear una corutina que use yield from con nuestra función receive. Esta nueva corutina recibirá e imprimirá solo enteros.
@consumer
def print_ints():
    """
    A coroutine that receives and prints integers only.
    Uses yield from to delegate to the receive coroutine.
    """
    while True:
        val = yield from receive(int)
        print('Got:', val)
  1. Para probar esta corutina, abre una shell de Python y ejecuta el siguiente 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}")

Deberías ver la siguiente salida:

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

Comprender cómo funciona yield from con corutinas

Cuando usamos yield from receive(int) en la corutina print_ints, ocurren los siguientes pasos:

  1. El control se delega a la corutina receive. Esto significa que la corutina print_ints se pausa y la corutina receive comienza a ejecutarse.
  2. La corutina receive utiliza yield para recibir un valor. Espera a que se le envíe un valor.
  3. Cuando se envía un valor a print_ints, en realidad lo recibe receive. La declaración yield from se encarga de pasar el valor de print_ints a receive.
  4. La corutina receive valida el tipo del valor recibido. Si el tipo es correcto, devuelve el valor.
  5. El valor devuelto se convierte en el resultado de la expresión yield from en la corutina print_ints. Esto significa que la variable val en print_ints se le asigna el valor devuelto por receive.

Usar yield from hace que el código sea más legible que si tuviéramos que manejar el devolver y recibir valores directamente. Abstrae la complejidad del paso de valores entre corutinas.

Crear corutinas de comprobación de tipos más avanzadas

Vamos a expandir nuestras funciones de utilidad para manejar validaciones de tipo más complejas. Así es como puedes hacerlo:

  1. Agrega las siguientes funciones al archivo 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 probar la nueva corutina, abre una shell de Python y ejecuta el siguiente 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}")

Deberías ver una salida 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'>

La declaración yield from hace que el código sea más limpio y legible. Nos permite centrarnos en la lógica de alto nivel de nuestro programa en lugar de perdernos en los detalles del paso de mensajes entre corutinas.

Envolver sockets con generadores

En este paso, aprenderemos cómo usar generadores para envolver operaciones de sockets. Este es un concepto realmente importante, especialmente cuando se trata de programación asíncrona. La programación asíncrona permite que tu programa maneje múltiples tareas al mismo tiempo sin esperar a que una tarea termine antes de comenzar otra. Usar generadores para envolver operaciones de sockets puede hacer que tu código sea más eficiente y fácil de manejar.

Comprender el problema

El archivo server.py contiene una implementación simple de un servidor de red utilizando generadores. Echemos un vistazo al código actual. Este código es la base de nuestro servidor, y entenderlo es crucial antes de realizar cualquier cambio.

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

En este código, usamos la palabra clave yield. La palabra clave yield se utiliza en Python para crear generadores. Un generador es un tipo especial de iterador que te permite pausar y reanudar la ejecución de una función. Aquí, yield se utiliza para indicar cuándo el servidor está listo para recibir una conexión o cuándo un manejador de cliente está listo para recibir o enviar datos. Sin embargo, las declaraciones yield manuales exponen el funcionamiento interno del bucle de eventos al usuario. Esto significa que el usuario tiene que saber cómo funciona el bucle de eventos, lo que puede hacer que el código sea más difícil de entender y mantener.

Crear una clase GenSocket

Vamos a crear una clase GenSocket para envolver las operaciones de sockets con generadores. Esto hará que nuestro código sea más limpio y legible. Al encapsular las operaciones de sockets en una clase, podemos ocultar los detalles del bucle de eventos al usuario y centrarnos en la lógica de alto nivel del servidor.

  1. Abre el archivo server.py en el editor:
cd /home/labex/project

Este comando cambia el directorio actual al directorio del proyecto donde se encuentra el archivo server.py. Una vez que estés en el directorio correcto, puedes abrir el archivo en tu editor de texto preferido.

  1. Agrega la siguiente clase GenSocket al final del archivo, antes de cualquier función existente:
class GenSocket:
    """
    A generator-based wrapper for socket operations.
    """
    def __init__(self, sock):
        self.sock = sock

    def accept(self):
        """Accept a connection and return a new GenSocket"""
        yield 'recv', self.sock
        client, addr = self.sock.accept()
        return GenSocket(client), addr

    def recv(self, maxsize):
        """Receive data from the socket"""
        yield 'recv', self.sock
        return self.sock.recv(maxsize)

    def send(self, data):
        """Send data to the socket"""
        yield 'send', self.sock
        return self.sock.send(data)

    def __getattr__(self, name):
        """Forward any other attributes to the underlying socket"""
        return getattr(self.sock, name)

Esta clase GenSocket actúa como un envoltorio para las operaciones de sockets. El método __init__ inicializa la clase con un objeto de socket. Los métodos accept, recv y send realizan las operaciones de socket correspondientes y usan yield para indicar cuándo la operación está lista. El método __getattr__ permite que la clase reenvíe cualquier otro atributo al objeto de socket subyacente.

  1. Ahora, modifica las funciones tcp_server y echo_handler para usar la clase 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()

Observa cómo las declaraciones explícitas yield 'recv', sock y yield 'send', client se han reemplazado con expresiones yield from más limpias. La palabra clave yield from se utiliza para delegar la ejecución a otro generador. Esto hace que el código sea más legible y oculta los detalles del bucle de eventos al usuario. Ahora, el código se parece más a llamadas de función normales, y el usuario no tiene que preocuparse por el funcionamiento interno del bucle de eventos.

  1. Vamos a agregar una función de prueba simple para demostrar cómo se usaría nuestro servidor:
def run_server():
    """Start the server on port 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 es más legible y mantenible. La clase GenSocket encapsula la lógica de yield, lo que permite que el código del servidor se centre en el flujo de alto nivel en lugar de los detalles del bucle de eventos. La función run_server inicia el servidor en el puerto 25000 y maneja la excepción KeyboardInterrupt, lo que permite al usuario detener el servidor presionando Ctrl+C.

Comprender los beneficios

El enfoque yield from ofrece varios beneficios:

  1. Código más limpio: Las operaciones de socket se parecen más a llamadas de función normales. Esto hace que el código sea más fácil de leer y entender, especialmente para los principiantes.
  2. Abstracción: Los detalles del bucle de eventos se ocultan al usuario. El usuario no tiene que saber cómo funciona el bucle de eventos para usar el código del servidor.
  3. Legibilidad: El código expresa mejor lo que está haciendo en lugar de cómo lo está haciendo. Esto hace que el código sea más autoexplicativo y fácil de mantener.
  4. Mantenibilidad: Los cambios en el bucle de eventos no requerirán cambios en el código del servidor. Esto significa que si necesitas modificar el bucle de eventos en el futuro, puedes hacerlo sin afectar el código del servidor.

Este patrón es un paso hacia la sintaxis moderna de async/await, que exploraremos en el siguiente paso. La sintaxis async/await es una forma más avanzada y limpia de escribir código asíncrono en Python, y entender el patrón yield from te ayudará a pasar a ella más fácilmente.

De generadores a async/await

En este último paso, exploraremos cómo el patrón yield from en Python evolucionó hacia la sintaxis moderna de async/await. Comprender esta evolución es crucial, ya que te ayuda a ver la conexión entre los generadores y la programación asíncrona. La programación asíncrona permite que tu programa maneje múltiples tareas sin esperar a que cada una termine, lo cual es especialmente útil en la programación de redes y otras operaciones limitadas por E/S (I/O).

La conexión entre generadores y async/await

La sintaxis async/await, introducida en Python 3.5, se basa en la funcionalidad de generadores y yield from. Bajo el capó, las funciones async se implementan utilizando generadores. Esto significa que los conceptos que has aprendido sobre generadores están directamente relacionados con cómo funciona async/await.

Para pasar del uso de generadores a la sintaxis async/await, necesitamos seguir estos pasos:

  1. Utilizar el decorador @coroutine del módulo types. Este decorador ayuda a convertir funciones basadas en generadores en una forma que se pueda usar con async/await.
  2. Convertir las funciones que usan yield from para que usen async y await en su lugar. Esto hace que el código sea más legible y exprese mejor la naturaleza asíncrona de las operaciones.
  3. Actualizar el bucle de eventos para manejar corrutinas nativas. El bucle de eventos es responsable de programar y ejecutar tareas asíncronas.

Actualizar la clase GenSocket

Ahora, modifiquemos nuestra clase GenSocket para que funcione con el decorador @coroutine. Esto permitirá que nuestra clase se use en un contexto async/await.

  1. Abre el archivo server.py en el editor. Puedes hacer esto ejecutando el siguiente comando en la terminal:
cd /home/labex/project
  1. En la parte superior del archivo server.py, agrega la importación de coroutine. Esta importación es necesaria para usar el decorador @coroutine.
from types import coroutine
  1. Actualiza la clase GenSocket para usar el decorador @coroutine. Este decorador transforma nuestros métodos basados en generadores en corrutinas esperables (awaitable), lo que significa que se pueden usar con la palabra clave await.
class GenSocket:
    """
    A generator-based wrapper for socket operations
    that works with async/await.
    """
    def __init__(self, sock):
        self.sock = sock

    @coroutine
    def accept(self):
        """Accept a connection and return a new GenSocket"""
        yield 'recv', self.sock
        client, addr = self.sock.accept()
        return GenSocket(client), addr

    @coroutine
    def recv(self, maxsize):
        """Receive data from the socket"""
        yield 'recv', self.sock
        return self.sock.recv(maxsize)

    @coroutine
    def send(self, data):
        """Send data to the socket"""
        yield 'send', self.sock
        return self.sock.send(data)

    def __getattr__(self, name):
        """Forward any other attributes to the underlying socket"""
        return getattr(self.sock, name)

Convertir a la sintaxis async/await

A continuación, convertamos nuestro código del servidor para usar la sintaxis async/await. Esto hará que el código sea más legible y exprese claramente la naturaleza asíncrona de las operaciones.

async def tcp_server(address, handler):
    """
    An asynchronous TCP server using 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):
    """
    An asynchronous handler for echo clients.
    """
    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()

Observa que yield from se ha reemplazado con await, y las funciones ahora se definen con async def en lugar de def. Este cambio hace que el código sea más intuitivo y fácil de entender.

Comprender la transformación

La transición de generadores con yield from a la sintaxis async/await no es solo un simple cambio sintáctico. Representa un cambio en cómo pensamos sobre la programación asíncrona.

  1. Generadores con yield from:

    • Cuando se usan generadores con yield from, se cede explícitamente el control para señalar que una tarea está lista. Esto significa que tienes que gestionar manualmente cuándo una tarea puede continuar.
    • También necesitas gestionar manualmente la programación de las tareas. Esto puede ser complejo, especialmente en programas más grandes.
    • El enfoque se centra en la mecánica del flujo de control, lo que puede hacer que el código sea más difícil de leer y mantener.
  2. Sintaxis async/await:

    • Con la sintaxis async/await, el control se cede implícitamente en los puntos await. Esto hace que el código sea más sencillo, ya que no tienes que preocuparte por ceder explícitamente el control.
    • El bucle de eventos se encarga de programar las tareas, por lo que no tienes que gestionarlo manualmente.
    • El enfoque se centra en el flujo lógico del programa, lo que hace que el código sea más legible y mantenible.

Esta transformación permite escribir código asíncrono más legible y mantenible, lo cual es especialmente importante para aplicaciones complejas como servidores de red.

Programación asíncrona moderna

En Python moderno, generalmente se utiliza el módulo asyncio para la programación asíncrona en lugar de un bucle de eventos personalizado. El módulo asyncio proporciona soporte integrado para muchas características útiles:

  • Ejecutar múltiples corrutinas de forma concurrente. Esto permite que tu programa maneje múltiples tareas al mismo tiempo.
  • Gestionar la E/S de red. Simplifica el proceso de enviar y recibir datos a través de la red.
  • Primitivas de sincronización. Estas te ayudan a gestionar el acceso a recursos compartidos en un entorno concurrente.
  • Programación y cancelación de tareas. Puedes programar fácilmente tareas para que se ejecuten en momentos específicos y cancelarlas si es necesario.

Así es como podría verse nuestro servidor utilizando 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 logra la misma funcionalidad que nuestro servidor basado en generadores, pero utiliza la biblioteca estándar asyncio, que es más robusta y rica en características.

Conclusión

En este laboratorio, has aprendido varios conceptos importantes:

  1. La declaración yield from y cómo delega a otro generador. Este es un concepto fundamental para entender cómo funcionan los generadores.
  2. Cómo usar yield from con corrutinas para el paso de mensajes. Esto te permite comunicarte entre diferentes partes de tu programa asíncrono.
  3. Envolver operaciones de sockets con generadores para un código más limpio. Esto hace que tu código relacionado con la red esté más organizado y sea más fácil de entender.
  4. La transición de generadores a la sintaxis moderna de async/await. Comprender esta transición te ayudará a escribir código asíncrono más legible y mantenible en Python, ya sea que uses generadores directamente o la sintaxis moderna de async/await.

Resumen

En este laboratorio, has aprendido sobre el concepto de delegación de generadores en Python, centrándote en la declaración yield from y sus diversas aplicaciones. Has explorado cómo usar yield from para delegar a otro generador, lo que simplifica el código y mejora la legibilidad. También has aprendido sobre la creación de corrutinas con yield from para recibir y validar mensajes, y el uso de generadores para envolver operaciones de sockets para obtener un código de red más limpio.

Estos conceptos son esenciales para entender la programación asíncrona en Python. La transición de generadores a la sintaxis moderna de async/await representa un avance significativo en el manejo de operaciones asíncronas. Para explorar más a fondo estos conceptos, puedes estudiar el módulo asyncio, examinar cómo los frameworks populares utilizan async/await y desarrollar tus propias bibliotecas asíncronas. Comprender la delegación de generadores y yield from proporciona una visión más profunda del enfoque de Python para la programación asíncrona.