PROJET AUTOBLOG


Sam & Max: Python, Django, Git et du cul

Site original : Sam & Max: Python, Django, Git et du cul

⇐ retour index

Mise à jour

Mise à jour de la base de données, veuillez patienter...

Pourquoi utiliser un mécanisme d’exceptions ? 18

mercredi 6 janvier 2016 à 01:17

L’article ne vient pas de nulle part. En fait j’ai dû dans la même journée répondre sur le subreddit Python à cette question et à un lecteur par email, alors je me suis dit qu’il serait bon de faire le point ici.

Avant de lire l’article, assurez-vous de bien avoir compris la gestion des erreurs en Python.

La réponse simple, courte, et définitive

“Pourquoi utiliser un mécanisme d’exceptions ?”

Parce que c’est la manière de faire que de la communauté du langage que vous utilisez.

Si vous êtes dans un langage qui utilise des codes de retour comme C, alors gérez les erreurs avec des codes de retour. Si vous avez du pattern matching comme en Rust, utilisez le pattern matching. Si vous avez des exceptions comme en Java, utilisez les exceptions. Si vous avez des guards comme en swift, vous utilisez des guards.

Je crois que vous avez pigé.

Ceci est la règle, qui est complètement indépendante de la qualité du système de gestion des erreurs : codez dans le style approprié pour le langage et n’essayez pas de bricoler votre solution perso dans votre coin. Si ça vous fait chier, changez de langage. Mais ne faites pas du pseudo-Go en Erlang, ça n’a pas de sens, et ça va saoûler tous vos collègues, en plus de diminuer l’intégration avec l’écosystème de la techno.

En Python, les erreurs (et même plus) sont gérées via un mécanisme à base d’exceptions, et c’est donc ce qu’il faut utiliser.

Maintenant, un mécanisme de gestion d’erreurs est vraiment une question de goût. Il n’y en a pas de parfait, mais il peut être intéressant de connaitre les points forts de celui qu’utilise Python.

Quels sont donc les points forts d’un mécanisme à base d’exceptions ?

Cela évite les races conditions

En informatique, dès qu’un système peut faire plusieurs choses à la fois, plusieurs choses peuvent arriver simultanément et créer ce qu’on appelle une race condition.

Par exemple, si je fais ceci:

# on vérifie que le fichier existe avant de l'ouvrir
if os.path.isfile('monfichier'): 
    with open('monfichier') as f:
        print(f.read())
else:
    print('pouet')

Entre la première ligne et la seconde ligne s’écoule un temps très court pendant lequel un autre processus peut supprimer le fichier et faire planter mon programme.

En utilisant la philosophie “il est plus facile de demander pardon que la permission”, on évite ce problème:

try:
    # on s'en bat les couilles, on essaye
    # de l'ouvrir à sec
    with open('monfichier') as f:
        print(f.read())
except OSError:
    print('pouet')

Dans ce cas on tente d’ouvrir le fichier de toute façon, et si l’ouverture déclenche une erreur, alors on la gère. Pas de race condition.

Finally et with pour les opérations de nettoyage

En ouvrant mon fichier, je dois m’assurer de le fermer après. Mais je peux avoir une erreur à l’ouverture du fichier, ou pendant sa lecture, qui fasse planter mon programme et que je n’avais pas prévue.

Comme les exceptions remontent la file d’appel, on peut les attraper à plusieurs niveaux. Grâce à finally (et with qui enrobe finally), on peut donc très élégamment s’assurer que les opérations de nettoyage sont lancées automatiquement, même si tout pête:

# ouvrir un fichier avec with garantit sa fermeture
with open('monfichier') as f:
    print(f.read())

Les exceptions sont très explicites

Des mécanismes comme le pattern matching ou le retour de codes sont génériques, et peuvent être utilisés pour à peu près tout.

Les exceptions, à l’image des guards, ont un champ d’usage plus restreint, et quand on en voit, on sait donc généralement à quoi s’en tenir. Cela facilite la lecture en diagonale du code.

Les exceptions décrivent une hiérarchie d’erreurs

Ceci permet non seulement de choisir de gérer plusieurs erreurs d’un coup, ou alors séparément, mais également de documenter par le type quel est le problème. Une fois qu’on connait les exceptions les plus courantes en Python (ValueError, OSError, KeyError, TypeError, etc.), on identifie vite l’idée générale d’un message d’erreur ou d’un code attrapant une erreur.

Comme on peut créer ses propres types d’exceptions, on peut permettre le ciblage des erreurs d’une lib en particulier, ou d’un sous ensemble d’une lib ou d’une opération. Et c’est aussi une forme de documentation par le code.

Tout cela autorise une fine granularité sur ce qu’on veut gérer ou pas : tout d’un coup, au cas par cas, seulement sur une partie du code, etc.

Les exceptions bubblent

