Exceptions

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. |
#!/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
:
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
...
acquerirDonnees(jeuDonnees)
...
if __name__=="__main__":
for data in [data1, data2, data3, data4, data5]:
traiterDonnees(data)
from moduleYann import lire
def acquerirDonnees(filename):
...
lire(filename)
...
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 :
-
ce sont les couches basses qui détectent les problèmes, mais elles ne comprennent pas le processus qui les provoque,
-
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 :
-
aux couches basses d’envoyer un signal vers les couches hautes pour signaler une anomalie (lancer une exception),
-
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) :
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
...
acquerirDonnees(jeuDonnees)
...
from moduleYann import lire
def acquerirDonnees(filename):
...
try:
lire(filename)
except FileNotFoundError:
...
...
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 :
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
...
try:
acquerirDonnees(jeuDonnees)
except FileNotFoundError:
...
...
from moduleYann import lire
def acquerirDonnees(filename):
...
lire(filename)
...
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 :
from moduleAlice import acquerirDonnees
def traiterDonnees(jeuDonnees):
...
try:
acquerirDonnees(jeuDonnees)
except FileNotFoundError:
...
...
from moduleYann import lire
def acquerirDonnees(filename):
...
try:
lire(filename)
except FileNotFoundError:
... # traitement partiel
raise # relance la même exception vers les couches hautes
...
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.