async
semántica y la palabra clave de programación asincrónica han permeado muchos lenguajes de programación populares: JavaScript , Rust , C # y muchos otros . Por supuesto, Python también lo tiene async/await
, se introdujo en Python 3.5.
En este artículo quiero discutir los problemas del código asincrónico, especular sobre alternativas y proponer un nuevo enfoque para admitir aplicaciones sincrónicas y asincrónicas al mismo tiempo.
Color de función
Cuando se incluyen funciones asincrónicas en un lenguaje de programación, esencialmente se divide en dos. Aparecen funciones rojas (o asincrónicas) y algunas funciones permanecen azules (sincrónicas).
El principal problema es que las funciones azules no pueden llamar a las rojas, pero las rojas pueden causar potencialmente azules. En Python, por ejemplo, esto es parcialmente cierto: las funciones asincrónicas solo pueden llamar a funciones sincrónicas sin bloqueo. Pero es imposible determinar a partir de la descripción si la función está bloqueando o no. Python es un lenguaje de programación.
Esta división conduce a la división del idioma en dos subconjuntos: sincrónico y asincrónico. Python 3.5 se lanzó hace más de cinco años, pero
async
todavía no es tan compatible como las capacidades sincrónicas de Python.
Puede leer más sobre los colores de función en este excelente artículo .
Código duplicado
Diferentes colores de funciones significan duplicación de código en la práctica.
Imagine que está desarrollando una herramienta CLI para recuperar el tamaño de una página web y desea mantener formas tanto síncronas como asincrónicas de hacerlo. Por ejemplo, esto es necesario si está escribiendo una biblioteca y no sabe cómo se utilizará su código. Y no se trata solo de las bibliotecas PyPI, sino también de nuestras propias bibliotecas con lógica común para varios servicios, escritas, por ejemplo, en Django y aiohttp. Aunque, por supuesto, las aplicaciones independientes se escriben principalmente de forma síncrona o asincrónica.
Comencemos con el pseudocódigo sincrónico:
def fetch_resource_size(url: str) -> int:
response = client_get(url)
return len(response.content)
Se ve bien. Ahora veamos el análogo asincrónico:
async def fetch_resource_size(url: str) -> int:
response = await client_get(url)
return len(response.content)
En general, este es el mismo código, pero con la adición de las palabras
async
y await
. Y no me lo inventé, compare los ejemplos de código en el tutorial en http:
Hay exactamente la misma imagen.
Abstracción y composición
Resulta que necesita reescribir todo el código sincrónico y organizar aquí y allá
async
y await
para que el programa se vuelva asincrónico.
Dos principios pueden ayudar a resolver este problema. Primero, reescribamos el pseudocódigo imperativo en funcional. Esto le permitirá ver la imagen con mayor claridad.
def fetch_resource_size(url: str) -> Abstraction[int]:
return client_get(url).map(
lambda response: len(response.content),
)
Preguntas qué es este método
.map
, qué hace. Así es como se produce la composición de abstracciones complejas y funciones puras en un estilo funcional. Esto le permite crear una nueva abstracción con un nuevo estado a partir de uno existente. Suponga que client_get(url)
inicialmente regresa Abstraction[Response]
y la llamada .map(lambda response: len(response.content))
convierte la respuesta en la instancia requerida Abstraction[int]
.
Queda claro qué hacer a continuación. Observe la facilidad con la que pasamos de varios pasos independientes a llamadas de función secuenciales. Además, cambiamos el tipo de respuesta: ahora la función devuelve algo de abstracción.
Reescribamos el código para que funcione con la versión asincrónica:
def fetch_resource_size(url: str) -> AsyncAbstraction[int]:
return client_get(url).map(
lambda response: len(response.content),
)
Lo único que es diferente es el tipo de retorno -
AsyncAbstraction
. El resto del código es exactamente el mismo. Ya no es necesario utilizar palabras clave async
y await
. await
no se usa en absoluto ( por el bien de esto se inició todo ), y sin él no tiene sentido async
.
Lo último es decidir qué cliente necesitamos: asíncrono o síncrono.
def fetch_resource_size(
client_get: Callable[[str], AbstactionType[Response]],
url: str,
) -> AbstactionType[int]:
return client_get(url).map(
lambda response: len(response.content),
)
client_get
ahora es un argumento de tipo invocable que toma una cadena de URL como entrada y devuelve algún tipo AbstractionType
sobre el objeto Response
. AbstractionType
- cualquiera Abstraction
o AsyncAbstraction
de los ejemplos anteriores.
Cuando pasamos
Abstraction
, el código se ejecuta sincrónicamente, cuando AsyncAbstraction
el mismo código comienza a ejecutarse automáticamente de forma asincrónica.
IOResult y FutureResult
Afortunadamente,
dry-python/returns
ya existen las abstracciones correctas.
Permítanme presentarles una herramienta de tipo seguro, compatible con mypy, agnóstica de framework, escrita completamente en Python. Tiene abstracciones asombrosas, cómodas y maravillosas que se pueden usar en absolutamente cualquier proyecto.
Opción sincrónica
Primero, agregaremos dependencias para obtener un ejemplo reproducible.
pip install returns httpx anyio
A continuación, convierta el pseudocódigo en código Python funcional. Comencemos con la opción sincrónica.
from typing import Callable
import httpx
from returns.io import IOResultE, impure_safe
def fetch_resource_size(
client_get: Callable[[str], IOResultE[httpx.Response]],
url: str,
) -> IOResultE[int]:
return client_get(url).map(
lambda response: len(response.content),
)
print(fetch_resource_size(
impure_safe(httpx.get),
'https://sobolevn.me',
))
# => <IOResult: <Success: 27972>>
Se necesitaron un par de cambios para que el código funcionara:
- El uso
IOResultE
es una forma funcional de manejar errores de IO síncronos (las excepciones no siempre funcionan ). Los tipos basados en leResult
permiten simular excepciones, pero con valores separadosFailure()
. Las salidas exitosas luego se envuelven en un tipoSuccess
. Por lo general, a nadie le importan las excepciones, pero a nosotros sí. - Utilice
httpx
que pueda manejar solicitudes sincrónicas y asincrónicas. - Utilice una función
impure_safe
para convertir el tipo de retornohttpx.get
en una abstracciónIOResultE
.
Opción asincrónica
Intentemos hacer lo mismo en código asincrónico.
from typing import Callable
import anyio
import httpx
from returns.future import FutureResultE, future_safe
def fetch_resource_size(
client_get: Callable[[str], FutureResultE[httpx.Response]],
url: str,
) -> FutureResultE[int]:
return client_get(url).map(
lambda response: len(response.content),
)
page_size = fetch_resource_size(
future_safe(httpx.AsyncClient().get),
'https://sobolevn.me',
)
print(page_size)
print(anyio.run(page_size.awaitable))
# => <FutureResult: <coroutine object async_map at 0x10b17c320>>
# => <IOResult: <Success: 27972>>
Verá: el resultado es exactamente el mismo, pero ahora el código se ejecuta de forma asincrónica. Sin embargo, su parte principal no ha cambiado. Sin embargo, debe prestar atención a lo siguiente:
- Simultáneo
IOResultE
cambiado a asíncronoFutureResultE
,impure_safe
- activadofuture_safe
. Funciona de la misma, pero devuelve la abstracción diferente:FutureResultE
. - Utilizado
AsyncClient
desdehttpx
. - El valor resultante
FutureResult
debe ejecutarse porque las funciones rojas no se pueden llamar a sí mismas. - La utilidad
anyio
se utiliza para mostrar que este enfoque funciona con cualquier biblioteca asíncrona:asyncio
,trio
,curio
.
Dos en uno
Le mostraré cómo combinar las versiones síncrona y asincrónica en una API con seguridad de tipos.
Los tipos de tipo superior y la clase de tipo para trabajar con IO aún no se han lanzado (aparecerán en 0.15.0), por lo que lo ilustraré en lo habitual
@overload
:
from typing import Callable, Union, overload
import anyio
import httpx
from returns.future import FutureResultE, future_safe
from returns.io import IOResultE, impure_safe
@overload
def fetch_resource_size(
client_get: Callable[[str], IOResultE[httpx.Response]],
url: str,
) -> IOResultE[int]:
"""Sync case."""
@overload
def fetch_resource_size(
client_get: Callable[[str], FutureResultE[httpx.Response]],
url: str,
) -> FutureResultE[int]:
"""Async case."""
def fetch_resource_size(
client_get: Union[
Callable[[str], IOResultE[httpx.Response]],
Callable[[str], FutureResultE[httpx.Response]],
],
url: str,
) -> Union[IOResultE[int], FutureResultE[int]]:
return client_get(url).map(
lambda response: len(response.content),
)
Usamos decoradores para
@overload
describir qué datos de entrada están permitidos y qué tipo de valor de retorno será. Puedes leer más sobre el decorador @overload
en mi otro artículo .
Una llamada de función con un cliente sincrónico o asincrónico se ve así:
# Sync:
print(fetch_resource_size(
impure_safe(httpx.get),
'https://sobolevn.me',
))
# => <IOResult: <Success: 27972>>
# Async:
page_size = fetch_resource_size(
future_safe(httpx.AsyncClient().get),
'https://sobolevn.me',
)
print(page_size)
print(anyio.run(page_size.awaitable))
# => <FutureResult: <coroutine object async_map at 0x10b17c320>>
# => <IOResult: <Success: 27972>>
Como puede ver,
fetch_resource_size
en la variante sincrónica regresa inmediatamente IOResult
y lo ejecuta. Mientras que en la versión asincrónica, se requiere un bucle de eventos, como para una corrutina regular. anyio
utilizado para mostrar resultados.
En
mypy
este código no hay comentarios:
» mypy async_and_sync.py
Success: no issues found in 1 source file
Veamos qué pasa si algo se estropea.
---lambda response: len(response.content),
+++lambda response: response.content,
mypy
encuentra fácilmente nuevos errores:
» mypy async_and_sync.py
async_and_sync.py:33: error: Argument 1 to "map" of "IOResult" has incompatible type "Callable[[Response], bytes]"; expected "Callable[[Response], int]"
async_and_sync.py:33: error: Argument 1 to "map" of "FutureResult" has incompatible type "Callable[[Response], bytes]"; expected "Callable[[Response], int]"
async_and_sync.py:33: error: Incompatible return value type (got "bytes", expected "int")
Juego de manos y sin magia: escribir código asincrónico con las abstracciones correctas requiere simplemente una composición antigua. Pero el hecho de que obtengamos la misma API para diferentes tipos es realmente genial. Por ejemplo, le permite abstraerse de cómo funcionan las solicitudes HTTP: de forma sincrónica o asincrónica.
Con suerte, este ejemplo ha demostrado lo asombrosos que pueden ser los programas asincrónicos. Y si prueba dry-python / returns , encontrará muchas más cosas interesantes. En la nueva versión, ya hemos realizado las primitivas necesarias para trabajar con Higher Kinded Types y todas las interfaces necesarias. El código anterior ahora se puede reescribir así:
from typing import Callable, TypeVar
import anyio
import httpx
from returns.future import future_safe
from returns.interfaces.specific.ioresult import IOResultLike2
from returns.io import impure_safe
from returns.primitives.hkt import Kind2, kinded
_IOKind = TypeVar('_IOKind', bound=IOResultLike2)
@kinded
def fetch_resource_size(
client_get: Callable[[str], Kind2[_IOKind, httpx.Response, Exception]],
url: str,
) -> Kind2[_IOKind, int, Exception]:
return client_get(url).map(
lambda response: len(response.content),
)
# Sync:
print(fetch_resource_size(
impure_safe(httpx.get),
'https://sobolevn.me',
))
# => <IOResult: <Success: 27972>>
# Async:
page_size = fetch_resource_size(
future_safe(httpx.AsyncClient().get),
'https://sobolevn.me',
)
print(page_size)
print(anyio.run(page_size.awaitable))
# => <FutureResult: <coroutine object async_map at 0x10b17c320>>
# => <IOResult: <Success: 27972>>
Vea la rama `master`, ya funciona allí.
Más características de dry-python
Aquí hay algunas otras características útiles de dry-python de las que estoy más orgulloso.
- Funciones escritas
partial
y@curry
.
from returns.curry import curry, partial
def example(a: int, b: str) -> float:
...
reveal_type(partial(example, 1))
# note: Revealed type is 'def (b: builtins.str) -> builtins.float'
reveal_type(curry(example))
# note: Revealed type is 'Overload(def (a: builtins.int) -> def (b: builtins.str) -> builtins.float, def (a: builtins.int, b: builtins.str) -> builtins.float)'
Esto le permite usar
@curry
, por ejemplo, así:
@curry
def example(a: int, b: str) -> float:
return float(a + len(b))
assert example(1, 'abc') == 4.0
assert example(1)('abc') == 4.0
- Pipelines funcionales con inferencia de tipos.
Con un complemento mypy personalizado, puede crear canalizaciones funcionales que devuelvan tipos.
from returns.pipeline import flow
assert flow(
[1, 2, 3],
lambda collection: max(collection),
lambda max_number: -max_number,
) == -3
Por lo general, es muy inconveniente trabajar con lambdas en código escrito porque sus argumentos son siempre de tipo
Any
. La inferencia mypy
resuelve este problema.
Con su ayuda, ahora sabemos de qué
lambda collection: max(collection)
tipo Callable[[List[int]], int]
, pero lambda max_number: -max_number
simple Callable[[int], int]
. In flow
puede pasar cualquier número de argumentos y funcionarán bien. Todo gracias al complemento.
La abstracción over
FutureResult
, de la que hablamos anteriormente, se puede usar para pasar dependencias explícitamente a programas asincrónicos en un estilo funcional.
Planes para el futuro
Antes de que finalmente lancemos la versión 1.0, tenemos varias tareas importantes que resolver:
- Implementar tipos de tipo superior o su emulación ( problema ).
- Agregue las clases de tipos adecuadas para implementar las abstracciones requeridas ( problema ).
- Tal vez intente un compilador
mypyc
, que potencialmente permitirá que los programas de Python anotados y escritos se compilen en un binario. Entonces, el código cdry-python/returns
funcionará varias veces más rápido ( problema ). - Explore nuevas formas de escribir código funcional en Python, como "notación do" .
conclusiones
La composición y la abstracción pueden solucionar cualquier problema. En este artículo, analizamos cómo resolver el problema de los colores de las funciones y escribir un código simple, legible y flexible que funcione. Y verifique el tipo.
Pruebe dry-python / returns y únase a la Russian Python Week : en la conferencia, el desarrollador central de dry-python, Pablo Aguilar, llevará a cabo un taller sobre el uso de dry-python para escribir lógica empresarial.