Exceptions et Optional¶
Java gère les erreurs avec des exceptions, comme Python et C++. Mais il a une particularité unique : les exceptions checked, que le compilateur t'oblige à traiter. Et pour l'absence de valeur, il a Optional<T>, un cousin (moins strict) de l'Option<T> de Rust.
Dans ce chapitre
- La hiérarchie
Throwable/Error/Exception/RuntimeException - Checked contre unchecked, et la clause
throws try/catch/finally, le multi-catch et le try-with-resources- Créer ses exceptions et chaîner la cause
- Les comparaisons avec
Result<T, E>(Rust), C++ et Python, et les bonnes pratiques Optional<T>: l'équivalent d'Option<T>, et où ne pas l'utiliser
La hiérarchie des exceptions¶
Tout ce qui peut être lancé avec throw hérite de Throwable :
flowchart TD
T[Throwable] --> E[Error<br/>ne pas attraper]
T --> X[Exception<br/>checked]
E --> OOM[OutOfMemoryError]
E --> SO[StackOverflowError]
X --> IO[IOException]
IO --> NSF[NoSuchFileException]
X --> R[RuntimeException<br/>unchecked]
R --> NPE[NullPointerException]
R --> IAE[IllegalArgumentException]
IAE --> NFE[NumberFormatException]
R --> ISE[IllegalStateException]
R --> IOOBE[IndexOutOfBoundsException]
| Branche | Signification | Le compilateur t'oblige à la gérer ? |
|---|---|---|
Error |
La JVM est en difficulté (mémoire, pile) | Non, et ne l'attrape pas |
Exception (hors RuntimeException) |
Erreur prévisible de l'environnement : fichier absent, réseau... | Oui (checked) |
RuntimeException |
Bug ou argument invalide : null, index, cast... |
Non (unchecked) |
Checked contre unchecked¶
Une exception checked doit être soit attrapée, soit déclarée dans la signature avec throws. Sinon, ça ne compile pas :
static String lire(String chemin) {
return Files.readString(Path.of(chemin));
// error: unreported exception IOException; must be caught or declared to be thrown
}
static String lire(String chemin) throws IOException { // ok : je propage
return Files.readString(Path.of(chemin));
}
Les exceptions unchecked (RuntimeException et ses filles) ne figurent nulle part dans la signature. Integer.parseInt("douze") peut lever NumberFormatException sans que rien ne te prévienne.
Le lien avec Rust
Une exception checked, c'est un peu un Result<T, E> : l'erreur fait partie de la signature et le compilateur vérifie que tu t'en occupes. Mais la propagation est implicite : pas de ?, l'exception remonte toute seule si la méthode déclare throws.
Une RuntimeException est entre les deux : elle signale souvent un bug, comme un panic!, mais on l'attrape couramment (Spring le fait pour transformer les erreurs en réponses HTTP).
Les exceptions checked ont mauvaise réputation : elles se composent mal avec les lambdas (voir Lambdas et streams) et poussent à écrire des catch vides pour faire taire le compilateur. Les bibliothèques modernes, dont Spring, utilisent presque uniquement des exceptions unchecked.
try / catch / finally¶
try {
String contenu = lire("/nexiste/pas.txt");
System.out.println(contenu);
} catch (NoSuchFileException e) { // (1)!
System.out.println("Fichier absent : " + e.getMessage());
} catch (IOException e) {
System.out.println("Erreur d'E/S : " + e);
} finally {
System.out.println("toujours exécuté"); // (2)!
}
// affiche : Fichier absent : /nexiste/pas.txt
// toujours exécuté
- Les
catchsont testés dans l'ordre : le plus précis d'abord. Dans l'autre ordre, le compilateur refuse :exception NoSuchFileException has already been caught. finallys'exécute dans tous les cas : succès, exception, et même après unreturn.
Le multi-catch traite plusieurs types de la même façon :
try {
int n = Integer.parseInt(s);
System.out.println(100 / n);
} catch (NumberFormatException | ArithmeticException e) {
System.out.println("Refusé : " + e.getMessage());
}
// s = "douze" : Refusé : For input string: "douze"
// s = "0" : Refusé : / by zero
// s = "4" : 25
Les types d'un multi-catch ne doivent pas hériter l'un de l'autre (IOException | NoSuchFileException est refusé).
Piège : return dans un finally
Un return dans finally remplace l'exception en cours, qui disparaît sans laisser de trace :
static int lire() {
try {
throw new IllegalStateException("perdue");
} finally {
return 0; // l'exception est avalée, lire() renvoie 0
}
}
Le compilateur avertit seulement avec -Xlint (finally clause cannot complete normally). Ne mets jamais de return ni de throw dans un finally.
Rappel : try-with-resources¶
Tout objet qui implémente AutoCloseable (fichier, connexion...) et qui est ouvert dans un try (...) est fermé automatiquement en sortie de bloc, dans l'ordre inverse d'ouverture, même en cas d'exception : c'est le Drop de Rust et le with de Python, vu dans Mémoire et références.
Un détail propre aux exceptions : si le bloc et close() lèvent tous les deux, c'est l'exception du bloc qui remonte, et celle de close() lui est attachée comme exception supprimée (au lieu de l'écraser) :
class Fragile implements AutoCloseable {
@Override public void close() { throw new IllegalStateException("échec de close"); }
}
try (Fragile f = new Fragile()) {
throw new RuntimeException("échec du traitement");
} catch (RuntimeException e) {
System.out.println(e.getMessage()); // échec du traitement
for (Throwable s : e.getSuppressed()) {
System.out.println(" supprimée : " + s.getMessage()); // échec de close
}
}
Créer ses exceptions¶
Une exception est une classe comme une autre. Hérite de RuntimeException (unchecked) sauf raison précise, et ajoute des champs si l'appelant en a besoin :
class SoldeInsuffisantException extends RuntimeException {
private final long manque;
SoldeInsuffisantException(long manque) {
super("Solde insuffisant"); // le message de getMessage()
this.manque = manque;
}
long manque() { return manque; }
}
void retirer(long montant) {
if (montant > solde) throw new SoldeInsuffisantException(montant - solde);
solde -= montant;
}
Chaîner la cause¶
Quand tu traduis une exception de bas niveau en exception métier, passe l'originale en cause. Sinon tu perds l'information qui permet de déboguer :
class ConfigException extends RuntimeException {
ConfigException(String message, Throwable cause) {
super(message, cause);
}
}
static String charger(String chemin) {
try {
return Files.readString(Path.of(chemin));
} catch (IOException e) {
throw new ConfigException("Impossible de charger " + chemin, e); // (1)!
}
}
- Une exception checked transformée en unchecked : l'appelant n'a plus de
throwsà déclarer, mais rien n'est perdu.
La trace (stack trace) affiche alors les deux, reliées par Caused by (sortie réelle, raccourcie) :
ConfigException: Impossible de charger app.conf
at Config.charger(Perso.java:35)
at Perso.main(Perso.java:7)
...
Caused by: java.nio.file.NoSuchFileException: app.conf
at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
...
at Config.charger(Perso.java:33)
... 6 more
C'est l'équivalent de raise ConfigError(...) from e en Python, ou de .context() avec anyhow en Rust.
Comparaison avec Rust, C++ et Python¶
Lire un entier dans un fichier et propager les erreurs :
| Langage | Erreur attendue | Bug | Visible dans la signature |
|---|---|---|---|
| Java | Exception checked (ou unchecked) | RuntimeException |
throws, pour les checked |
| Rust | Result<T, E> + ? |
panic! |
Toujours (type de retour) |
| C++ | Exception (ou std::expected) |
Exception, abort, UB |
Non (noexcept seulement) |
| Python | Exception | Exception | Non |
Bonnes pratiques¶
Ne jamais avaler une exception. Un catch vide cache le problème jusqu'au jour où il explose ailleurs, sans indice :
try { traiter(); } catch (Exception e) { } // non : l'erreur disparaît
try { traiter(); } catch (IOException e) {
throw new UncheckedIOException("Traitement impossible", e); // oui : on propage
}
Pas de catch (Exception e) partout. N'attrape que ce que tu sais traiter, à l'endroit où tu sais le traiter. Le reste remonte jusqu'à un gestionnaire global ; dans Spring, c'est un @RestControllerAdvice (voir API REST).
Les exceptions, c'est pour l'exceptionnel. Pas pour le contrôle de flux normal : créer une exception capture toute la pile d'appels, ce qui coûte cher. « L'utilisateur n'existe pas » dans une recherche, c'est un Optional vide ; « l'utilisateur 42 demandé par l'URL n'existe pas », c'est une exception.
Réutilise les exceptions standard quand elles collent :
IllegalArgumentException: argument invalide ;IllegalStateException: l'objet n'est pas dans le bon état pour cet appel ;NoSuchElementException: élément absent ;UnsupportedOperationException: opération non supportée ;Objects.requireNonNull(x, "x"): lève une NPE avec un message clair, dès l'entrée de la méthode.
Optional<T> : l'équivalent d'Option<T>¶
En Java, toute référence peut être null, et rien dans le type ne le dit. Optional<T> sert à rendre l'absence visible dans le type de retour d'une méthode :
Optional<Utilisateur> findById(long id) {
return Optional.ofNullable(base.get(id)); // vide si get renvoie null
}
| Java | Rust |
|---|---|
Optional.of(x) |
Some(x) (NPE si x est null) |
Optional.empty() |
None |
Optional.ofNullable(x) |
(pas d'équivalent : pas de null en Rust) |
.map(f) / .filter(p) |
.map(f) / .filter(p) |
.flatMap(f) |
.and_then(f) |
.orElse(v) / .orElseGet(() -> v) |
.unwrap_or(v) / .unwrap_or_else(...) |
.orElseThrow() |
.unwrap() (lève NoSuchElementException) |
.orElseThrow(() -> new X()) |
.ok_or_else(...)? |
.ifPresent(x -> ...) |
if let Some(x) = ... |
findById(1).map(Utilisateur::nom).orElse("inconnu"); // "Ada"
findById(9).map(Utilisateur::nom).orElse("inconnu"); // "inconnu"
findById(2).map(Utilisateur::email).orElse("pas d'email");
// "pas d'email" : si la fonction de map renvoie null, on obtient un Optional vide
findById(1).ifPresent(u -> System.out.println("Trouvé : " + u.nom()));
findById(9).ifPresentOrElse(
u -> System.out.println(u),
() -> System.out.println("Absent"));
Utilisateur u = findById(9)
.orElseThrow(() -> new NoSuchElementException("Utilisateur 9 introuvable"));
// flatMap : la fonction renvoie elle-même un Optional
Optional.of("21").flatMap(s -> parseEntier(s)).map(n -> n * 2); // Optional[42]
Piège : orElse évalue toujours son argument
orElse(x) reçoit une valeur déjà calculée. Si ce calcul est coûteux (ou a un effet de bord), il est fait même quand l'Optional est plein :
findById(1).map(Utilisateur::nom).orElse(nomParDefaut());
// nomParDefaut() est appelé, pour rien
findById(1).map(Utilisateur::nom).orElseGet(() -> nomParDefaut());
// nomParDefaut() n'est pas appelé
Même distinction qu'entre unwrap_or et unwrap_or_else en Rust.
Piège : get() et isPresent()
opt.get() sur un Optional vide lève NoSuchElementException: No value present. Le motif if (opt.isPresent()) { opt.get() } n'est qu'un test de null déguisé : préfère map, orElse... ou ifPresent. Si tu veux vraiment « planter si vide », écris orElseThrow(), qui dit clairement ton intention.
Où ne pas utiliser Optional¶
Optional a été conçu pour les types de retour, et seulement pour eux :
- Pas en champ : ce n'est pas
Serializable, ça alourdit chaque objet, et un champnull+ un getter qui renvoieOptional.ofNullable(champ)fait le travail. - Pas en paramètre : l'appelant devrait écrire
f(Optional.empty()); propose plutôt deux surcharges, ou acceptenullet documente-le. - Pas autour d'une collection : renvoie une liste vide, pas un
Optional<List<T>>. - Jamais
nullà la place d'un Optional : une méthode qui renvoieOptionalne doit jamais renvoyernull.
Différence avec Rust
Option<T> est un enum sans coût, et le compilateur t'interdit d'oublier le cas None. Optional<T> est un objet alloué sur le tas, rien ne t'oblige à le consulter, et la référence Optional elle-même pourrait être null. C'est une convention utile, pas une garantie.
Et dans Spring ?¶
Spring a fait des choix clairs, que tu retrouveras dans la partie 2 :
- Exceptions unchecked partout. Les erreurs SQL (checked
SQLException) sont traduites enDataAccessException(unchecked). Tes exceptions métier héritent aussi deRuntimeException, et un gestionnaire global les transforme en réponses HTTP (404, 400...). -
Les repositories renvoient des
Optional:findById(id)renvoie unOptional<Todo>. Le motif typique dans un service : -
Par défaut,
@Transactionalannule la transaction (rollback) sur une exception unchecked, pas sur une checked. Encore une raison de préférer les unchecked. Le fonctionnement de@Transactionalest expliqué dans Annotations et réflexion.
Exercices¶
1. Écris static Optional<Integer> parseEntier(String s) qui renvoie un Optional vide si s est null ou n'est pas un entier (pense à trim()). Utilise-la pour sommer les entrées valides de Arrays.asList("12", "abc", " 30 ", null).
Solution
static Optional<Integer> parseEntier(String s) {
if (s == null) return Optional.empty();
try {
return Optional.of(Integer.parseInt(s.trim()));
} catch (NumberFormatException e) {
return Optional.empty(); // ici, l'erreur est attendue : on la traduit
}
}
List<String> saisies = Arrays.asList("12", "abc", " 30 ", null); // (1)!
int total = 0;
for (String s : saisies) {
total += parseEntier(s).orElse(0);
}
System.out.println(total); // affiche : 42
List.ofrefuserait lenull;Arrays.asListl'accepte.
2. Crée une exception unchecked TodoIntrouvableException dont le message est Todo <id> introuvable, puis une méthode Todo trouver(long id) qui s'appuie sur un findById renvoyant un Optional<Todo>.
Solution
record Todo(long id, String titre) {}
class TodoIntrouvableException extends RuntimeException {
TodoIntrouvableException(long id) {
super("Todo " + id + " introuvable");
}
}
static final Map<Long, Todo> BASE = Map.of(1L, new Todo(1, "Apprendre Java"));
static Optional<Todo> findById(long id) {
return Optional.ofNullable(BASE.get(id));
}
static Todo trouver(long id) {
return findById(id).orElseThrow(() -> new TodoIntrouvableException(id));
}
System.out.println(trouver(1).titre()); // affiche : Apprendre Java
try {
trouver(42);
} catch (TodoIntrouvableException e) {
System.out.println(e.getMessage()); // affiche : Todo 42 introuvable
}
3. Écris String lireConfig(Path chemin) sans throws : une IOException doit devenir une ConfigException (unchecked) qui garde la cause. Vérifie que getCause() est bien conservée.
Solution
class ConfigException extends RuntimeException {
ConfigException(String message, Throwable cause) { super(message, cause); }
}
static String lireConfig(Path chemin) {
try {
return Files.readString(chemin);
} catch (IOException e) {
throw new ConfigException("Config illisible : " + chemin, e);
}
}
try {
lireConfig(Path.of("absent.properties"));
} catch (ConfigException e) {
System.out.println(e.getMessage());
System.out.println("cause : " + e.getCause());
}
// affiche : Config illisible : absent.properties
// cause : java.nio.file.NoSuchFileException: absent.properties
4. Sans l'exécuter, qu'affiche ce programme ?
static StringBuilder sb = new StringBuilder();
static String test() {
try {
sb.append("a");
Integer.parseInt("x");
sb.append("b");
} catch (NumberFormatException e) {
sb.append("c");
return sb.toString();
} finally {
sb.append("d");
}
return sb.toString();
}
public static void main(String[] args) {
System.out.println(test());
System.out.println(sb);
}
Solution
parseInt lève l'exception, donc "b" n'est jamais ajouté. Le return du catch calcule sa valeur ("ac"), puis le finally s'exécute (ajoute "d" au StringBuilder) avant que la méthode ne rende la main. La valeur déjà calculée n'est pas modifiée.
À retenir¶
Error: ne pas attraper.Exception: checked, à attraper ou déclarer avecthrows.RuntimeException: unchecked.- Une exception checked ≈ un
Resultdans la signature, mais propagé implicitement (pas de?). finallys'exécute toujours ; jamais dereturndedans. Pour les ressources, try-with-resources.- Crée des exceptions unchecked et chaîne toujours la cause (
super(message, cause)). - N'avale jamais une exception, n'attrape que ce que tu sais traiter, laisse le reste remonter.
Optional<T>≈Option<T>, uniquement en type de retour : ni champ, ni paramètre, ni autour d'une collection.orElseGetpour une valeur par défaut coûteuse,orElseThrowpour échouer explicitement, jamaisget()seul.- Spring : exceptions unchecked,
findById(...).orElseThrow(...), rollback par défaut sur les unchecked.