modifié le

Exceptions

pommes

Une exception est un mécanisme destiné à signaler que quelque chose d’anormal se produit. Le court normal d’exécution du programme est alors chamboulé.

Le concept d’exception n’est pas lié à la programmation orientée objet : les langages oriéntés objet ne sont pas les seuls à pouvoir disposer de mécanisme de gestion d’exception. Mais les exceptions facilitent la conception de briques logicielles réutilisables. C’est pourquoi on les voit apparaître dans ce contexte.

1. Introduction

Les exemples et exercices de ce chapitre sont à exécuter dans un terminal, car les environnement de développement (comme spyder & co) attrapent une partie des exceptions pour leur propre compte.
exemple
#!/bin/env python3
# fichier exceptions1.py

def myfunction():
    x = 1/0             # 4

def mafonction():
    myfunction()        # 7

def main():
    mafonction()        # 10

if __name__ == "__main__":
    main()              # 13
Traceback (most recent call last): (1)
  File "exceptions1.py", line 13, in <module>
    main()
  File "exceptions1.py", line 10, in main
    mafonction()
  File "exceptions1.py", line 7, in mafonction
    myfunction()
  File "exceptions1.py", line 4, in myfunction
    x = 1/0
ZeroDivisionError: division by zero (2)
1 pile des appels de fonction en cours
2 cette ligne indique le type de l’exception et un message explicatif

A chaque type d’exception (dans l’exemple ZeroDivisionError) correspond une classe. Python propose une hiéarchie de classes d' exceptions prédéfinies. Le développeur peut piocher dans ces classes pour identifier et gérer ses propres exceptions. Il peut aussi vouloir les gérer plus finement, et pour cela, il peut créer de nouvelles classes plus adaptées à ses besoins, par héritage de classes prises dans cette hiérarchie.

2. Gestion des exceptions

Pourquoi a-t-on besoin d’un mécanisme spécial (une structure de contrôle particulière, comparable à if…​else ou for…​in…​) pour gérer les exceptions ? Pour le comprendre, il faut voir qu’un logiciel est un empilement de couches, réalisées indépendamment les unes des autres.

En POO, on définit des classes de bas niveau (c’est-a-dire qui collent aux structures de données) : par exemple des listes, des files, des chaines de caractères particulières, etc. Puis, avec ces classes, on construit des classes de plus haut niveau (c’est-à-dire qui collent au métier : math, physique, gestion, biologie etc.). Et, au final, on élabore un programme construit à partir de ces classes de haut niveau.

En programmation classique, c’est la même chose : les fonctions de haut niveau d’abstraction s’appuient sur des fonctions de plus bas niveau, qii elles-mêmes font la même chose.

Lorsqu’une erreur survient au plus profond du code, dans les couches basses, comment la traiter ? L’utilisateur d’un composant logiciel de haut niveau, qui ne peut pas intervenir lors de l’apparition d’une erreur à l’intérieur du composant qu’il utilise, peut vouloir la traiter à sa façon. Et symétriquement, le concepteur du composant de bas niveau, qui lui peut détecter l’erreur dès qu’elle apparait, n’a probablement pas les éléments pour la gérer judicieusement.

Exemple Bob-Alice-Yann

Par exemple, soit une application appli.py utilisant un module moduleAlice, utilisant à son tour un module moduleYann :

fichier appli.py écrit en 2024 par Bob
from moduleAlice import acquerirDonnees

def traiterDonnees(jeuDonnees):
  ...
  acquerirDonnees(jeuDonnees)
  ...

if __name__=="__main__":
  for data in [data1, data2, data3, data4, data5]:
    traiterDonnees(data)
fichier moduleAlice.py écrit en 2022 par Alice
from moduleYann import lire

def acquerirDonnees(filename):
  ...
  lire(filename)
  ...
fichier moduleYann.py écrit en 2021 par Yann
def lire(file):
  ...
  f = open(file, "r")
  ...

Si Bob, en 2024, fournit un jeu de données inexistant à la fonction acquerirDonnées, c’est la fonction lire de Yann qui va s’en rendre compte. Bob aimerait pourtant bien pouvoir intervenir quand le jeu de données qu’il fournit pose problème, mais comment faire sans modifier le module de Yann ?

La solution consistant à stopper net l’exécution du programme est trop radicale, car elle ne permet pas de remédier au problème, ni même d’en comprendre la cause (à ce niveau de profondeur, on n’a pas le recul suffisant pour comprendre comment et pourquoi on en est arrivé là). En caricaturant, on peut dire que :

  1. ce sont les couches basses qui détectent les problèmes, mais elles ne comprennent pas le processus qui les provoque,

  2. les couches hautes possèdent la vue d’ensemble permettant de comprendre comment et pourquoi les problèmes apparaissent, mais ne les détectent pas.

Le mécanisme d’exceptions est fait pour gérer cela. Il permet :

  1. aux couches basses d’envoyer un signal vers les couches hautes pour signaler une anomalie (lancer une exception),

  2. quelque part dans les couches hautes, à l’endroit où l’anomalie peut être traitée judicieusement, de prendre la main pour y remédier (attraper l’exception et la traiter).

