https://a.storyblok.com/f/270183/1368x665/9810908b7d/checked-exceptions_24.png

Pourquoi faut-il éviter d'utiliser des exceptions vérifiées en Java ?

Publié le September 12, 2023

Temps de lecture : 11 minutes

J'ai fait une courte présentation à ce sujet à Devoxx UK 2023. A voir sur sur YouTube.

Introduction

La plupart des langages de programmation courants disposent de structures dédiées au traitement des les exceptions. Qu'est-ce qu'une exception ? Il s'agit de cas de comportement anormal résultant d'une entrée inhabituelle ou d'un état du système. Les mécanismes de gestion des exceptions interrompent le déroulement normal de l'exécution d'un programme, comme indiqué sur Wikipédia. Vous êtes probablement familier avec try-catch-finally si vous utilisez un langage de programmation orienté objet comme C# ou Java. Cependant, vous n'êtes peut-être pas familier avec la notion d' exceptions vérifiées. J'ai récemment appris que cette "fonctionnalité" est exclusive à Java, du moins en ce qui concerne les langages de programmation traditionnels. - du moins lorsqu'il s'agit de langages de programmation grand public. Dans cet article, j'espère vous convaincre que les exceptions contrôlées sont mauvaises et, si vous n'êtes pas un développeur Java, que vous devriez être reconnaissant que votre langage n'en dispose pas !

Différence entre les exceptions vérifiées et non vérifiées

L'idée des exceptions vérifiées est d'imposer une gestion explicite des exceptions au moment de la compilation afin d'assurer la "complétude" du code. Une méthode qui produit une exception vérifiée peut lancer cette exception, qui devient partie intégrante de la signature de la méthode. Tous les appelants de cette méthode doivent alors soit propager l'exception en l'incluant également dans la signature de leur méthode, soit la traiter à l'aide d'un catch bloc. Pour illustrer ce principe, examinons le code suivant :

public class ExceptionsDemo {

    public static void main(String[] args) throws Throwable {
        code();
    }

    static class BaseChecked extends Exception {}
    static class CheckedA extends BaseChecked {}
    static class CheckedB extends BaseChecked {}

    static class BaseUnchecked extends RuntimeException {}
    static class UncheckedA extends BaseUnchecked {}
    static class UncheckedB extends BaseUnchecked {}

    interface MyApi {
        void checked() throws CheckedA, CheckedB;
        void unchecked();
    }

    static class MyApiImpl implements MyApi {
        @Override
        public void checked() throws CheckedA {
            throw new CheckedA();
        }

        @Override
        public void unchecked() {
            throw (Math.random() > 0.5) ? new UncheckedA() : new UncheckedB();
        }
    }

    static void code() {
        MyApi api = new MyApiImpl();
        api.unchecked();
    }
}

Nous avons ici deux hiérarchies d'exceptions : BaseChecked et BaseUncheckedqui possèdent chacune deux sous-classes. Remarquez que BaseChecked s'étend à partir de Exceptionalors que BaseUnchecked étend RuntimeException. Pour illustrer la différence, considérons l'interface MyApi qui déclare deux méthodes. La méthode checked() déclare dans sa signature qu'une méthode d'implémentation peut lancer soit une méthode CheckedA ou CheckedB alors que unchecked() ne déclare rien. Lors de l'implémentation de l'interface, il est possible de lancer n'importe quelle exception non contrôlée à partir de la unchecked() à partir de la méthode C'est essentiellement ce qu'est une exception non contrôlée : une exception qui est une sous-classe de java.lang.RuntimeException (directement ou indirectement).

