Aller au contenu

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é
  1. Les catch sont testés dans l'ordre : le plus précis d'abord. Dans l'autre ordre, le compilateur refuse : exception NoSuchFileException has already been caught.
  2. finally s'exécute dans tous les cas : succès, exception, et même après un return.

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)!
    }
}
  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 :

static int lireNombre(Path chemin) throws IOException {
    String s = Files.readString(chemin).trim();
    return Integer.parseInt(s);  // NumberFormatException : unchecked, absente de la signature
}
fn lire_nombre(chemin: &Path) -> Result<i32, Box<dyn Error>> {
    let s = fs::read_to_string(chemin)?;   // propagation explicite
    Ok(s.trim().parse()?)
}
int lire_nombre(const std::string& chemin) {
    std::ifstream f(chemin);
    if (!f) throw std::runtime_error("ouverture impossible : " + chemin);
    int n;
    if (!(f >> n)) throw std::runtime_error("pas un nombre");
    return n;
}
def lire_nombre(chemin):
    with open(chemin) as f:
        return int(f.read().strip())   # OSError, ValueError : rien dans la signature
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 champ null + un getter qui renvoie Optional.ofNullable(champ) fait le travail.
  • Pas en paramètre : l'appelant devrait écrire f(Optional.empty()) ; propose plutôt deux surcharges, ou accepte null et 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 renvoie Optional ne doit jamais renvoyer null.

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 en DataAccessException (unchecked). Tes exceptions métier héritent aussi de RuntimeException, et un gestionnaire global les transforme en réponses HTTP (404, 400...).
  • Les repositories renvoient des Optional : findById(id) renvoie un Optional<Todo>. Le motif typique dans un service :

    Todo todo = repository.findById(id)
        .orElseThrow(() -> new TodoIntrouvableException(id));
    
  • Par défaut, @Transactional annule la transaction (rollback) sur une exception unchecked, pas sur une checked. Encore une raison de préférer les unchecked. Le fonctionnement de @Transactional est 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
  1. List.of refuserait le null ; Arrays.asList l'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
ac
acd

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 avec throws. RuntimeException : unchecked.
  • Une exception checked ≈ un Result dans la signature, mais propagé implicitement (pas de ?).
  • finally s'exécute toujours ; jamais de return dedans. 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.
  • orElseGet pour une valeur par défaut coûteuse, orElseThrow pour échouer explicitement, jamais get() seul.
  • Spring : exceptions unchecked, findById(...).orElseThrow(...), rollback par défaut sur les unchecked.