Aller au contenu principal

Programmation structurée (Python) · L1 · Section 6/11

Exceptions

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Exceptions

Une exception signale une situation anormale sans interrompre brutalement le programme: la fonction courante s'arrête, et l'erreur remonte à l'appelant jusqu'à trouver un except adapté. C'est le mécanisme qui sépare le cas normal du traitement des erreurs.

Prérequis: fonctions, fichiers.

Objectifs d'apprentissage:

  • Écrire des blocs try/except/else/finally selon l'intention: gérer une erreur ou libérer une ressource.
  • Intercepter des exceptions précises et en lever avec des messages utiles.
  • Chaîner les exceptions avec raise ... from pour préserver la cause.
  • Utiliser des context managers pour automatiser le nettoyage.

#Anatomie d'un bloc try

pythonpython

1def lire_entier(invite: str) -> int:2    while True:3        reponse = input(invite)4        try:5            return int(reponse)6        except ValueError:7            print(f"'{reponse}' n'est pas un entier, réessayez.")8 9# int('abc') lève ValueError, interceptée ici: la boucle redemande.

Les quatre clauses, dans l'ordre:

  • try: le code surveillé.
  • except TypeErreur as e: traitement si cette erreur survient (plusieurs clauses possibles, de la plus précise à la plus générale).
  • else: exécuté si aucune exception n'est survenue (sépare le cas de succès du code surveillé).
  • finally: toujours exécuté, succès ou échec (libération de ressources).
pythonpython

1try:2    valeur = 10 / 23except ZeroDivisionError:4    print('division par zéro')5else:6    print('résultat:', valeur)   # résultat: 5.07finally:8    print('toujours exécuté')9# Sortie:10# résultat: 5.011# toujours exécuté

#Lever une exception

Une fonction qui détecte une entrée invalide le signale avec raise plutôt que de retourner une valeur magique:

pythonpython

1def racine(n: float) -> float:2    if n < 0:3        raise ValueError(f"n doit être >= 0, reçu {n}")4    return n ** 0.55 6print(racine(9))     # 3.07 8try:9    racine(-1)10except ValueError as e:11    print('Refusé:', e)12# Sortie: Refusé: n doit être >= 0, reçu -1

Le message inclut la valeur fautive: c'est ce qui fait gagner du temps au débogage. Retourner None, -1 ou 0 en cas d'erreur est un anti-pattern: l'appelant peut ignorer la valeur et le bug se propage silencieusement.

#Hiérarchie et exceptions métier

Les exceptions forment une hiérarchie: ZeroDivisionError et ValueError héritent d'Exception. Créer sa propre classe héritant d'Exception distingue les erreurs métier des erreurs système:

pythonpython

1class SoldeInsuffisant(Exception):2    """Erreur métier: retrait au-delà du solde disponible."""3 4def retire(solde: float, montant: float) -> float:5    if montant > solde:6        raise SoldeInsuffisant(f"solde {solde}, retrait demandé {montant}")7    return solde - montant8 9try:10    retire(100, 150)11except SoldeInsuffisant as e:12    print('Opération refusée:', e)13# Sortie: Opération refusée: solde 100, retrait demandé 150

#Chaînage: raise ... from

Envelopper une erreur technique dans une erreur métier sans perdre la cause:

pythonpython

1class ErreurConfig(Exception):2    pass3 4def charge_config(chemin: str) -> dict:5    try:6        with open(chemin, encoding='utf-8') as f:7            import json8            return json.load(f)9    except OSError as e:10        raise ErreurConfig(f"impossible de lire {chemin}") from e11 12try:13    charge_config('absent.json')14except ErreurConfig as e:

e.__cause__ conserve l'erreur d'origine: la trace complète reste consultable au débogage.

#Nettoyage: finally et context managers

finally convient quand il n'y a pas d'abstraction disponible; le context manager with exprime la même garantie de libération de façon déclarative:

pythonpython

1from contextlib import contextmanager2import time3 4@contextmanager5def chronometre():6    debut = time.perf_counter()7    try:8        yield9    finally:10        duree_ms = (time.perf_counter() - debut) * 100011        print(f"durée: {duree_ms:.1f} ms")12 13with chronometre():14    total = sum(range(100_000))

