Quand on code un programme il peut arriver que tout ne se passe pas comme prévu, que des exceptions surviennent qui interrompent le déroulé normal du programme.
Ce chapitre a pour but de vous présenter le fonctionnement des exceptions et la manière de les gérer.
Tout ne se passe pas comme prévu
On a déjà rencontré des exceptions, ce sont les erreurs qui se produisent quand une opération échoue (conversion impossible, élément inexistant dans un dictionnaire, ouverture d’un fichier introuvable, etc.).
L’erreur survient alors sous la forme d’une exception avec un type particulier (ValueError
, TypeError
, KeyError
, etc.).
Le souci est que cela coupe l’exécution de la fonction et du programme (hors interpréteur interactif).
Imaginons que nous souhaitions au chargement de notre jeu regarder si une sauvegarde existe. On essaierait alors d’ouvrir le fichier de sauvegarde, et s’il n’existe pas on obtiendrait une exception.
À l’exécution :
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'game.sav'
Ainsi, le programme s’arrête à l’exception, ce qui est plutôt embêtant. Notre jeu devrait être en mesure de démarrer sans sauvegarde existante, de traiter l’erreur et de continuer.
Pour autant une exception peut être un comportement attendu, d’autant plus si elle provient d’une valeur entrée par l’utilisateur. Dans une calculatrice, on ne veut pas que le programme plante si l’utilisateur demande une division par zéro. De même dans un annuaire si un nom n’est pas trouvé.
def calculatrice(a, op, b):
if op == '+':
return a + b
if op == '-':
return a - b
if op == '*':
return a * b
if op == '/':
return a / b
print('Calcul impossible')
>>> calculatrice(3, '+', 0)
3
>>> calculatrice(3, '/', 0)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 9, in calculatrice
ZeroDivisionError: division by zero
Comment alors peut-on gérer ces erreurs pour éviter cela ?
Éviter l'exception
Une solution pour éviter les erreurs est d’empêcher qu’elles se produisent. Ainsi, avant d’exécuter une action, on va tester différents cas d’erreurs pour les écarter. C’est la stratégie dite LBYL (Look before you leap, soit réfléchis avant d’agir).
Par exemple pour une calculatrice dans le cadre d’une division, on testerait si le quotient n’est pas nul avant de réaliser l’opération.
def calculatrice(a, op, b):
if op == '+':
return a + b
if op == '-':
return a - b
if op == '*':
return a * b
if op == '/' and b != 0:
return a / b
print('Calcul impossible')
>>> calculatrice(3, '/', 2)
1.5
>>> calculatrice(3, '/', 0)
Calcul impossible
Pour notre problématique de sauvegarde, il faudrait donc être en mesure de tester si un fichier existe.
Une telle fonctionnalité est disponible dans le module pathlib
.
Ce module propose un type Path
représentant un chemin sur le système de fichiers, et bénéficiant naturellement d’une méthode exists
renvoyant un booléen pour tester si le chemin existe ou non.
>>> from pathlib import Path
>>> p = Path('hello.txt')
>>> p.exists()
True
>>> p = Path('game.sav')
>>> p.exists()
False
Ainsi, on peut remplacer notre code de chargement par :
from pathlib import Path
if Path('game.sav').exists():
with open('game.sav') as save:
state = load_game(save.read())
else:
state = None
On notera que les objets Path
possèdent aussi une méthode open
équivalente à la fonction du même nom : Path(foo).open()
revient à écrire open(foo)
.
On peut alors améliorer notre code précédent pour éviter les répétitions.
save_path = Path('game.sav')
if save_path.exists():
with save_path.open() as save:
state = load_game(save.read())
else:
state = None
Limites
La stratégie LBYL est cependant limitée. Déjà, il est difficile d’envisager tous les cas d’erreurs : on pourrait obtenir une exception parce que le fichier est un répertoire, parce que les permissions ne sont pas suffisantes pour le lire, etc.
Mais considérons que l’on arrive à anticiper toutes les erreurs possibles, il resterait un problème.
Quand on demande au système si un fichier existe, il le vérifie à l’instant t ; mais quand on l’ouvre nous sommes à l’instant t+1.
Pendant ce très court laps de temps le fichier a pu être supprimé, déplacé, ses permissions modifiées, et donc on n’échapperait pas à l’exception.
Il va alors nous falloir adopter une autre stratégie, dite EAFP (Easier to ask for forgiveness than permission, il est plus simple de demander pardon que demander la permission). C’est-à-dire laisser l’exception se produire et la traiter ensuite, comme nous allons le voir tout de suite.
Pour autant, la stratégie LBYL n’est pas à jeter, il reste des cas où elle est parfaitement adaptée, quand les conditions ne sont pas amenées à changer entre les pré-conditions et l’opération. C’est le cas par exemple du test pour le quotient nul dans la division, s’il est non-nul à l’instant t, il sera toujours à t+1.
Traiter l'exception
Pour gérer les exceptions on va utiliser un nouveau type de bloc, ou plutôt un couple de blocs, introduits par les mots-clés try
et except
(littéralement « essaie » et « à l’exception de »).
Ces deux mots-clés vont de pair pour intercepter les erreurs.
Dans le bloc try
on place le code qui peut échouer, et le bloc except
sera exécuté si et seulement si une exception survient.
Il aura pour effet d’attraper cette exception et donc éviter que le programme ne plante, en proposant un traitement adapté.
>>> try:
... result = 1 / 0
... except:
... print('Division par zéro')
...
Division par zéro
Ici notre traitement est simplement d’afficher un message, mais il est possible de faire ce que l’on veut dans le bloc except
, comme renvoyer une valeur particulière.
def division(a, b):
try:
return a / b
except:
return float('nan')
Quel est ce float('nan')
?
NaN, pour Not a Number (Pas un Nombre), est une valeur particulière de la norme des nombres flottants évoquant un résultat qui ne serait pas un nombre.
On y accède en Python via la variable nan
du module math
, ou avec un simple float('nan')
.
>>> division(3, 5)
0.6
>>> division(4, 2)
2.0
>>> division(10, 0)
nan
L’exécution du programme reprend normalement à l’issue du except
.
On ne le voit pas dans l’exemple car on y utilise un return
, mais la suite de la fonction est bien exécutée.
def division(a, b):
try:
result = a / b
except:
result = float('nan')
print('Résultat :', result)
return result
Si l’exécution s’arrêtait juste après le except
, nous ne passerions pas dans le print
et le return
.
>>> division(1, 2)
Résultat : 0.5
0.5
>>> division(1, 0)
Résultat : nan
nan
Aussi, nous utilisons except
sans lui préciser aucun argument, il attrapera donc toute exception qui surviendrait, quel qu’en soit son type.
>>> division('x', 'y')
Résultat : nan
nan
Pourtant ce n’est pas toujours souhaitable.
Par exemple dans le cas présent il s’agit d’une erreur de type et donc d’un mauvais usage de la fonction, on pourrait préférer ne pas traiter cette exception et la laisser survenir.
Ainsi, on pourra préciser derrière except
le type de l’exception que l’on veut attraper, dans notre cas ZeroDivisionError
.
def division(a, b):
try:
return a / b
except ZeroDivisionError:
return float('nan')
Notre fonction interceptera maintenant les erreurs de division par zéro, et uniquement celles-ci.
>>> division(1, 2)
0.5
>>> division(1, 0)
nan
>>> division(1, 'x')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in division
TypeError: unsupported operand type(s) for /: 'int' and 'str'
Attraper plusieurs exceptions
On peut placer plusieurs blocs except
à la suite d’un try
pour traiter des exceptions différentes.
Sur le même principe que les if
/ elif
/ else
, un seul de ces blocs sera exécuté, le premier qui correspond à l’exception survenue.
Changeons d’exemple et passons à un cas plus réel de lecture de fichier. Imaginons que l’on souhaite simplement lire un score dans un fichier. Il nous faut alors une fonction prenant un chemin de fichier en paramètre et renvoyant son contenu sous forme de nombre.
Plusieurs exceptions peuvent survenir comme on l’a vu : le fichier peut ne pas exister ou ne pas avoir les bonnes permissions (erreurs OSError
), peut contenir une valeur invalide (ValueError
) et d’autres encore.
def get_score(path):
try:
with open(path) as f:
return int(f.read())
except OSError:
print("Impossible d'ouvrir le fichier")
except ValueError:
print('Score invalide')
Maintenant voilà ce que l’on obtient avec un fichier score.txt
contenant 42
et un fichier hello.txt
quelconque.
>>> get_score('score.txt')
42
>>> get_score('hello.txt')
Score invalide
>>> get_score('not_found.txt')
Impossible d'ouvrir le fichier
Bien sûr, les blocs except
ne peuvent attraper que les exceptions qui surviendraient pendant l’exécution du try
.
Toute exception survenue avant leur échapperait.
def get_score(path):
with open(path) as f:
try:
return int(f.read())
except OSError:
print("Impossible d'ouvrir le fichier")
except ValueError:
print('Score invalide')
Dans l’exemple précédent, la conversion du contenu du fichier en nombre a toujours lieu dans le try
donc l’erreur sur hello.txt
sera bien traitée.
Mais l’ouverture du fichier se situe en dehors, nous ne gérons donc pas l’erreur OSError
sur not_found.txt
.
>>> get_score('score.txt')
42
>>> get_score('hello.txt')
Score invalide
>>> get_score('not_found.txt')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in get_score
FileNotFoundError: [Errno 2] No such file or directory: 'not_found.txt'
On voit que l’erreur qui survient est une FileNotFoundError
et non une OSError
.
Il faut savoir qu’il existe une hiérarchie des exceptions que nous étudierons plus tard, et que FileNotFoundError
est une erreur qui descend de OSError
.
Aussi, l’exécution d’un bloc try
s’arrête à la première erreur rencontrée.
Cela signifie que tout son contenu n’est pas nécessairement exécuté, donc certaines variables définies dans le try
n’existent peut-être pas.
Essayez la fonction suivante pour vous rendre compte des problèmes que cela peut poser.
def get_score(path):
try:
with open(path) as f:
content = f.read()
score = int(content)
except OSError:
print("Impossible d'ouvrir le fichier")
except ValueError:
print('Score invalide')
return score
La fonction de cet exemple gère mal les exceptions : score
ne sera jamais définie si une erreur est survenue, et donc le return
échouera car accèdera à une variable inexistante.
Quant aux variables f
et content
on ne sait pas si elles existent car cela dépend de l’endroit précis où est survenue l’erreur.
Pour une erreur à l’ouverture du fichier content
ne sera pas définie, mais s’il s’agit d’une erreur lors de la conversion alors content
contiendra sa bonne valeur.
Pour s’assurer que ces variables existent, il nous faut alors les définir dans tous les cas.
Soit en le faisant avant le try
(puisque c’est du code qui sera toujours exécuté), soit en répétant la définition dans chaque clause except
.
def get_score(path):
score = None
try:
with open(path) as f:
content = f.read()
score = int(content)
except OSError:
print("Impossible d'ouvrir le fichier")
except ValueError:
print('Score invalide')
return score
Remontée d’erreurs
On peut voir l’exécution d’un programme informatique comme le parcours d’un arbre, de branche en branche, de façon à passer par toutes les feuilles. Les embranchements étant faits de conditions, de boucles et d’appels de fonctions. Notamment d’appels de fonctions.
À chaque instant du programme, l’instruction en cours d’exécution représente un curseur le long d’une branche : l’appel d’une fonction fait aller ce curseur plus loin dans l’arbre tandis qu’un retour le fait revenir sur ses pas.
Ainsi, il existe toujours un chemin depuis la racine du programme (le tronc) jusque la position actuelle du curseur.
Ce chemin représente la pile d’appels courante (stacktrace), les fonctions qu’il a fallu parcourir pour arriver jusqu’à ce point du programme. Toute exception est liée à la position courante dans le programme, au contexte qui l’a fait surgir, et donc à un certain état de la pile d’appels.
Cette pile liée à l’exception, on la voit d’ailleurs apparaître dans le terminal quand on n’attrape pas l’exception.
% python error.py
Traceback (most recent call last):
File "error.py", line 11, in <module>
main()
File "error.py", line 9, in main
print(i, inverse(i))
File "error.py", line 5, in inverse
return division(1, x)
File "error.py", line 2, in division
return a / b
ZeroDivisionError: division by zero
De haut en bas, on voit que l’appel à main
ligne 11 a provoqué un appel à inverse
ligne 9, qui induit lui-même un appel à division
ligne 5, à l’intérieur de laquelle se produit l’erreur (ligne 2).
Quand une exception n’est pas attrapée, elle remonte pas à pas la pile d’appels, et continue sa route jusqu’à couper le programme lui-même.
Car oui, il n’existe pas un seul endroit où l’exception peut être attrapée, elle peut l’être tout le long du programme.
On pourrait choisir de placer un try
/ except
dans la fonction division
, mais aussi dans inverse
ou dans main
.
Choisir de le mettre dans la boucle ou à l’extérieur, chaque solution ayant un comportement différent.
Par exemple, attraper l’exception à l’extérieur de la boucle aura pour effet de s’arrêter à la première erreur, puisque la boucle sera coupée à la première itération (i = 0
).
% python error.py
Alors qu’attraper l’exception à l’intérieur de la boucle permettra de ne couper que l’itération courante puis de passer à la suivante.
% python error.py
1 1.0
2 0.5
3 0.3333333333333333
4 0.25
5 0.2
6 0.16666666666666666
7 0.14285714285714285
8 0.125
9 0.1111111111111111
Mais dans cet exemple, les appels à inverse(0)
ou division(1, 0)
continuent d’échouer : on pourrait choisir de traiter l’erreur dans ces fonctions pour renvoyer NaN.
% python error.py
0 nan
1 1.0
2 0.5
3 0.3333333333333333
4 0.25
5 0.2
6 0.16666666666666666
7 0.14285714285714285
8 0.125
9 0.1111111111111111
Il convient alors chaque fois de réfléchir au comportement que l’on veut adopter et de placer judicieusement les blocs try
/ except
en fonction de cela, pour n’être ni trop large, ni trop fin.