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)
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')
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))
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))
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))
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()
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."""
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
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."""
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.