¿Qué son las excepciones? Por el nombre está claro: surgen cuando ocurre una excepción en el programa. Puede preguntar por qué las excepciones son un antipatrón y cómo se relacionan con la escritura. Traté de
resolverlo , y ahora quiero discutir esto contigo, harazhiteli.
Problemas de excepción
Es difícil encontrar fallas en lo que enfrentas todos los días. El hábito y la ceguera convierten los errores en características, pero tratemos de ver las excepciones con una mente abierta.
Las excepciones son difíciles de detectar
Hay dos tipos de excepciones: las "explícitas" se crean llamando a
raise
directamente en el código que está leyendo; Los "ocultos" están ocultos en las funciones, clases, métodos utilizados.
El problema es que las excepciones "ocultas" son realmente difíciles de notar. Déjame mostrarte un ejemplo de una función pura:
def divide(first: float, second: float) -> float: return first / second
La función simplemente divide un número por otro, devolviendo un
float
. Los tipos están marcados y puede ejecutar algo como esto:
result = divide(1, 0) print('x / y = ', result)
¿Te has dado cuenta? De hecho, la ejecución del programa nunca llegará a
print
, porque dividir 1 por 0 es una operación imposible, generará un
ZeroDivisionError
. Sí, dicho código es de tipo seguro, pero no se puede usar de todos modos.
Para notar un problema potencial incluso en un código tan simple y legible, uno necesita experiencia. Cualquier cosa en Python puede dejar de funcionar con diferentes tipos de excepciones: división, llamadas a funciones,
int
,
str
, generadores, iteradores en bucles, acceso a atributos o claves. Incluso
raise something()
puede provocar un bloqueo. Además, ni siquiera menciono las operaciones de entrada y salida. Y las
excepciones marcadas ya no serán compatibles en el futuro cercano.
Restaurar el comportamiento normal en su lugar no es posible
Pero precisamente para tal caso, tenemos excepciones. Solo manejemos
ZeroDivisionError
y el código se convertirá en tipo seguro.
def divide(first: float, second: float) -> float: try: return first / second except ZeroDivisionError: return 0.0
Ahora todo está bien. Pero, ¿por qué devolvemos 0? ¿Por qué no 1 o
None
? Por supuesto, en la mayoría de los casos, obtener
None
casi tan malo (si no peor) como una excepción, pero aún así debe confiar en la lógica empresarial y las opciones para usar la función.
¿Qué estamos compartiendo exactamente? Números arbitrarios, alguna unidad específica o dinero? No todas las opciones son fáciles de prever y restaurar. Puede resultar que la próxima vez que use una función, resulte que necesita una lógica de recuperación diferente.
La triste conclusión: la solución a cada problema es individual, dependiendo del contexto específico de uso.
No hay una bala de plata para tratar con
ZeroDivisionError
una vez por todas. Y no estamos hablando de la posibilidad de E / S complejas con solicitudes y tiempos de espera repetidos.
¿Quizás no sea necesario manejar las excepciones exactamente donde surgen? Tal vez solo lo arroje al proceso de ejecución del código; alguien lo resolverá más tarde. Y luego nos vemos obligados a volver al estado actual de las cosas.
Proceso de ejecución poco claro
Bueno, esperemos que alguien más detecte la excepción y tal vez la maneje. Por ejemplo, el sistema puede pedirle al usuario que cambie el valor ingresado, porque no puede dividirse entre 0. Y la función de
divide
no debe ser explícitamente responsable de recuperarse del error.
En este caso, debe verificar dónde detectamos la excepción. Por cierto, ¿cómo determinar exactamente dónde se procesará? ¿Es posible ir al lugar correcto en el código? Resulta que no,
es imposible .
No es posible determinar qué línea de código se ejecutará después de que se genere la excepción. Se pueden manejar diferentes tipos de excepciones con diferentes opciones
except
, y se pueden
ignorar algunas excepciones. Y puede lanzar excepciones adicionales en otros módulos que se ejecutarán antes, y en general romperá toda la lógica.
Supongamos que hay dos subprocesos independientes en una aplicación: un subproceso normal que se ejecuta de arriba a abajo y un subproceso de excepción que se ejecuta a su gusto. ¿Cómo leer y entender este código?
Solo con el depurador activado en el modo "capturar todas las excepciones".
Excepciones, como el notorio
goto
, desgarran la estructura del programa.
Las excepciones no son exclusivas.
Veamos otro ejemplo: el código de acceso HTTP API remoto habitual:
import requests def fetch_user_profile(user_id: int) -> 'UserProfile': """Fetches UserProfile dict from foreign API.""" response = requests.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response.json()
En este ejemplo, literalmente todo puede salir mal. Aquí hay una lista parcial de posibles errores:
- La red puede no estar disponible y la solicitud no se ejecutará en absoluto.
- El servidor puede no funcionar.
- El servidor puede estar demasiado ocupado, se producirá un tiempo de espera.
- El servidor puede requerir autenticación.
- Una API puede no tener dicha URL.
- Un usuario inexistente puede ser transferido.
- Puede que no haya suficientes derechos.
- El servidor puede bloquearse debido a un error interno al procesar su solicitud
- El servidor puede devolver una respuesta no válida o dañada.
- El servidor puede devolver JSON no válidos que no se pueden analizar.
La lista sigue y sigue, por lo que muchos problemas potenciales se encuentran en el código de las desafortunadas tres líneas. Podemos decir que generalmente funciona solo por casualidad, y es mucho más probable que caiga con una excepción.
¿Cómo protegerte?
Ahora que nos hemos asegurado de que las excepciones pueden ser dañinas para el código, descubramos cómo deshacernos de ellas. Para escribir código sin excepción, hay diferentes patrones.
- Escribir en todas partes
except Exception: pass
. Callejón sin salida. No lo hagas. - Devolver
None
. Demasiado malvado. Como resultado, tendrá que comenzar casi todas las líneas if something is not None:
y toda la lógica se perderá detrás de la basura de los controles de limpieza, o sufrirá TypeError
todo el tiempo. No es una buena elección. - Escribir clases para casos de uso especial. Por ejemplo, la clase base
User
con subclases para errores como UserNotFound
y MissingUser
. Este enfoque se puede utilizar en algunas situaciones específicas, como AnonymousUser
en Django, pero no es realista incluir todos los posibles errores en las clases. Tomará demasiado trabajo y el modelo de dominio se volverá inimaginablemente complejo. - Use contenedores para ajustar la variable resultante o el valor de error en un contenedor y continúe trabajando con el valor del contenedor. Es por eso que creamos el proyecto
@dry-python/return
. Entonces, las funciones devuelven algo significativo, mecanografiado y seguro.
Volvamos al ejemplo de división, que devuelve 0 cuando ocurre un error. ¿Podemos indicar explícitamente que la función no tuvo éxito sin devolver un valor numérico específico?
from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc)
Adjuntamos los valores en uno de los dos contenedores:
Success
o
Failure
. Estas clases se heredan de la clase base
Result
. Los tipos de valores empaquetados se pueden especificar en la anotación con la función devuelta, por ejemplo,
Result[float, ZeroDivisionError]
devuelve
Success[float]
o
Failure[ZeroDivisionError]
.
¿Qué nos da esto? Más
excepciones no son excepcionales, pero son problemas esperados . Además, envolver una excepción en
Failure
resuelve un segundo problema: la complejidad de identificar posibles excepciones.
1 + divide(1, 0)
Ahora son fáciles de detectar. Si ve
Result
en el código, la función puede generar una excepción. E incluso sabes de antemano su tipo.
Además, la biblioteca está completamente tipificada y es
compatible con PEP561 . Es decir, mypy te avisará si intentas devolver algo que no coincide con el tipo declarado.
from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success('Done')
¿Cómo trabajar con contenedores?
Hay dos
métodos :
map
para funciones que devuelven valores normales;bind
para funciones que devuelven otros contenedores.
Success(4).bind(lambda number: Success(number / 2))
Lo bueno es que este código lo protegerá de los scripts fallidos, ya que
.bind
y
.map
no se ejecutarán para contenedores con
Failure
:
Failure(4).bind(lambda number: Success(number / 2))
Ahora puede concentrarse en el proceso de ejecución correcto y asegurarse de que el estado incorrecto no interrumpa el programa en un lugar inesperado. Y siempre existe la oportunidad de
determinar el estado incorrecto, corregirlo y volver al camino concebido del proceso.
Failure(4).rescue(lambda number: Success(number + 1))
En nuestro enfoque, "todos los problemas se resuelven individualmente" y "el proceso de ejecución ahora es transparente". ¡Disfruta de la programación que viaja sobre rieles!
Pero, ¿cómo expandir los valores de los contenedores?
De hecho, si trabaja con funciones que no saben nada sobre contenedores, necesita los valores mismos. Luego puede usar los
.unwrap()
o
.value_or()
:
Success(1).unwrap()
Espera, tuvimos que deshacernos de las excepciones, y ahora resulta que todas las llamadas
.unwrap()
pueden conducir a otra excepción.
¿Cómo no pensar en UnwrapFailedErrors?
Ok, veamos cómo vivir con las nuevas excepciones. Considere este ejemplo: debe verificar la entrada del usuario y crear dos modelos en la base de datos. Cada paso puede terminar con una excepción, razón por la cual todos los métodos se envuelven en
Result
:
from returns.result import Result, Success, Failure class CreateAccountAndUser(object): """Creates new Account-User pair."""
En primer lugar, no tiene que expandir los valores en su propia lógica de negocios:
class CreateAccountAndUser(object): """Creates new Account-User pair.""" def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" return self._validate_user( username, email, ).bind( self._create_account, ).bind( self._create_user, )
Todo funcionará sin problemas, no se generarán excepciones, porque
.unwrap()
no se utiliza. ¿Pero es fácil leer ese código? No ¿Y cuál es la alternativa?
@pipeline
:
from result.functions import pipeline class CreateAccountAndUser(object): """Creates new Account-User pair.""" @pipeline def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" user_schema = self._validate_user(username, email).unwrap() account = self._create_account(user_schema).unwrap() return self._create_user(account)
Ahora este código está bien leído. Así es como
.unwrap()
y
@pipeline
trabajan juntos: cada vez que
.unwrap()
un método
.unwrap()
y
Failure[str]
, el decorador
@pipeline
lo
@pipeline
y devuelve
Failure[str]
como el valor resultante. Así es como propongo eliminar todas las excepciones del código y hacerlo realmente seguro y escrito.
Envuélvelo todo
Bueno, ahora aplicaremos las nuevas herramientas, por ejemplo, con una solicitud a la API HTTP. ¿Recuerdas que cada línea puede lanzar una excepción? Y no hay forma de hacer que devuelvan el contenedor con
Result
. Pero puede usar el
decorador @safe para envolver funciones inseguras y hacerlas seguras. A continuación hay dos opciones de código que hacen lo mismo:
from returns.functions import safe @safe def divide(first: float, second: float) -> float: return first / second
El primero, con
@safe
, es más fácil y mejor de leer.
Lo último que se debe hacer en el ejemplo de solicitud de API es agregar el decorador
@safe
. El resultado es el siguiente código:
import requests from returns.functions import pipeline, safe from returns.result import Result class FetchUserProfile(object): """Single responsibility callable object that fetches user profile."""
Para resumir cómo deshacerse de las excepciones y asegurar el código :
- Use el contenedor
@safe
para todos los métodos que pueden @safe
una excepción. Cambiará el tipo de retorno de la función a Result[OldReturnType, Exception]
. - Utilice
Result
como contenedor para transferir valores y errores en una simple abstracción. - Use
.unwrap()
para expandir el valor del contenedor. - Use
@pipeline
para hacer que las .unwrap
llamadas .unwrap
sean más fáciles de leer.
Al observar estas reglas, podemos hacer exactamente lo mismo, solo que es seguro y fácil de leer. Se resolvieron todos los problemas con excepciones:
- "Las excepciones son difíciles de detectar" . Ahora están envueltos en un contenedor de
Result
escrito, lo que los hace completamente transparentes. - "Restablecer el comportamiento normal en su lugar es imposible" . Ahora puede delegar con seguridad el proceso de recuperación a la persona que llama. Para tal caso, hay
.fix()
y .rescue()
. - "La secuencia de ejecución no está clara" . Ahora son uno con el flujo comercial habitual. De principio a fin.
- "Las excepciones no son excepcionales" . Lo sabemos! Y esperamos que algo salga mal y listo para cualquier cosa.
Casos de uso y limitaciones
Obviamente, no puede usar este enfoque en todo su código. Será
demasiado seguro para la mayoría de las situaciones cotidianas y es incompatible con otras bibliotecas o marcos. Pero debe escribir las partes más importantes de su lógica de negocios exactamente como lo mostré, para garantizar el funcionamiento correcto de su sistema y facilitar el soporte futuro.
¿El tema te hace pensar o incluso parecer holivarny? Ven a Moscú Python Conf ++ el 5 de abril, ¡lo discutiremos! Además de mí, Artyom Malyshev, fundador del proyecto de python seco y desarrollador principal de Django Channels, estará allí. Hablará más sobre python seco y lógica de negocios.