Erreurs et exceptions, try, catch, finally, propagation et throws, exceptions personnalisées, et quand lever plutôt que rendre un code d'erreur.
Comment une méthode signale-t-elle qu'elle n'a pas pu faire son travail ?
La réponse du C était le code de retour : -1 en cas d'échec, et un appelant discipliné qui
le teste. Le cours de programmation a montré ce que cela coûte — scanf rend le nombre de
conversions réussies, et personne ne le regarde jamais.
int retirer(double montant) { if (montant > solde) return -1; /* échec */ solde -= montant; return 0;} compte.retirer(1000); /* et voilà. Personne n'a rien vérifié. */Quatre défauts, indépendants les uns des autres. Le code d'erreur peut être ignoré en toute
impunité — c'est le cas ci-dessus. Il occupe la valeur de retour, donc une méthode qui doit
rendre un résultat utile n'a plus de place pour signaler l'échec. Il ne porte aucune
information : -1 ne dit pas combien il manquait. Et il doit être propagé à la main à
travers chaque niveau d'appel, chacun devant le tester et le retransmettre.
Les exceptions règlent les quatre.
Le mécanisme
public void retirer(double montant) { if (montant <= 0) { throw new IllegalArgumentException("montant non positif : " + montant); } if (montant > solde) { throw new SoldeInsuffisantException(montant - solde); } solde -= montant;}throw interrompt immédiatement la méthode. La valeur de retour n'est jamais produite : il
n'y a plus de code d'erreur à ignorer, et le chemin d'échec est séparé du chemin normal.
Du côté de l'appelant :
try { compte.retirer(1000); System.out.println("retrait effectué");} catch (SoldeInsuffisantException e) { System.out.println("il manque " + e.getManquant() + " euros");} catch (IllegalArgumentException e) { System.out.println("montant invalide : " + e.getMessage());}Ce qui se passe alors mérite d'être décrit précisément, car c'est le mécanisme du bloc I
d'Algorithmique 2 employé à l'envers. Quand une exception est levée, la machine remonte la
pile d'appels — elle dépile les cadres un par un — jusqu'à trouver un catch capable de la
traiter. Tous les cadres traversés sont abandonnés. Si aucun catch ne se présente, la remontée
atteint main, le programme s'arrête, et la trace affichée est exactement le contenu de la
pile au moment du throw.
D'où la propriété centrale : l'exception franchit les niveaux intermédiaires sans qu'ils aient un mot à dire. Une méthode qui ne sait pas traiter un échec n'a rien à écrire du tout — c'est ce qui supprime la propagation manuelle du code d'erreur.
L'ordre des catch compte : ils sont examinés dans l'ordre, et le premier compatible gagne. Un
catch (Exception e) placé avant les autres les rend inatteignables, et le compilateur le
refuse — c'est la substituabilité du chapitre 6 appliquée aux erreurs, puisque toutes les
exceptions héritent d'un ancêtre commun.
finally
FileReader f = null;try { f = new FileReader("donnees.txt"); /* lecture */} catch (IOException e) { System.err.println("lecture impossible");} finally { if (f != null) f.close(); /* exécuté DANS TOUS LES CAS */}Le bloc finally s'exécute quoi qu'il arrive : sortie normale, exception attrapée, exception
non attrapée qui traverse — et même après un return. C'est le seul endroit où l'on peut
garantir qu'une ressource sera rendue.
Java offre depuis la version 7 une forme plus sûre, le try avec ressources, qui appelle
close() automatiquement :
try (FileReader f = new FileReader("donnees.txt")) { /* lecture */} catch (IOException e) { ... }C'est la forme à employer. Elle évite l'oubli du close, le null à tester, et l'exception que
close() pourrait lever dans le finally — cas tordu qui masquerait l'exception d'origine.
Un piège à connaître : un return dans le finally écrase celui du try, exception
comprise. C'est légal, c'est déroutant, et cela ne s'écrit pas.
Contrôlées ou non contrôlées
Java est le seul grand langage à faire cette distinction, et elle structure tout le chapitre.
| Contrôlées (checked) | Non contrôlées (unchecked) | |
|---|---|---|
| Héritent de | Exception | RuntimeException |
| Le compilateur | exige de les traiter ou de les déclarer | ne dit rien |
| Exemples | IOException, SQLException | NullPointerException, IllegalArgumentException |
| Signifient | un échec prévisible et extérieur | une erreur de programmation |
Une méthode qui peut lever une exception contrôlée sans la traiter doit l'annoncer :
public void charger(String chemin) throws IOException { ... }Le critère de choix, tel qu'on l'enseigne : une exception contrôlée signale une situation anormale mais attendue, sur laquelle l'appelant peut agir — fichier absent, réseau coupé. Une exception non contrôlée signale un bogue : un argument invalide, un pointeur nul, un indice hors bornes. On ne demande pas à l'appelant de traiter un bogue, on le corrige.
La distinction est débattue : elle produit des signatures encombrées et pousse à écrire des
catch vides pour faire taire le compilateur. C'est pourquoi les langages venus après Java —
C#, Kotlin — ne l'ont pas reprise. Il faut la connaître parce que Java l'impose, et savoir
qu'elle n'est pas une évidence.
Exceptions personnalisées
On en écrit une dès que le type standard ne dit pas assez, ou qu'on veut transporter une donnée.
public class SoldeInsuffisantException extends RuntimeException { private final double manquant; public SoldeInsuffisantException(double manquant) { super("il manque " + manquant + " euros"); this.manquant = manquant; } public double getManquant() { return this.manquant; }}Trois choix se lisent dans ces lignes. Le nom finit par Exception, convention universelle.
Le message passe au parent par super(...), et sera affiché dans la trace. Et la classe
porte une donnée — le montant manquant — que l'appelant peut exploiter : c'est le troisième
défaut du code d'erreur, réglé.
Le choix du parent — Exception ou RuntimeException — est le vrai choix de conception, et il
découle du tableau précédent.
Les trois anti-usages
Ils sont fréquents, et chacun annule le bénéfice du chapitre.
Avaler l'exception.
try { ... } catch (Exception e) { } /* le pire code de ce cours */L'échec disparaît sans trace. Le programme continue dans un état inconnu, et le diagnostic devient impossible. Si l'on ne sait vraiment pas quoi faire, on laisse remonter — c'est gratuit, il suffit de ne rien écrire.
Employer les exceptions comme structure de contrôle. Sortir d'une boucle par une exception, ou tester l'existence d'une clé en attrapant l'échec, fonctionne et coûte cher : construire une exception implique de capturer la pile d'appels. Une exception doit rester exceptionnelle.
Attraper trop large. catch (Exception e) ramasse tout, y compris ce qu'on n'avait pas
prévu — un bogue de programmation qu'il aurait mieux valu voir. On attrape le type le plus
précis possible.
Quels défauts du code de retour les exceptions corrigent-elles ?
Quand faut-il faire hériter son exception de RuntimeException plutôt que d'Exception ?
À vous
L'exercice suit le fil de bibliothèque, et compare les deux approches sur le même service.
D'abord la version à code de retour : vous écrivez emprunter qui rend -1, -2, 0, puis un
appelant qui oublie de tester — et vous constatez qu'un livre est emprunté deux fois sans
que rien ne le dise.
Puis la version à exceptions, avec une exception personnalisée transportant la donnée utile. Vous observerez la remontée de pile sur trois niveaux d'appel : le niveau intermédiaire n'écrit rien, et l'exception le traverse.
Enfin les pièges : l'ordre des catch, le finally qui s'exécute même après un return, et le
catch vide — que vous instrumenterez pour voir combien d'échecs il a fait disparaître.
Opposez code de retour et exceptions sur le même service, puis mesurez ce qu'un catch vide fait disparaître.
// ── 1. Version à code de retour ─────────────────────────────────────────── const OK = 0, DEJA_EMPRUNTE = -1, INCONNU = -2; class BibliothequeCodes { constructor(livres) { this.livres = livres; } emprunter(titre, qui) { const l = this.livres.find((x) => x.titre === titre); if (!l) return INCONNU; if (!l.disponible) return DEJA_EMPRUNTE; l.disponible = false; l.par = qui; return OK; } } // ── 2. Version à exceptions ─────────────────────────────────────────────── class LivreIndisponibleException extends Error { constructor(titre, detenteur) { super("« " + titre + " » est déjà emprunté par " + detenteur); this.name = "LivreIndisponibleException"; // ← à écrire : transporter la donnée utile (le détenteur actuel) } } class BibliothequeExceptions { constructor(livres) { this.livres = livres; } emprunter(titre, qui) { const l = this.livres.find((x) => x.titre === titre); if (!l) throw new Error("titre inconnu : " + titre); // ← à écrire : lever LivreIndisponibleException si déjà emprunté l.disponible = false; l.par = qui; } } // Trois niveaux d'appel : le niveau intermédiaire n'écrit RIEN. function servirUnLecteur(biblio, titre, qui) { traiterDemande(biblio, titre, qui); } function traiterDemande(biblio, titre, qui) { biblio.emprunter(titre, qui); } // ── 3. Le catch vide, instrumenté ───────────────────────────────────────── let avales = 0; function catchVide(action) { try { action(); } catch (e) { avales++; } // le pire code du cours } // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Appelez emprunter() deux fois SANS tester le code de retour, et // constatez ce qui n'est pas dit. // 2. Complétez l'exception personnalisée et son levée. Vérifiez que le // niveau intermédiaire n'a rien eu à écrire. // 3. Faites vingt emprunts dont plusieurs impossibles, à travers catchVide : // combien d'échecs ont disparu, et qu'en sait le programme ? const catalogue = () => [ { titre: "Dune", disponible: true, par: null }, { titre: "Ubik", disponible: true, par: null }, ]; const b = new BibliothequeCodes(catalogue()); b.emprunter("Dune", "Ana"); b.emprunter("Dune", "Bo"); console.log(" état : " + JSON.stringify(b.livres[0]));
En travaux pratiques
Signaler l'erreur là où on sait la traiter
Choisir entre code de retour et exception, distinguer vérifiées et non vérifiées, et supprimer de son code les deux fautes qui rendent une erreur invisible.
- Les TP 1 à 6
- 1. Sans exceptions
Écrivez le chargement d'un catalogue en signalant les erreurs par des codes de retour. Comptez les lignes de traitement d'erreur par rapport aux lignes utiles.
- 2. Avec exceptions
Réécrivez la même chose avec des exceptions. Recomptez, et repérez où le code utile est devenu lisible d'un seul tenant.
- 3. Vérifiée ou non
Écrivez une exception vérifiée et une non vérifiée. Appelez les deux sans les traiter et comparez ce que dit le compilateur.
- 4. Le catch vide
Attrapez une exception et ne faites rien. Provoquez l'erreur et observez ce que voit l'utilisateur. Puis affichez la trace et comparez.
- 5. Attraper trop large
Attrapez le type le plus général possible autour d'un bloc, et faites-y survenir une erreur de programmation. Constatez ce qui est masqué.
- 6. L'exception qui perd sa cause
Attrapez une exception basse et relancez-en une métier SANS transmettre la cause. Comparez la trace obtenue avec celle où la cause est transmise.
- 7. Fermer quoi qu'il arrive
Ouvrez un fichier, provoquez une erreur avant la fermeture, et vérifiez que la ressource fuit. Corrigez avec la fermeture automatique.
- 8. Choisir
Pour six situations que vous listerez, décidez : code de retour, exception vérifiée, ou non vérifiée. Justifiez chaque choix en une phrase.
- Votre version à exceptions sépare visiblement le cas nominal du traitement d'erreur
- Vous montrez un catch qui masque une erreur de programmation
- Votre trace conserve la cause d'origine sur trois niveaux
- Votre fichier est fermé même quand une exception survient
Ce que la suite en fait
Le chapitre 8 donne la notation pour dessiner ce que les six premiers chapitres ont construit, exceptions comprises : une hiérarchie d'exceptions est une hiérarchie de classes ordinaire, et elle se dessine comme telle.
Le chapitre 9 les fera rencontrer les collections et les fichiers, où elles sont partout :
IOException à la lecture, NumberFormatException à la conversion,
NoSuchElementException sur une collection vide. C'est là qu'on mesure que les exceptions ne
sont pas un ornement mais le mode de signalement de toute la bibliothèque standard.
À retenir
Vous avez parcouru les 9 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.