En revanche, les exceptions vérifiées sont des sous-classes de java.lang.Exception. Le compilateur exige que les exceptions vérifiées soient déclarées dans la signature de la méthode pour qu'elles soient lancées. Cependant, il convient de noter que, bien que la méthode de l'interface MyApi::checked déclare CheckedA et CheckedB comme des exceptions possibles, la mise en œuvre (MyApiImpl::checked) ne lance que CheckedA et n'a pas besoin de déclarer CheckedB. Ainsi, les méthodes superposées n'ont pas besoin de déclarer les exceptions de leur parent dans leur propre signature, à moins qu'elles ne soient lancées par la méthode. Toutefois, les nouvelles exceptions vérifiées qui ne sont pas déclarées par la super-méthode ne peuvent pas être lancées, car cela violerait les principes de polymorphie sur lesquels repose le langage.

Sémantique excentrique

La sémantique devient plus facile à comprendre avec l'aide d'un compilateur, donc si vous n'êtes pas familier avec les exceptions en Java, je vous encourage à jouer avec le code dans votre IDE. Par exemple, remarquez comment le compilateur vous oblige à gérer à la fois CheckedA et CheckedB si vous changez l'appel de api.unchecked() à api.checked() dans l'exemple suivant, c'est parce que vous appelez la méthode de l'interface, et non celle de l'implémentation.

static void code() {
        MyApi api = new MyApiImpl();
        try {
            api.checked();
        }
        catch (CheckedA | CheckedB ex) {
            ex.printStackTrace();
        }
    }

Vous pouvez bien sûr le jeter à la place :

static void code() throws CheckedA, CheckedB {
    MyApi api = new MyApiImpl();
    api.checked();
}

ou traiter une exception et rejeter l'autre :

static void code() throws CheckedB {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (CheckedA ax) {
        // TODO handle here
    }
}

Vous pouvez également "protéger votre code pour l'avenir" pour une compatibilité maximale en déclarant BaseChecked dans la signature de la méthode :

static void code() throws BaseChecked {
    MyApi api = new MyApiImpl();
    api.checked();
}

Si vous ne voulez pas polluer la signature de votre méthode, vous pouvez l'envelopper dans une méthode RuntimeException:

static void code() {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (BaseChecked ex) {
        throw new RuntimeException(ex);
    }
}

Notez que vous pouvez toujours l'attraper dans le code d'appel, mais vous devrez appeler getCause() pour obtenir l'exception originale qui a été lancée, comme suit :

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (Exception ex) {
        assert ex.getCause() instanceof BaseChecked;
    }
}

Lancer sournois

Il y a aussi une astuce cachée que j'ai apprise en faisant des recherches sur ce sujet et qui m'a surpris la première fois que je l'ai rencontrée. Saviez-vous que Java vous permet de contourner efficacement les exceptions vérifiées ? Avant d'entrer dans le vif du sujet, vous devez d'abord savoir que RuntimeException étend en fait Exceptionbien que la première ne soit pas vérifiée et que la seconde le soit ! Le compilateur vérifie la hiérarchie des classes et crée une exception explicite (pardonnez le jeu de mots) pour RuntimeException et ses sous-classes. Voici l'astuce

public static <E extends Throwable> void sneakyThrow(Exception ex) throws E {
    throw (E) ex;
}

static void code() {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (BaseChecked ex) {
        sneakyThrows(ex);
    }
}

Remarquez que nous pouvons appeler la méthode api.checked() et lancer effectivement l'exception vérifiée sans la déclarer dans la signature de la méthode de code()! Et non, il ne s'agit pas d'un sucre syntaxique pour envelopper une exception. Si vous deviez l'attraper dans l'appelant, vous verriez que c'est la même exception qui a été lancée, et non un RuntimeException qui l'enveloppe.

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (Exception ex) {
        System.out.println(ex.getClass().getName());
    }
}

Sous-types d'exception d'ombrage

Si la section précédente vous a fait perdre la tête, c'est que j'ai partiellement réussi à faire valoir mon point de vue : les exceptions contrôlées sont compliquées ! En particulier dans un langage qui supporte à la fois les exceptions vérifiées et non vérifiées, la sémantique et l'interaction entre elles peuvent être assez complexes et parfois dérangeantes. Ce problème est encore aggravé par le fait que les exceptions contrôlées sont "par défaut" en Java. Cela crée un problème d'"ombrage" des exceptions non vérifiées. Prenons par exemple le code suivant :