Sous ce terme barbare se cache le fait que les exceptions se déclenchent localement dans un bloc de code, et l’interrompent, mais ne font pas planter le programme tout de suite. À la place, l’exception monte d’un niveau dans la file d’appels, et casse tout, puis remonte d’un cran, et casse tout, et ainsi de suite, jusqu’en haut.

Ce mécanisme permet de choisir exactement où on veut arrêter l’exception, et ce que l’on souhaite qu’elle puisse interrompre. Cela laisse le choix de gérer des erreurs de manière macroscopique ou microscopique.

Par exemple, si j’utilise un try en dehors d’une boucle :

print('start')
try:
    for x in range(0, 10):
        print(1 / (x - 2))
except ZeroDivisionError:
    pass
 
print('fin')
## Affiche :
## start
## -0.5
## -1.0
## fin

Et un dans une boucle :

print('start')
for x in range(0, 10):
    try:
        print(1 / x)
    except ZeroDivisionError:
        print('ERROR !')
    else:
        print("Pas d'erreur :)")
    finally:
        print('TOUJOURS')
print('fin')
## Affiche:
## start
## ERROR !
## TOUJOURS
## 1.0
## Pas d'erreur :)
## TOUJOURS
## 0.5
## Pas d'erreur :)
## TOUJOURS
## 0.3333333333333333
## Pas d'erreur :)
## TOUJOURS
## 0.25
## Pas d'erreur :)
## TOUJOURS
## 0.2
## Pas d'erreur :)
## TOUJOURS
## 0.16666666666666666
## Pas d'erreur :)
## TOUJOURS
## 0.14285714285714285
## Pas d'erreur :)
## TOUJOURS
## 0.125
## Pas d'erreur :)
## TOUJOURS
## 0.1111111111111111
## Pas d'erreur :)
## TOUJOURS
## fin

J’obtiens un résultat radicalement différent. On peut choisir facilement l’étendue de la propagation de l’erreur.

Les exceptions ont des données attachées

Les exceptions sont des objets riches, qui viennent avec un message d’erreur, un contexte qui permet de générer une stack trace, et parfois des attributs en plus comme le code d’erreur fourni par l’OS.

C’est un point d’entrée capable de concentrer en un seul endroit tout ce dont on a besoin pour le debug.

Pas cher mon fils

En Python, les exceptions sont particulièrement peu couteuses à utiliser. En fait, Python les utilise pour le contrôle de flux (StopIteration, GeneratorExit, etc) donc elles sont au coeur du fonctionnement du langage, et pas juste pour pour les erreurs.

Faire un try/except n’a pas le coût qu’on a en Java ni en terme de performance du code, ni en terme de verbosité car il n’y a pas de throw à déclarer.

Le truc le plus ennuyeux, c’est bien entendu de trouver le nom de l’exception qu’on veut gérer et comment l’importer. Afin d’éviter cette chose affreuse:

    try:
        meh()
    except: # YOLO
        print("Il s'est passé un truc, mais je sais pas quoi")

Il y a à peine quelques heures j’étais avec un client qui avait des utilisateurs se plaignant que le système ne marchait pas sans vraiment pouvoir diagnostiquer pourquoi.

Vous voyez, pas besoin d’inventer, j’ai les exemples qui me tombent tout cru dans le bec.

Voici ce qu’il avait, en prod :

# je vous pseudo code, mais c'est l'idée
def get_dbs():
    try:
        # connection, listing, filtrage, casting
        # nahhh, que pourrait-il arriver de grave ?
        con = Connect()
        dbs = con.get_databases()
        dbs = fitler_system_db(dbs)
        return tuple(dbs)
    except: # double combo: catch all ET silence
        return ()

Oui, j’ai bien dit en prod. La base de données gère des listings de médecins. Ouch.

Le code utilisant cette fonction faisait (avant que j’arrive avec mon fouet de zorro et corrige tout ce bordel):

dbs = get_dbs()
if not dbs:
    display_error("Mongo ne tourne pas, ou la connection a échoué, ou aucune table n'est créé")

Vous imaginez comme l’utilisateur final pouvait facilement décrire son problème… Ouai alors soit t’as pas un truc, soit le truc marche pas, soit tu te sers pas du truc. Ouai ça couvre à peut prêt l’ensemble des erreurs possibles sur tous systèmes, comme ça on peut pas avoir fondamentalement tort.

EDIT:

Histoire de mettre fin au débat sur le code final qui va immanquablement envahir les coms:

Bref, faut tout réécrire. Ça tombe bien, il me paie pour ça.

Notez que face à ce genre de code, le comportement à adopter n’est pas de mettre la misère à son client, mais simplement de lister les améliorations possibles, les justifier et estimer le coût et les bénéfices des changements. On est pas la pour fanfaronner, juste pour s’assurer qu’il puisse faire son taff.

Là je me permet de faire le clown parce que ça rend l’article plus léger, et parce qu’il est américain, et ne sera donc pas impacté par l’article. De l’intérêt également d’être un blogger anonyme et francophone…