Le code après yield s'exécute à la sortie du bloc, normalement ou sur exception. C'est exactement le mécanisme de with open(...) as f: pour refermer le fichier.

#Playground

Chargement de l’éditeur...

#Exercices

  1. Parseur robuste. Lisez des lignes au format nom;note (certaines invalides: note non numérique, nombre de champs incorrect). Affichez les paires valides et un rapport listant chaque ligne invalide avec sa raison. Testez sur ['Ada;18', 'Bob;abc', 'Eve'].
  2. Chronomètre vérifié. Reprenez chronometre ci-dessus et vérifiez qu'il affiche bien la durée même quand le bloc lève une exception (levez RuntimeError dans le with et interceptez-le autour).
  3. Retrait bancaire. À partir de retire, écrivez une boucle qui traite une liste d'opérations, ignore celles qui échouent avec SoldeInsuffisant en les signalant, et affiche le solde final. Départ 100, opérations [('retrait', 30), ('retrait', 90), ('retrait', 20)], attendu: solde final 50 et un refus.

#Corrections

Correction: parseur robuste
pythonpython

1lignes = ['Ada;18', 'Bob;abc', 'Eve']2valides, erreurs = [], []3 4for numero, ligne in enumerate(lignes, start=1):5    parties = ligne.strip().split(';')6    if len(parties) != 2:7        erreurs.append((numero, 'nombre de champs incorrect'))8        continue9    nom, note = parties10    try:11        valides.append((nom, int(note)))12    except ValueError:13        erreurs.append((numero, f"note non entière: {note!r}"))14 

Le rapport d'erreurs porte le numéro de ligne et la raison: c'est lui qui rend le traitement auditable. Intercepter ValueError au plus près de la conversion garde le reste de la boucle sain.

Correction: chronomètre vérifié
pythonpython

1from contextlib import contextmanager2import time3 4@contextmanager5def chronometre():6    debut = time.perf_counter()7    try:8        yield9    finally:10        print(f"durée: {(time.perf_counter() - debut) * 1000:.1f} ms")11 12try:13    with chronometre():14        raise RuntimeError('panne simulée')

La ligne "durée" s'affiche avant "interceptée": finally s'exécute pendant la remontée de l'exception, avant que le except externe ne l'attrape. La garantie tient même en cas d'erreur.

Correction: retrait bancaire
pythonpython

1class SoldeInsuffisant(Exception):2    pass3 4def retire(solde: float, montant: float) -> float:5    if montant > solde:6        raise SoldeInsuffisant(f"solde {solde}, retrait {montant}")7    return solde - montant8 9solde = 10010for _, montant in [('retrait', 30), ('retrait', 90), ('retrait', 20)]:11    try:12        solde = retire(solde, montant)13    except SoldeInsuffisant as e:14        print('refus:', e)

L'exception interceptée permet de poursuivre le traitement: une opération invalide n'interrompt pas la file d'opérations.

#Pièges fréquents

#return dans finally supprime l'exception

pythonpython

1def f():2    try:3        1 / 04    finally:5        return 'masque l\'exception'   # à ne jamais faire6 7print(f())   # 'masque l'exception': le ZeroDivisionError a disparu

Un return (ou break) dans finally avale l'exception en cours de propagation. Règle: finally nettoie, il ne retourne rien.

#except trop large

except Exception: sans re-raise ciblé intercepte aussi les fautes de frappe (NameError, AttributeError) et transforme un bug évident en comportement silencieux erroné. Interceptez le type précis; si vous devez intercepter large, journalisez puis relancez avec raise.

#Exception silencieuse

pythonpython

1try:2    traite(fichier)3except Exception:4    pass          # anti-pattern: l'erreur existe mais personne ne la voit

Même en production, journalisez l'erreur. Un pass rend le programme impossible à déboguer.

#Quiz

Quelle clause s'exécute toujours ?
Quelle clause s'exécute toujours ?
Que fait un return placé dans finally ?
Que fait un return placé dans finally ?
À quoi sert raise ... from e ?
À quoi sert raise ... from e ?