static void code() throws Exception {
    MyApi api = new MyApiImpl();
    if (Math.random() > 0.67) {
        api.checked();
    }
    else {
        api.unchecked();
    }
}

Remarquez que la signature de la méthode déclare maintenant le général java.lang.Exception dans sa clause throws dans sa clause. Si nous appelons la méthode, nous devons maintenant la gérer. Mais que se passe-t-il si, au lieu de lancer BaseChecked, a BaseUnchecked une exception est lancée ? Que se passe-t-il si nous ne voulons pas attraper RuntimeException? Eh bien, nous devons la relancer. La manière idiomatique est d'attraper RuntimeException en premier, comme ceci :

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (RuntimeException ex) {
        throw ex;
    }
    catch (Exception ex) {
        // Handle checked
    }
}

De même, si vous voulez gérer CheckedA, CheckedB et BaseUnchecked explicitement mais pas RuntimeExceptionvous pouvez le faire comme suit :

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (BaseUnchecked ex) {
        // Handle BaseUnchecked
    }
    catch (RuntimeException ex) {
        throw ex;
    }
    catch (Exception ex) {
        if (ex instanceof CheckedA) {
            // Handle CheckedA
        }
        else if (ex instanceof CheckedB) {
            // Handle CheckedB
        }
    }
}

Ainsi, vous attrapez les exceptions de la plus spécifique à la plus générique, presque comme une déclaration. switch déclaration. Voyez-vous le problème ? Plus une exception est générique, plus elle cache d'informations. Vous êtes obligé de traiter le type d'exception le plus générique, et c'est à vous de déchiffrer les sous-types spécifiques qui peuvent être lancés par une méthode. Cela ne peut pas être communiqué par les signatures de méthodes - le compilateur ne peut pas vous aider à cet égard. Vous devez donc vous appuyer sur la documentation ou même sur la connaissance du code source pour déchiffrer les exceptions possibles qu'une méthode peut lancer. On peut dire que cela va à l'encontre de l'objectif principal des exceptions vérifiées. Il est vrai que l'utilisation inappropriée des exceptions dans les signatures de méthodes est un problème de conception plus que de langage, mais il suffit d'une pomme pour corrompre vos signatures de méthodes.

Dans la pratique, cela est particulièrement fréquent avec java.io.IOExceptionoù il existe de nombreuses sous-classes qui décrivent des problèmes spécifiques, mais si vous utilisez une méthode de bibliothèque qui lance IOExceptionalors vous avez effectivement renoncé à la possibilité de lancer des sous-types plus spécifiques, à moins que vous ne soyez prêt à attraper et à gérer explicitement toutes les autres exceptions IO possibles, ce que je ne recommanderais pas !

Évolution de l'API et fuites dans l'abstraction

Par définition, les exceptions vérifiées doivent être déclarées dans la signature d'une méthode pour être lancées. Cela signifie que si votre implémentation change - par exemple, si vous appelez une méthode de la bibliothèque qui lève une exception vérifiée IOException ou une autre exception vérifiée - vous êtes obligé soit de la gérer au sein de votre implémentation, soit de l'envelopper dans une méthode RuntimeException. La première option alourdit votre code et rend la méthode plus difficile à comprendre. La seconde option est une solution de contournement, mais elle n'est pas nécessairement un "changement radical", car votre méthode n'a pas besoin de déclarer toutes les exceptions non vérifiées possibles. La meilleure façon de communiquer un tel changement est d'utiliser la @throws dans la documentation de la signature de la méthode, comme suit :

/**
 * Method that does X.
 * 
 * @throws UncheckedA If A goes wrong.
 * @throws UncheckedB If B goes wrong.
 */
static void code() {
    new MyApiImpl().unchecked();
}

