Les exceptions Python sont désormais considérées comme anti-modèle

Quelles sont les exceptions? D'après le nom, c'est clair - ils surviennent lorsqu'une exception se produit dans le programme. Vous pouvez vous demander pourquoi les exceptions sont un anti-modèle et comment sont-elles liées à la frappe? J'ai essayé de comprendre , et maintenant je veux en discuter avec vous, harazhiteli.

Problèmes d'exception


Il est difficile de trouver des failles dans ce à quoi vous faites face tous les jours. L'habitude et la cécité transforment les bogues en fonctionnalités, mais essayons d'examiner les exceptions avec un esprit ouvert.

Les exceptions sont difficiles à repérer


Il existe deux types d'exceptions: les «explicites» sont créées en appelant raise directement dans le code que vous lisez; «Caché» est caché dans les fonctions, classes et méthodes utilisées.

Le problème est que les exceptions «cachées» sont vraiment difficiles à remarquer. Permettez-moi de vous montrer un exemple de fonction pure:

 def divide(first: float, second: float) -> float: return first / second 

La fonction divise simplement un nombre par un autre, renvoyant un float . Les types sont vérifiés et vous pouvez exécuter quelque chose comme ceci:

 result = divide(1, 0) print('x / y = ', result) 

L'avez-vous remarqué? En fait, l'exécution du programme n'atteindra jamais l' print , car la division de 1 par 0 est une opération impossible, elle ZeroDivisionError une ZeroDivisionError . Oui, ce code est sûr pour le type, mais il ne peut pas être utilisé de toute façon.

Pour remarquer un problème potentiel même dans un code aussi simple et lisible, il faut de l'expérience. Tout en Python peut cesser de fonctionner avec différents types d'exceptions: division, appels de fonction, int , str , générateurs, itérateurs en boucles, accès aux attributs ou aux clés. Même raise something() peut provoquer un crash. De plus, je ne mentionne même pas les opérations d'entrée et de sortie. Et les exceptions vérifiées ne seront plus prises en charge dans un avenir proche.

Il n'est pas possible de rétablir un comportement normal en place


Mais précisément pour un tel cas, nous avons des exceptions. Prenons simplement ZeroDivisionError et le code deviendra sûr pour le type.

 def divide(first: float, second: float) -> float: try: return first / second except ZeroDivisionError: return 0.0 

Maintenant tout va bien. Mais pourquoi renvoyons-nous 0? Pourquoi pas 1 ou None ? Bien sûr, dans la plupart des cas, obtenir None presque aussi mauvais (sinon pire) qu'une exception, mais vous devez toujours vous fier à la logique métier et aux options pour utiliser la fonction.

Que partageons-nous exactement? Des nombres arbitraires, des unités spécifiques ou de l'argent? Toutes les options ne sont pas faciles à prévoir et à restaurer. Il peut s'avérer que la prochaine fois que vous utilisez une fonction, il s'avère que vous avez besoin d'une logique de récupération différente.

La triste conclusion: la solution à chaque problème est individuelle, selon le contexte spécifique d'utilisation.

Il n'y a pas de ZeroDivisionError miracle pour faire face à ZeroDivisionError une fois pour toutes. Et nous ne parlons pas de la possibilité d'E / S complexes avec des demandes et des délais d'expiration répétés.

Peut-être qu'il n'est pas nécessaire de gérer les exceptions exactement où elles se produisent? Peut-être simplement le jeter dans le processus d'exécution du code - quelqu'un le découvrira plus tard. Et puis nous sommes obligés de revenir à l'état actuel des choses.

Le processus d'exécution n'est pas clair


Eh bien, espérons que quelqu'un d'autre intercepte l'exception et peut-être la gère. Par exemple, le système peut demander à l'utilisateur de modifier la valeur entrée, car elle ne peut pas être divisée par 0. Et la fonction de divide ne doit pas être explicitement responsable de la récupération après l'erreur.

Dans ce cas, vous devez vérifier où nous avons intercepté l'exception. Soit dit en passant, comment déterminer exactement où il sera traité? Est-il possible d'aller au bon endroit dans le code? Il s’avère que non, c’est impossible .

Il n'est pas possible de déterminer la ligne de code qui s'exécutera après la levée de l'exception. Différents types d'exceptions peuvent être gérés avec différentes options except , et certaines exceptions peuvent être ignorées . Et vous pouvez lever des exceptions supplémentaires dans d'autres modules qui seront exécutés plus tôt, et en général rompre toute la logique.

Supposons qu'il existe deux threads indépendants dans une application: un thread normal qui s'exécute de haut en bas et un thread d'exception qui s'exécute à sa guise. Comment lire et comprendre ce code?

