Pourquoi utiliser un mécanisme d’exceptions ?


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 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 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:

  • La connexion n’a juste rien à foutre dans la fonction qui liste les dbs. Cela doit être deux fonctions séparées.
  • On attrape pas d’exception au niveau de la connexion. On attrape une exception au niveau du code qui déclenche la connexion afin de pouvoir donner un rapport d’erreur à l’utilisateur depuis le code qui gère l’UI.
  • On attrape pas une exception, mais 3-4, avec des messages d’erreur distincts pour chaque cas donnant une action claire à l’utilisateur qu’il puisse mettre en œuvre pour résoudre ce problème en particulier.
  • Oui, on peut retourner un tuple vide pour indiquer qu’on a rien trouvé (comme ça on retourne toujours un itérable) mais certainement pas pour signaler une erreur.
  • La fonction ne permet pas d’être unit testée facilement car non seulement elle fait trop de choses, mais en plus elle a plein d’entrées implicites (settings de connexion, liste des choses à fitrer, etc.).

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…

20 thoughts on “Pourquoi utiliser un mécanisme d’exceptions ?

  • Fred

    Mouais. Le mec aurait pu au-moins faire un return None dans son except. Le test sur l’appel aurait été le même mais ça évitait ce tuple vide, inutile consommateur de ressources et peu représentatif.

    Même si je connais bien (et que j’utilise) les exceptions, j’ai beaucoup aimé cet article (comme tous les autres d’ailleurs). Petite coquille: Grave à finallyGrâce à finally

  • mentat

    Typo:

    la progragation de l’erreur => propagation

    que je n’avais pas prévu => prévue (une erreur)

    soit tu te sers par du truc => pas

    sur tout système = > sur tous systèmes

    Sinon, très bon article, comme d’habitude.

  • sayoun

    Je connaissais pas le terme de exception bubble, intéressant.

    @Fred: un return tout court fait la même chose qu’un return None, de même qu’une méthode de classe qui ne fait pas de return explicite retournera un None aussi par défaut.

  • toub

    Par rapport à la réponse simple, courte et définitive :

    je ne comprends pas bien la nuance qui est faite avec les langages utilisant les codes de retour comme C : Python dispose aussi du return, non ?

    Par exemple, dans une fonction qui recherche un élément dans un ensemble, est-ce qu’un return None n’est pas suffisant pour signaler l’erreur ?

    Autre exemple, lors de la connexion à un serveur, un return False peut-il convenir pour signaler l’échec de l’ouverture de connexion ?

  • Sam Post author

    @toub:

    je ne comprends pas bien la nuance qui est faite avec les langages utilisant les codes de retour comme C : Python dispose aussi du return, non ?

    Ce n’est pas une question de capacité, c’est une question de style en vigueur. Tu peux recoder un système d’exceptions en C et l’utiliser. Tu peux retourner des codes d’erreur en Python. Néanmoins, en C, le style que la communauté utilise est majoritairement l’utilisation du code d’erreur. Et en Python, le style utilisé majoritaire par la communauté est l’usage d’exceptions. De fait, les collègues s’attendent à cela. Les outils s’attendent à cela. Les docs s’attendent à cela.

    Par exemple, dans une fonction qui recherche un élément dans un ensemble, est-ce qu’un return None n’est pas suffisant pour signaler l’erreur ?

    Ne pas retrouver un élément n’est pas une erreur. On retourne None, non pas pour signaler une erreur, mais pour retourner la valeur qui a le plus de sens dans ce contexte. Ici, on ne trouve rien. Comme None représente la notin de “rien”, c’est ce qu’on utilise.

    Autre exemple, lors de la connexion à un serveur, un return False peut-il convenir pour signaler l’échec de l’ouverture de connexion ?

    Oui, c’est possible. Mais encore une fois ce n’est pas une question de capacité, c’est une question de style. Ce n’est pas ce qu’on fera en Python. Stylistiquement, un échec de connexion se fera avec une exception.

    En l’occurence, ont en obtient les bénéfices suivant:

    • S’il existe différentes erreurs possibles qui font foirer la connexion, on peut facilement les différencier avec plusieurs exceptions plutôt que False. Pratique pour le debug, mais aussi pour le choix des actions pour chaque type d’erreur.
    • On a un message d’erreur clair pour chaque type d’erreur.
    • On peut réutiliser le code, hériter de l’erreur, et créer son propre sous type d’erreur.
    • en cas de plantage, on peut remonter la stack trace jusqu’à l’origine de l’exception, et voir non seulement où dans son code ça a foiré, mais aussi où dans le code de la connexion ça a foiré.
    • On peut gérer l’erreur au niveau de la connexion (retenter une connexion, faire un prompt à l’utilisateur, etc) ou au niveau du programme (log généralisé des erreurs).
    • On peut choisir d’attraper telle ou telle erreur, car on sait la gérer, mais laisser planter le programme pour d’autres.
    • Le code de gestion de l’erreur est très facile à distinguer du reste du code.
  • cym13

    @toub @Fred

    Le fait d’utiliser un tuple vide ici est beaucoup plus propre à l’utilisation car il évite d’avoir à tester à chaque site d’appel pour vérifier que le résultat n’est pas None (ce qui cause tant d’erreurs en C) avant de le passer à une boucle for par exemple: un tuple même vide est un itérable. De plus c’est conceptuellement plus clair: la fonction retourne l’ensemble des valeurs correspondant à la recherche, pas une fois un ensemble et une fois un autre truc.

  • toub

    @sam @cym13

    OK ça y est je vois la nuance, merci ! (je comprends vite mais faut m’expliquer longtemps)

    Bien vu cym13 le coup du tuple pour un retour de résultat vide, qui évite à gérer le cas du None, comme tu dis grosse source d’erreur en C.

    Question subsidiaire : y a-t-il une siouxerie python pour gérer le nettoyage dans la fonction qui lève l’exception ? ou bien faut-il gérer à la mimine le nettoyage avant d’invoquer le raise (attention, je ne parle pas du nettoyage dans le finally qui nettoie au moment du catch de l’exception)

  • Sam Post author

    Il faut que tu me montres du code car là réponse va vraiment dépendre ce celui-ci.

  • toub

    Comme pour le moment mon code n’a pas le moindre début d’usage d’exception (ce que je souhaite corriger) je n’ai pas de vrai code à te donner.

    Le cas d’usage c’est un truc de ce gout la:

    # On lance un thread qui bloque sur le fd d'une socket, et remplit ReadThread.data_received avec les donnees lues.
     
    read_thread = ReadThread(socket_fd)
     
    read_thread.start()
     
    # Si on n'a pas reçu les trames de demarrage au bout de 5 secondes
     
    sleep(5)
     
    if read_thread.data_received != "StartMessage":
     
        raise StartDatasException
     
    while True:
     
        read_thread.data_received = None
     
        sleep(5)
     
        # Si on n'a pas reçu le heartbeat au bout de 5 secondes
     
        if read_thread.data_received != "Heartbeat":
     
            raise HeartBeatException

    Comment gérer l’arrêt du thread, la fermeture de la socket… de maniere unifiee pour les 2 raise d’exception ?

    Bon l’exemple est rapide et pourri de bugs potentiels, mais ce qui m’intéressesse en l’occurence c’est surtout le nettoyage des données suite aux raise

  • boblinux

    @toub

    Au pire amène toi sur indexerror pour cette question, le format s’y prête beaucoup plus, de même que la qualité des réponses.

    On pourra en discuter tranquillement histoire de laisser la place ici pour les com’s un peu moins précis/techniques :)

  • inso

    De manière générale, une bonne manière de gérer les erreurs est de le faire en mode “Fail Fast”.

    Le Fail Fast consiste à ne pas traiter des situations qui ne devraient pas arriver. Par exemple, il ne faut pas tester à null à chaque fois qu’un objet est passé en paramètre. Ou il ne faut pas vérifier si un entier est toujours positif lorsqu’on passe un index de tableau.

    Il faut au contraire ne rien faire. Ca plantera si un null ou un entier négatif arrive. Mais si ça plante, ce n’est pas le code de traitement du null ou de l’entier qui bug, c’est avant. Un mauvais paramètre aura été passé et il faut en trouver la raison.

    Le fail fast permet de ne pas surcharger le code avec de la verbosité qui ne fera que rendre la correction de bug plus complexe encore.

  • Xavier Combelle

    Si je me trompe pas, si les exceptions sont “rapides” en python, c’est que les autres opérations sont lentes donc relativement, les exceptions sont plutôt rapides.

    Ce que je veux dire, c’est que python n’a pas trouvé de recette magique pour que les exceptions deviennent miraculeusement rapide dans l’absolu.

  • Brice

    @toub :

    Par rapport au premier exemple, j’aurais tendance à le faire de 2 manières différentes en fonction du contexte :

    Si j’ai absolument besoin d’un (et un seul) retour, ma fonction sera get_element() et lèvera une exception si l’element n’existe pas. Sinon, c’est seulement une recherche qui peut ne rien trouver, et la fonction sera find_element() ou search_element(), et ça retournera None si je cherche un seul element, ou un iterable vide si je peux avoir plusieurs elements (find_elements()). Dans ce cas, c’est la fonction appelante qui se chargera de lever une exception si l’absence de retour est finalement problématique.

  • ultra

    @Sam

    Ce que t’expliques pour les exceptions qui sont au coeur de python, marche aussi pour le logging qui y est intégré.

    Écrire print() pour loguer en python c’est contre l’esprit du langage.

    En fait, quand tu débarques dans un langage, il y a tous les non dits et les usages de la “communauté” qui ne sont pas explicites.

    Donc non seulement faut apprendre le langage mais aussi les bonnes pratiques de la communauté.

    C’est tout l’intérêt de ce blog qui explique bien l’esprit du langage.

    J’ai réécrit une bonne partie de mon code python après la lecture de ce blog.

  • Gerardo

    Merci pour l’article, interessant comme d’habitude !

    Et deux petites coquilles :

    Parce que c’est la manière de faire que de la communauté

    elles sont au coeur du fonctionnement du langage, et pas juste pour pour les erreurs.

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.