Il offre un moyen de dérouter le flux d’exécution du point de détection de l’erreur au point adéquat pour la traiter (saut dans le déroulement du programme accompagné d’un transfert d’information).

Le champ d’action du mécanisme d’exceptions est plus large que la gestion des erreurs, et s’applique à toute situation exceptionnelle (comme la fin d’une itération, la fin de lecture d’un fichier, etc.).

Comportement par défaut de Python

Par défaut, lorsqu’une erreur (arithmétique, périphérique, mathématique, etc.) est détectée, une exception est automatiquement lancée. Rien n’attrape cette exception, et le programme s’arrête brutalement, en sortant un message (indiquant le type de l’exception et le contenu de la pile des appels de fonctions). C’est le comportement standard auquel conduit toute exception non attrapée.

3. Lancer une exception

On lance une exception avec l’instruction raise.

On peut lancer une exception pour signaler autre chose qu’une erreur. Par exemple, Python lance des StopIteration lorsqu’une itération arrive normalement à son terme.

Exemple, soit la classe Fraction :

class Fraction:
  def __init__(self,n,d):
    self.num = n
    self.den = d

  def reduire(self):
    pgcd = gcd(self.num,self.den)
    self.num //= pgcd
    self.den //= pgcd
    return self

  def __float__(self,r):
    return self.num / self.den
  ...

Bien que créer une fraction avec un dénominateur nul ne pose pas de problème immédiat, tôt ou tard une erreur arithmétique fatale va apparaître (lors du calcul du PGCD, du quotient, etc.). On peut donc anticiper en lançant une exception dès la création d’une fraction avec un dénominateur nul :

class Fraction:
  def __init__(self,n,d):
    if d==0:
        raise ValueError(f"problème pour créer Fraction({n},{d}) !") (1)
    self.num = n
    self.den = d
  ...
1 ici, un objet de type ValueError est créé, puis il est lancé vers les couches hautes

Autre stratégie possible : autoriser la création d’une fraction à dénominateur nul, et lancer une exception au dernier moment, juste avant une division par 0 :

class Fraction:
  def __init__(self,n,d):
    self.num = n
    self.den = d

  def reduire(self):
    if self.den==0:
      raise ArithmeticError(f"problème pour réduire la Fraction({self.num},{self.den}) !")
    pgcd = gcd(self.num,self.den)
    self.num //= pgcd
    self.den //= pgcd
    return self

  def __float__(self,r):
    if self.den==0:
      raise ZeroDivisionError(f"problème pour obtenir le quotien de la Fraction({self.num},{self.den}) !")
    return self.num / self.den
  ...

Les instances de type ArithmeticError et ZeroDivisionError vont remonter la pile des appels de fonctions, et pourront être attrapées par n’importe quelle fonction en cours d’exécution. Toute instance non attrapée donnera lieu au comportement par défaut, à savoir interruption brutale du programme et écriture du message d’erreur standard.

Exception personnalisée

Si aucune exception prédéfinie ne correspond à ce que l’on veut signaler, ou bien si l’on veut remonter de l’information supplémentaire, alors il est possible de se créer un type particulier d’exception. Pour cela, il suffit de dériver une nouvelle classe de l’une des classes exceptions prédéfinies. Par exemple :

class MonErreur(RuntimeError):
  def __init__(self,msg,q):
    super().__init__(msg)
    self.num,self.den = q.num,q.den
    self.strict_mode = q.strict_mode
    self.auto_reduire = q.auto_reduire

que l’on peut utiliser ensuite comme :

class Fraction:
  strict_mode = True
  auto_reduire = True

  @classmethod
  def strict_disable(cls):
    cls.strict_mode = False

  def __init__(self,n,d):
    self.num = n
    self.den = d
    if Fraction.strict_mode:
      if d==0:
        raise MonErreur("problème à la création",self)
    if Fraction.auto_reduire:
        self.reduire()

  def reduire(self):
    if self.den==0:
      raise MonErreur("problème à la simplification",self)

    pgcd = gcd(self.num,self.den)
    self.num //= pgcd
    self.den //= pgcd
    return self

Avec cela, l’instruction :

q = Fraction(1,0)

donne

raise MonErreur("problème à la création",self)
__main__.MonErreur: problème à la création

alors que :

Fraction.strict_disable()
q = Fraction(1,0)

donne :

raise MonErreur("problème à la simplification",self)
__main__.MonErreur: problème à la simplification

4. Attraper une exception

Attraper une exception va permettre, avant que le programme ne s’arrête brutalement, de prendre la main pour expliquer clairement à l’utilisateur ce qui s’est passé, ou mieux, pour tenter de corriger le problème (ou moins bien, le mettre sous le tapis 😉).

Par exemple, le code ci-dessous attrape les exceptions de type ArithmeticError et ValueError lancées soit par le constructeur de Fraction (instruction f=Fraction(a,b)), soit par la méthode reduire (instruction f.reduire()) :