Uniquement avec le débogueur activé en mode "intercepter toutes les exceptions".


Des exceptions, comme le goto notoire, déchirent la structure du programme.

Les exceptions ne sont pas exclusives


Regardons un autre exemple: le code d'accès API HTTP distant habituel:

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

Dans cet exemple, tout peut littéralement mal se passer. Voici une liste partielle des erreurs possibles:

  • Le réseau peut ne pas être disponible et la demande ne sera pas exécutée du tout.
  • Le serveur peut ne pas fonctionner.
  • Le serveur est peut-être trop occupé, un délai d'attente se produit.
  • Le serveur peut nécessiter une authentification.
  • Une API peut ne pas avoir une telle URL.
  • Un utilisateur inexistant peut être transféré.
  • Peut-être pas assez de droits.
  • Le serveur peut se bloquer en raison d'une erreur interne lors du traitement de votre demande
  • Le serveur peut renvoyer une réponse invalide ou endommagée.
  • Le serveur peut renvoyer un JSON non valide qui ne peut pas être analysé.

La liste s'allonge encore et encore, tant de problèmes potentiels résident dans le code des trois malheureuses lignes. Nous pouvons dire que cela ne fonctionne généralement que par une chance, et est beaucoup plus susceptible de tomber avec une exception.

Comment se protéger?


Maintenant que nous nous sommes assurés que les exceptions peuvent nuire au code, essayons de les éliminer. Pour écrire du code sans exception, il existe différents modèles.

  • Partout écrire except Exception: pass . Impasse. Ne le faites pas.
  • Retour None . Trop mal. En conséquence, vous devrez soit commencer presque toutes les lignes avec if something is not None: et toute la logique sera perdue derrière les ordures des contrôles de nettoyage, soit souffrir de TypeError tout le temps. Pas un bon choix.
  • Écrivez des classes pour des cas d'utilisation spéciaux. Par exemple, la classe de base User avec des sous-classes pour les erreurs comme UserNotFound et MissingUser . Cette approche peut être utilisée dans certaines situations spécifiques, telles que AnonymousUser dans Django, mais envelopper toutes les erreurs possibles dans les classes est irréaliste. Cela demandera trop de travail et le modèle de domaine deviendra incroyablement complexe.
  • Utilisez des conteneurs pour encapsuler la variable ou la valeur d'erreur résultante dans un wrapper et continuez à travailler avec la valeur du conteneur. C'est pourquoi nous avons créé le projet @dry-python/return . Ainsi, les fonctions renvoient quelque chose de significatif, de typé et de sûr.

Revenons à l'exemple de division, qui renvoie 0 en cas d'erreur. Peut-on indiquer explicitement que la fonction n'a pas réussi sans retourner une valeur numérique spécifique?

 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) 

Nous enfermons les valeurs dans l'un des deux wrappers: Success ou Failure . Ces classes sont héritées de la classe de base Result . Les types de valeurs compressées peuvent être spécifiés dans l'annotation avec la fonction renvoyée, par exemple, Result[float, ZeroDivisionError] renvoie Success[float] ou Failure[ZeroDivisionError] .

Qu'est-ce que cela nous donne? Plus d' exceptions ne sont pas exceptionnelles, mais sont des problèmes attendus . En outre, l'encapsulation d'une exception dans Failure résout un deuxième problème: la complexité de l'identification des exceptions potentielles.

 1 + divide(1, 0) # => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]") 

Maintenant, ils sont faciles à repérer. Si vous voyez Result dans le code, la fonction peut lever une exception. Et vous connaissez même son type à l'avance.

De plus, la bibliothèque est entièrement typée et compatible avec PEP561 . Autrement dit, mypy vous avertira si vous essayez de renvoyer quelque chose qui ne correspond pas au type déclaré.

 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" 

Comment travailler avec des conteneurs?


Il existe deux méthodes :

  • map fonctions qui renvoient des valeurs normales;
  • bind pour les fonctions qui renvoient d'autres conteneurs.

 Success(4).bind(lambda number: Success(number / 2)) # => Success(2) Success(4).map(lambda number: number + 1) # => Success(5) 

La beauté est que ce code vous protégera des scripts infructueux, car .bind et .map ne s'exécuteront pas pour les conteneurs avec Failure :

 Failure(4).bind(lambda number: Success(number / 2)) # => Failure(4) Failure(4).map(lambda number: number / 2) # => Failure(4) 

Maintenant, vous pouvez simplement vous concentrer sur le processus d'exécution correct et être sûr qu'un mauvais état ne cassera pas le programme dans un endroit inattendu. Et il y a toujours la possibilité de déterminer le mauvais état, de le corriger et de revenir sur le chemin conçu du processus.

 Failure(4).rescue(lambda number: Success(number + 1)) # => Success(5) Failure(4).fix(lambda number: number / 2) # => Success(2) 

