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 frompara 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 corutinasserver.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.
- Primero, necesitamos abrir el archivo
cofollow.pyen el editor. Para hacer esto, usaremos el comandocdpara navegar al directorio correcto. Ejecuta el siguiente comando en la terminal:
cd /home/labex/project
- A continuación, agregaremos dos funciones al archivo
cofollow.py. La funciónsubgenes un generador simple que devuelve los números del 0 al 4. La funciónmain_genutilizayield frompara delegar la generación de estos números asubgeny luego devuelve la cadena'Done'. Agrega el siguiente código al final del archivocofollow.py:
def subgen():
for i in range(5):
yield i
def main_gen():
yield from subgen()
yield 'Done'
- 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.
- 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.
- 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:
- Primero, abre el archivo
cofollow.pyen el editor. Puedes usar el siguiente comando en la terminal para navegar al directorio correcto:
cd /home/labex/project
- A continuación, agrega la siguiente función
receiveal final del archivocofollow.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
yieldsin una expresión para recibir un valor. Cuando se envía un valor a la corutina, esta declaraciónyieldlo capturará. - Comprueba si el valor recibido es del tipo esperado utilizando la función
isinstance. Si el tipo no coincide, genera unAssertionError. - Si la comprobación de tipo es exitosa, devuelve el valor.
- Ahora, vamos a crear una corutina que use
yield fromcon nuestra funciónreceive. 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)
- 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:
- El control se delega a la corutina
receive. Esto significa que la corutinaprint_intsse pausa y la corutinareceivecomienza a ejecutarse. - La corutina
receiveutilizayieldpara recibir un valor. Espera a que se le envíe un valor. - Cuando se envía un valor a
print_ints, en realidad lo recibereceive. La declaraciónyield fromse encarga de pasar el valor deprint_intsareceive. - La corutina
receivevalida el tipo del valor recibido. Si el tipo es correcto, devuelve el valor. - El valor devuelto se convierte en el resultado de la expresión
yield fromen la corutinaprint_ints. Esto significa que la variablevalenprint_intsse le asigna el valor devuelto porreceive.
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:
- 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.")
- 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.
- Abre el archivo
server.pyen 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.
- Agrega la siguiente clase
GenSocketal 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.
- Ahora, modifica las funciones
tcp_serveryecho_handlerpara usar la claseGenSocket:
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.
- 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:
- 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.
- 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.
- 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.
- 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:
- Utilizar el decorador
@coroutinedel módulotypes. Este decorador ayuda a convertir funciones basadas en generadores en una forma que se pueda usar conasync/await. - Convertir las funciones que usan
yield frompara que usenasyncyawaiten su lugar. Esto hace que el código sea más legible y exprese mejor la naturaleza asíncrona de las operaciones. - 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.
- Abre el archivo
server.pyen el editor. Puedes hacer esto ejecutando el siguiente comando en la terminal:
cd /home/labex/project
- En la parte superior del archivo
server.py, agrega la importación decoroutine. Esta importación es necesaria para usar el decorador@coroutine.
from types import coroutine
- Actualiza la clase
GenSocketpara 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 claveawait.
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.
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.
- Cuando se usan generadores con
Sintaxis async/await:
- Con la sintaxis
async/await, el control se cede implícitamente en los puntosawait. 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.
- Con la sintaxis
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:
- La declaración
yield fromy cómo delega a otro generador. Este es un concepto fundamental para entender cómo funcionan los generadores. - Cómo usar
yield fromcon corrutinas para el paso de mensajes. Esto te permite comunicarte entre diferentes partes de tu programa asíncrono. - 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.
- 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 deasync/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.