Las excepciones de Python ahora se consideran antipatrón

¬Ņ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) # => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]") 

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') # => error: incompatible type "str"; expected "float" except ZeroDivisionError as exc: return Failure(0) # => error: incompatible type "int"; expected "ZeroDivisionError" 

¬Ņ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)) # => Success(2) Success(4).map(lambda number: number + 1) # => Success(5) 

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)) # => Failure(4) Failure(4).map(lambda number: number / 2) # => Failure(4) 

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)) # => Success(5) Failure(4).fix(lambda number: number / 2) # => Success(2) 

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() # => 1 Success(0).value_or(None) # => 0 Failure(0).value_or(None) # => None Failure(1).unwrap() # => Raises UnwrapFailedError() 

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.""" # TODO: we need to create a pipeline of these methods somehow... def _validate_user( self, username: str, email: str, ) -> Result['UserSchema', str]: """Returns an UserSchema for valid input, otherwise a Failure.""" def _create_account( self, user_schema: 'UserSchema', ) -> Result['Account', str]: """Creates an Account for valid UserSchema's. Or returns a Failure.""" def _create_user( self, account: 'Account', ) -> Result['User', str]: """Create an User instance. If user already exists returns Failure.""" 

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 # is the same as: def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc) 

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.""" #: You can later use dependency injection to replace `requests` #: with any other http library (or even a custom service). _http = requests @pipeline def __call__(self, user_id: int) -> Result['UserProfile', Exception]: """Fetches UserProfile dict from foreign API.""" response = self._make_request(user_id).unwrap() return self._parse_json(response) @safe def _make_request(self, user_id: int) -> requests.Response: response = self._http.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response @safe def _parse_json(self, response: requests.Response) -> 'UserProfile': return response.json() 

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.

Source: https://habr.com/ru/post/445234/


All Articles