Dans notre approche, «tous les problèmes sont résolus individuellement» et «le processus d'exécution est désormais transparent». Profitez d'une programmation qui roule sur des rails!

Mais comment augmenter les valeurs des conteneurs?


En effet, si vous travaillez avec des fonctions qui ne connaissent rien aux conteneurs, vous avez besoin des valeurs elles-mêmes. Ensuite, vous pouvez utiliser les .unwrap() ou .value_or() :

 Success(1).unwrap() # => 1 Success(0).value_or(None) # => 0 Failure(0).value_or(None) # => None Failure(1).unwrap() # => Raises UnwrapFailedError() 

Attendez, nous avons dû nous débarrasser des exceptions, et maintenant il s'avère que tous les .unwrap() peuvent conduire à une autre exception?

Comment ne pas penser à UnwrapFailedErrors?


Ok, voyons comment vivre avec les nouvelles exceptions. Considérez cet exemple: vous devez vérifier les entrées utilisateur et créer deux modèles dans la base de données. Chaque étape peut se terminer par une exception, c'est pourquoi toutes les méthodes sont encapsulées dans 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.""" 

Premièrement, vous n'avez pas du tout à étendre les valeurs dans votre propre logique métier:

 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, ) # ... 

Tout fonctionnera sans aucun problème, aucune exception ne sera .unwrap() , car .unwrap() pas utilisé. Mais est-il facile de lire un tel code? Non. Et quelle est l'alternative? @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) # ... 

Maintenant, ce code est bien lu. Voici comment .unwrap() et @pipeline fonctionnent ensemble: chaque fois qu'une méthode .unwrap() échoue et échoue Failure[str] , le décorateur @pipeline attrape et renvoie Failure[str] comme valeur résultante. C'est ainsi que je propose de supprimer toutes les exceptions du code et de le rendre vraiment sûr et typé.

Envelopper le tout ensemble


Eh bien, nous allons maintenant appliquer les nouveaux outils, par exemple, avec une demande à l'API HTTP. N'oubliez pas que chaque ligne peut lever une exception? Et il n'y a aucun moyen de leur faire retourner le conteneur avec Result . Mais vous pouvez utiliser le décorateur @safe pour encapsuler les fonctions dangereuses et les sécuriser. Voici deux options de code qui font la même chose:

 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) 

La première, avec @safe , est plus facile et meilleure à lire.

La dernière chose à faire dans l'exemple de demande d'API est d'ajouter le décorateur @safe . Le résultat est le code suivant:

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

Pour résumer comment supprimer les exceptions et sécuriser le code :

  • Utilisez le wrapper @safe pour toutes les méthodes susceptibles de @safe une exception. Il changera le type de retour de la fonction en Result[OldReturnType, Exception] .
  • Utilisez Result comme conteneur pour transférer les valeurs et les erreurs dans une abstraction simple.
  • Utilisez .unwrap() pour développer la valeur du conteneur.
  • Utilisez @pipeline pour faciliter la .unwrap des .unwrap appels .unwrap .

En observant ces règles, nous pouvons faire exactement la même chose - seulement sûr et bien lisible. Tous les problèmes liés aux exceptions ont été résolus:

  • "Les exceptions sont difficiles à repérer . " Maintenant, ils sont enveloppés dans un conteneur de Result typé, ce qui les rend complètement transparents.
  • "Il est impossible de rétablir un comportement normal en place . " Vous pouvez maintenant déléguer en toute sécurité le processus de récupération à l'appelant. Dans un tel cas, il existe .fix() et .rescue() .
  • "La séquence d'exécution n'est pas claire . " Maintenant, ils ne font qu'un avec le flux commercial habituel. Du début à la fin.
  • "Les exceptions ne sont pas exceptionnelles . " Nous le savons! Et nous nous attendons à ce que quelque chose se passe mal et soit prêt à tout.

Cas d'utilisation et limitations


De toute évidence, vous ne pouvez pas utiliser cette approche dans tout votre code. Il sera trop sûr pour la plupart des situations quotidiennes et est incompatible avec d'autres bibliothèques ou frameworks. Mais vous devez écrire les parties les plus importantes de votre logique métier exactement comme je l'ai montré, afin d'assurer le bon fonctionnement de votre système et de faciliter le support futur.

Le sujet vous fait-il penser ou semble-t-il holivarny? Venez à Moscou Python Conf ++ le 5 avril, nous discuterons! Outre moi, Artyom Malyshev, fondateur du projet dry-python et développeur principal de Django Channels, sera là. Il parlera davantage de dry-python et de logique métier.

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


All Articles