a = int(input("entrer un premier entier :"))
b = int(input("entrer un second entier :"))
try:                              (1)
  f = Fraction(a,b)
  f.reduire()
except ArithmeticError:    (2)
  print("problème pour calculer le PGCD")
  exit(1)
except ValueError:    (3)
  print("une fraction à dénominateur nul a été détectée")
  exit(1)
1 try définit un bloc réceptif aux exceptions
2 si une exception de type ArithmeticError est lancée dans le bloc try précédent, alors elle sera attrapée, et l’exécution sera déroutée directement ici ; s’il s’agit d’un autre type d’exception, ou si aucune exception n’est lancée, alors ce bloc except n’est pas exécuté
3 même chose pour une ValueError

On peut récupérer l’objet lancé par raise. C’est intéressant, car cet objet peut fournir des infos utiles sur ce qu’il s’est passé :

class Fraction:
  strict_mode = True
  auto_reduire = True

  @classmethod
  def strict_enable(cls):
    cls.strict_mode = True

  @classmethod
  def strict_disable(cls):
    cls.strict_mode = False

  @classmethod
  def reduire_enable(cls):
    cls.auto_reduire = True

  @classmethod
  def reduire_disable(cls):
    cls.auto_reduire = False

  def __init__(self,n,d):
    self.num = n
    self.den = d
    if Fraction.strict_mode:
      if d==0:
        raise ValueError(f"problème pour créer Fraction({n},{d}) !")
    if Fraction.auto_reduire:
        self.reduire()

  def reduire(self):
    if self.den==0:
      raise ArithmeticError(f"problème pour réduire la Fraction({self.num},{self.den}) !")

    pgcd = gcd(self.num,self.den)
    self.num //= pgcd
    self.den //= pgcd
    return self

ce qui permet d’écrire :

try:
  f = Fraction(1,0)
  f.reduire()
except ArithmeticError as e :
  print(e)                     (1)
  exit(1)
except ValueError as e:    (2)
  print(e)
  exit(1)
1 sort "problème pour réduire la Fraction(1,0) !"
2 sort "problème pour créer Fraction(1,0)"

Exemple Bob-Alice-Yann

Dans exemple Bob-Alice-Yann, soit Alice possède tous les éléments pour tenter de corriger le problème, et, dans ce cas, elle le gère (et Bob n’a rien à faire) :

fichier appli.py - Bob
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
  ...
  acquerirDonnees(jeuDonnees)
  ...
fichier moduleAlice.py - Alice
from moduleYann import lire
def acquerirDonnees(filename):
  ...
  try:
    lire(filename)
  except FileNotFoundError:
    ...
  ...
fichier moduleYann.py - Yann
def lire(file):
  ...
  f = open(file, "r")
  ...

ou sinon, Bob détient peut-être la clé du problème, et dans ce cas, c’est lui qui gère :

fichier appli.py - Bob
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
  ...
  try:
    acquerirDonnees(jeuDonnees)
  except FileNotFoundError:
    ...
  ...
fichier moduleAlice.py - Alice
from moduleYann import lire
def acquerirDonnees(filename):
  ...
  lire(filename)
  ...
fichier moduleYann.py - Yann
def lire(file):
  ...
  f = open(file, "r")
  ...

Le traitement de l’exception peut aussi être incrémental, c’est-à-dire géré par Alice et Bob, chacun à leur niveau :

fichier appli.py - Bob
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
  ...
  try:
    acquerirDonnees(jeuDonnees)
  except FileNotFoundError:
    ...
  ...
fichier moduleAlice.py - Alice
from moduleYann import lire
def acquerirDonnees(filename):
  ...
  try:
    lire(filename)
  except FileNotFoundError:
    ... # traitement partiel
    raise # relance la même exception vers les couches hautes
  ...
fichier moduleYann.py - Yann
def lire(file):
  ...
  f = open(file, "r")
  ...

Ici, Alice corrige partiellement le problème, puis relance la même exception, qui va donc continuer sa remontée vers Bob, qui l’attrapera à son tour pour terminer son traitement.

5. Pile des appels de fonctions

Le module traceback permet de récupérer des informations relatives à l’état de la pile des appels de fonctions en cours, via notamment sa fonction print_exc() :

  • traceback.print_exc() affiche l’état de la pile comme le ferait le comportement standard d’une exception non attrapée,

  • traceback.print_exc(limit=n), n>0, affiche les n premier éléments de la pile des appels,

  • traceback.print_exc(limit=-n), n>0, affiche les n derniers éléments de la pile des appels.

6. Changer d’exception en cours de route

Les données et même le type d’une exception peuvent être modifiés au cours de la remontée :

def f():
  raise ConnectionError

def g():
  try:
    f()
  except ConnectionError as e:
    raise RuntimeError('Failed to open database') from e

Ici, l’exception ConnectionError est attrapée, puis transformée en RuntimeError, et relancée.