De cette manière, les utilisateurs de votre méthode ne doivent pas partir en expédition pour découvrir quand une exception potentielle peut être levée. Cependant, cela impose une charge supplémentaire aux responsables de documenter explicitement les exceptions qui peuvent être levées, et aux utilisateurs de lire la documentation. Vous êtes explicite sur les exceptions que vous connaissez dans cette méthode et sur le moment où elles peuvent être levées. C'est sans doute plus communicatif que de simplement lancer une exception vérifiée et de compter sur le compilateur pour forcer vos utilisateurs à la gérer. En outre, de cette manière, vous pouvez être sélectif quant aux informations que vous exposez à vos utilisateurs. Vous n'avez pas à déclarer les détails de bas niveau des exceptions qui PEUVENT être lancées mais qui sont hautement improbables ou même impossibles. Ceci m'amène au point suivant.

Certaines exceptions ne peuvent JAMAIS se produire

Dans certains cas, les exceptions vérifiées doivent être capturées même s'il est prouvé qu'elles ne seront jamais levées. Voici un cas trivial :

static void code() throws URISyntaxException {
    URI url = new URI("https://example.com");
}

La plupart des chaînes sont des URI valides, puisque java.net.URI prend en charge les URI partiels et peut déterminer quels segments sont spécifiés. Conscients de ce fait, les responsables du JDK ont mis au point une autre méthode pour créer un URI. La [documentation] de cette méthode (https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URI.html#create(java.lang.String)) devrait vous donner une indication sur sa raison d'être.

static void code() {
    URI url = URI.create("example");
}

L'utilisation abusive d'exceptions vérifiées peut rendre une bibliothèque désagréable à utiliser et encore pire pour vos métriques. Un exemple particulièrement ennuyeux d'après mon expérience personnelle est Jackson ObjectMapper. Dans le Vonage Java SDKla plupart de nos objets de domaine doivent être sérialisés en JSON, mais l'utilisation de Jackson nous oblige à manipuler les objets JsonProcessingException. Cette exception ne peut être levée que si la classe n'utilise pas correctement les annotations. Il existe même une question populaire StackOverflow à ce sujet, car lorsque l'on cherche à couvrir 100% du code, il est nécessaire de couvrir le bloc catch même si, en pratique, l'exception ne sera jamais levée.

Propagation par défaut

La plupart du temps, vous voudrez probablement propager ("remonter") une exception de toute façon. La beauté des exceptions non contrôlées est que leur gestion est optionnelle. Par défaut, elles sont lancées jusqu'à ce qu'elles soient attrapées, ou gérées par la fonction de gestion des exceptions du thread en cours d'exécution (le comportement par défaut est d'afficher la trace de pile du thread). UncaughtExceptionHandler (le comportement par défaut est d'afficher la trace de pile du thread). Avec les exceptions vérifiées, vous devez les propager explicitement, ce qui, comme nous l'avons vu, pollue les signatures de méthodes et en fait le problème de l'appelant, même si, en pratique, l'exception peut ne jamais se produire.

Tomber en disgrâce

Ce n'est pas pour rien que Java est l'un des seuls langages de programmation largement répandus qui comporte des exceptions vérifiées (voir cette question de StackOverflow). D'autres langages JVM comme Kotlin, Scala, Groovy, Clojure, etc. n'ont pas d'exceptions vérifiées - du moins, ils ne vous obligent pas à les gérer. La documentation du langage Kotlin résume assez bien le raisonnement assez bien, et cite l'interview de interview de 2003 avec le concepteur principal du langage C# sur la raison d'être de l'abandon des exceptions vérifiées.

Au sein même du langage Java, il suffit d'examiner les nouvelles API de la bibliothèque standard pour se rendre compte que les exceptions vérifiées doivent être utilisées avec parcimonie. Les API introduites dans Java 8, telles que java.time, java.util.stream et java.util.function évitent toutes les exceptions vérifiées. L'API Streams, désormais bien établie, est activement hostile aux exceptions contrôlées. Cela a causé beaucoup de soucis aux développeurs qui souhaitent utiliser l'API Streams, car elle nécessite des solutions de contournement pour propager les exceptions vérifiées à partir des interfaces fonctionnelles intégrées de Java. Nous y reviendrons plus tard.

Autres paradigmes de gestion des exceptions

Ma curiosité pour les exceptions vérifiées est née d'une discussion avec mon collègue, Guillaume. Il a donné une excellente conférence sur ce sujet, qui plaide en faveur des exceptions vérifiées. Il affirme que les exceptions ne sont pas toujours le bon outil pour traiter les erreurs et préconise l'utilisation de monades à la place. La manière dont l'API Java Streams traite les exceptions en est un exemple. Prenons par exemple le code suivant :

static void code() {
    OptionalInt resultWrapped = IntStream.range(1, 20)
            .filter(i -> i % 9 == 0 && i % 2 == 0)
            .findAny();
    int guaranteedResult = resultWrapped.orElseThrow(IllegalStateException::new);
    int resultWithAlternative = resultWrapped.orElse(18);
}

Remarquez que l'utilisation d'un filter sur un flux signifie que lorsque vous appelez une opération terminale telle que finayAny()vous obtenez une enveloppe qui peut ou non contenir un résultat. Pour obtenir le résultat, vous devez appeler orElseThrow()qui peut également prendre un Supplier pour personnaliser l'exception si la valeur est absente. Il s'agit presque d'une exception vérifiée déguisée, car vous êtes obligé de reconnaître explicitement l'absence potentielle d'une valeur, même si, dans la pratique, sa présence est garantie. Bien sûr, on peut dire que c'est plus élégant et plus explicite, puisque vous avez également la possibilité de fournir une valeur alternative avec la commande orElse.

Cependant, l'intervention de Venkat Subramaniam à Devoxx UK 2023, intitulée "La gestion des exceptions dans la programmation fonctionnelle et réactive" m'a fait réaliser que les deux points de vue ne sont pas contradictoires. Voici deux citations directes de son exposé pour situer le contexte :

"La gestion des exceptions est un style de programmation purement impératif. "La programmation fonctionnelle et la gestion des exceptions s'excluent mutuellement.

Je recommande vivement cet exposé, qui aborde également la question de l'utilisation d'exceptions vérifiées dans un code fonctionnel, tel que les flux Java, comme nous l'avons vu précédemment. Ce qu'il faut retenir, c'est que dans les styles de programmation fonctionnelle et réactive, la gestion des exceptions est effectuée tout au long du processus de transformation des données. Plutôt que d'utiliser catch et finally les frameworks réactifs utilisent des fonctions explicites de gestion des erreurs appliquées au pipeline pour traiter les exceptions. Cela nous ramène à l'intention première des exceptions vérifiées : assurer l'exhaustivité et la reconnaissance explicite des erreurs et des défaillances dans le code. Le débat sur la gestion des exceptions porte peut-être davantage sur le moment et l'endroit du code où nous devons gérer les erreurs qui peuvent survenir que sur les mécanismes utilisés pour y parvenir.

Signature

C'est tout pour l'instant ! Si vous avez des commentaires ou des suggestions, n'hésitez pas à nous contacter sur X, anciennement connu sous le nom de Twitter ou à notre Slack communautaire. J'espère que cet article vous a été utile et je vous invite à me faire part de vos réflexions/opinions. Si vous l'avez apprécié, jetez un coup d'œil à mes autres articles sur Java.

Partager:

https://a.storyblok.com/f/270183/400x400/46a3751f47/sina-madani.png
Sina MadaniVonage Ancien membre de l'équipe

Sina est développeur Java chez Vonage. Il est issu d'une formation universitaire et est généralement curieux de tout ce qui touche aux voitures, aux ordinateurs, à la programmation, à la technologie et à la nature humaine. Pendant son temps libre, on peut le trouver en train de marcher ou de jouer à des jeux vidéo compétitifs.