web-dev-qa-db-fra.com

Exceptions: pourquoi lancer tôt? Pourquoi attraper tard?

Il existe de nombreuses bonnes pratiques bien connues concernant la gestion des exceptions de manière isolée. Je connais assez bien les "choses à faire et à ne pas faire", mais les choses se compliquent quand il s'agit de meilleures pratiques ou de modèles dans des environnements plus vastes. "Lancez tôt, attrapez tard" - je l'ai entendu à plusieurs reprises et cela m'embrouille toujours.

Pourquoi devrais-je lancer tôt et attraper tard, si à une couche de bas niveau une exception de pointeur nul est levée? Pourquoi devrais-je l'attraper à une couche supérieure? Cela n'a pas de sens pour moi d'attraper une exception de bas niveau à un niveau supérieur, comme une couche métier. Il semble violer les préoccupations de chaque couche.

Imaginez la situation suivante:

J'ai un service qui calcule un chiffre. Pour calculer le chiffre, le service accède à un référentiel pour obtenir des données brutes et à d'autres services pour préparer le calcul. Si quelque chose s'est mal passé au niveau de la couche de récupération des données, pourquoi dois-je lancer une exception DataRetrievalException à un niveau supérieur? En revanche, je préfère envelopper l'exception dans une exception significative, par exemple une CalculationServiceException.

Pourquoi jeter tôt, attraper tard?

165
shylynx

D'après mon expérience, il est préférable de lever les exceptions au point où les erreurs se produisent. Vous faites cela parce que c'est le point où vous savez le mieux pourquoi l'exception a été déclenchée.

Lorsque l'exception déroule la sauvegarde des couches, la capture et la reprise sont un bon moyen d'ajouter un contexte supplémentaire à l'exception. Cela peut signifier lever un type d'exception différent, mais inclure l'exception d'origine lorsque vous effectuez cette opération.

Finalement, l'exception atteindra une couche où vous pourrez prendre des décisions sur le flux de code (par exemple, inviter l'utilisateur à agir). C'est le point où vous devez enfin gérer l'exception et poursuivre l'exécution normale.

Avec de la pratique et de l'expérience avec votre base de code, il devient assez facile de juger quand ajouter du contexte supplémentaire aux erreurs et où il est le plus judicieux de réellement gérer les erreurs.

Catch → Rethrow

Faites cela où vous pouvez utilement ajouter plus d'informations qui éviteraient à un développeur d'avoir à parcourir toutes les couches pour comprendre le problème.

Catch → Handle

Pour ce faire, vous pouvez prendre des décisions finales sur ce qui est un flux d'exécution approprié mais différent à travers le logiciel.

Capture → Retour d'erreur

Bien qu'il y ait des situations où cela est approprié, intercepter des exceptions et renvoyer une valeur d'erreur à l'appelant doit être envisagé pour une refactorisation dans une implémentation Catch → Rethrow.

121
Michael Shaw

Vous souhaitez lever une exception dès que possible car cela facilite la recherche de la cause. Par exemple, considérons une méthode qui pourrait échouer avec certains arguments. Si vous validez les arguments et échouez au tout début de la méthode, vous savez immédiatement que l'erreur se trouve dans le code appelant. Si vous attendez que les arguments soient nécessaires avant d'échouer, vous devez suivre l'exécution et déterminer si le bogue est dans le code appelant (mauvais argument) ou si la méthode a un bogue. Plus tôt vous lancez l'exception, plus elle est proche de sa cause sous-jacente et plus il est facile de comprendre où les choses ont mal tourné.

La raison pour laquelle les exceptions sont gérées à des niveaux supérieurs est que les niveaux inférieurs ne savent pas quelle est la ligne de conduite appropriée pour gérer l'erreur. En fait, il pourrait y avoir plusieurs façons appropriées de gérer la même erreur en fonction du code appelant. Prenez par exemple l'ouverture d'un fichier. Si vous essayez d'ouvrir un fichier de configuration et qu'il n'y est pas, ignorer l'exception et poursuivre la configuration par défaut peut être une réponse appropriée. Si vous ouvrez un fichier privé essentiel à l'exécution du programme et qu'il est en quelque sorte manquant, votre seule option est probablement de fermer le programme.

Envelopper les exceptions dans les bons types est une préoccupation purement orthogonale.

58
Doval

D'autres ont assez bien résumé pourquoi jeter tôt. Permettez-moi de me concentrer sur la partie pourquoi attraper tard, pour laquelle je n'ai pas vu d'explication satisfaisante à mon goût.

POURQUOI DES EXCEPTIONS?

Il semble y avoir une grande confusion quant à la raison pour laquelle des exceptions existent en premier lieu. Permettez-moi de partager le grand secret ici: la raison des exceptions et la gestion des exceptions est ... [~ # ~] abstraction [~ # ~] .

Avez-vous vu du code comme celui-ci:

static int divide(int dividend, int divisor) throws DivideByZeroException {
    if (divisor == 0)
        throw new DivideByZeroException(); // that's a checked exception indeed

    return dividend / divisor;
}

static void doDivide() {
    int a = readInt();
    int b = readInt(); 
    try {
        int res = divide(a, b);
        System.out.println(res);
    } catch (DivideByZeroException e) {
        // checked exception... I'm forced to handle it!
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

Ce n'est pas ainsi que les exceptions devraient être utilisées. Un code comme celui-ci existe dans la vie réelle, mais il s'agit plutôt d'une aberration et constitue vraiment l'exception (jeu de mots). La définition de division par exemple, même en mathématiques pures, est conditionnelle: c'est toujours le "code appelant" qui doit gérer le cas exceptionnel de zéro pour restreindre le domaine d'entrée. C'est moche. C'est toujours pénible pour l'appelant. Pourtant, pour de telles situations, le modèle check-then-do est la voie naturelle à suivre:

static int divide(int dividend, int divisor) {
    // throws unchecked ArithmeticException for 0 divisor
    return dividend / divisor;
}

static void doDivide() {
    int a = readInt();
    int b = readInt();
    if (b != 0) {
        int res = divide(a, b);
        System.out.println(res);
    } else {
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

Alternativement, vous pouvez utiliser le commando complet sur OOP style comme ceci:

static class Division {
    final int dividend;
    final int divisor;

    private Division(int dividend, int divisor) {
        this.dividend = dividend;
        this.divisor = divisor;
    }

    public boolean check() {
        return divisor != 0;
    }

    public int eval() {
        return dividend / divisor;
    }

    public static Division with(int dividend, int divisor) {
        return new Division(dividend, divisor);
    }
}

static void doDivide() {
    int a = readInt();
    int b = readInt(); 
    Division d = Division.with(a, b);
    if (d.check()) {
        int res = d.eval();
        System.out.println(res);
    } else {
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

Comme vous le voyez, le code de l'appelant a le fardeau de la pré-vérification, mais ne fait aucune gestion des exceptions par la suite. Si un ArithmeticException vient jamais d'appeler divide ou eval, alors c'est [~ # ~] que vous [~ # ~] qui doit faire la gestion des exceptions et corriger votre code, car vous avez oublié la check(). Pour les mêmes raisons, attraper un NullPointerException est presque toujours la mauvaise chose à faire.

Maintenant, il y a des gens qui disent qu'ils veulent voir les cas exceptionnels dans la signature de la méthode/fonction, c'est-à-dire étendre explicitement la sortie domaine . Ce sont eux qui favorisent exceptions vérifiées . Bien sûr, la modification du domaine de sortie devrait forcer tout code d'appel direct à s'adapter, et cela serait en effet obtenu avec des exceptions vérifiées. Mais vous n'avez pas besoin d'exceptions pour cela! C'est pourquoi vous avez Nullable<T>classes génériques , - classes de cas , types de données algébriques , et types d'union . Certains OO personnes pourraient même préfèrent retournernull pour les cas d'erreur simples comme celui-ci:

static Integer divide(int dividend, int divisor) {
    if (divisor == 0) return null;
    return dividend / divisor;
}

static void doDivide() {
    int a = readInt();
    int b = readInt(); 
    Integer res = divide(a, b);
    if (res != null) {
        System.out.println(res);
    } else {
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

Techniquement, les exceptions peuvent être utilisées dans le but comme ci-dessus, mais voici le point: les exceptions n'existent pas pour une telle utilisation . Les exceptions sont l'abstraction pro. Les exceptions concernent l'indirection. Les exceptions permettent d'étendre le domaine "résultat" sans rompre les contrats clients direct et de reporter la gestion des erreurs à "ailleurs". Si votre code lève des exceptions qui sont gérées par des appelants directs du même code, sans aucune couche d'abstraction entre les deux, alors vous le faites W. R. O.N.G.

COMMENT CAPTER TARD?

Donc nous en sommes là. J'ai argumenté pour montrer que l'utilisation des exceptions dans les scénarios ci-dessus n'est pas la façon dont les exceptions sont censées être utilisées. Il existe cependant un véritable cas d'utilisation, où l'abstraction et l'indirection offertes par la gestion des exceptions sont indispensables. Comprendre une telle utilisation aidera également à comprendre la recommandation attraper tard.

Ce cas d'utilisation est: Programmation contre les abstractions de ressources ...

Oui, la logique métier devrait être programmée contre les abstractions , pas des implémentations concrètes. Niveau supérieur IOC Le code "câblage" instanciera les implémentations concrètes des abstractions de ressources et les transmettra à la logique métier. Rien de nouveau ici. Mais les implémentations concrètes de ces abstractions de ressources peuvent potentiellement générer leurs propres exceptions spécifiques à l'implémentation , n'est-ce pas?

Alors, qui peut gérer ces exceptions spécifiques à l'implémentation? Est-il alors possible de gérer des exceptions spécifiques aux ressources dans la logique métier? Non, ça ne l'est pas. La logique métier est programmée par rapport aux abstractions, ce qui exclut la connaissance de ces détails d'exception spécifiques à l'implémentation.

"Aha!", Vous pourriez dire: "mais c'est pourquoi nous pouvons sous-classer les exceptions et créer des hiérarchies d'exceptions" (consultez Mr. Spring !). Permettez-moi de vous dire que c'est une erreur. Premièrement, chaque livre raisonnable sur OOP dit que l'héritage concret est mauvais, mais en quelque sorte ce composant central de JVM, la gestion des exceptions, est étroitement lié à l'héritage concret. Ironiquement, Joshua Bloch n'aurait pas pu écrire son - Efficace Java book avant qu'il ne puisse acquérir de l'expérience avec une machine virtuelle Java fonctionnelle, n'est-ce pas? Il s'agit davantage d'un livre sur les "leçons apprises" pour la prochaine génération. Deuxièmement, et Plus important encore, si vous attrapez une exception de haut niveau, comment allez-vous la gérer? PatientNeedsImmediateAttentionException: devons-nous lui donner une pilule ou amputer ses jambes!? Que diriez-vous d'une déclaration de changement sur toutes les possibilités Il y a votre polymorphisme, il y a l'abstraction. Vous avez compris.

Alors, qui peut gérer les exceptions spécifiques aux ressources? Ce doit être celui qui connaît les concrétions! Celui qui a instancié la ressource! Le code "câblage" bien sûr! Regarde ça:

Logique métier codée contre les abstractions ... PAS DE GESTION DES ERREURS DE RESSOURCES EN BÉTON!

static interface InputResource {
    String fetchData();
}

static interface OutputResource {
    void writeData(String data);
}

static void doMyBusiness(InputResource in, OutputResource out, int times) {
    for (int i = 0; i < times; i++) {
        System.out.println("fetching data");
        String data = in.fetchData();
        System.out.println("outputting data");
        out.writeData(data);
    }
}

Pendant ce temps quelque part ailleurs les implémentations concrètes ...

static class ConstantInputResource implements InputResource {
    @Override
    public String fetchData() {
        return "Hello World!";
    }
}

static class FailingInputResourceException extends RuntimeException {
    public FailingInputResourceException(String message) {
        super(message);
    }
}

static class FailingInputResource implements InputResource {
    @Override
    public String fetchData() {
        throw new FailingInputResourceException("I am a complete failure!");
    }
}

static class StandardOutputResource implements OutputResource {
    @Override
    public void writeData(String data) {
        System.out.println("DATA: " + data);
    }
}

Et enfin le code de câblage ... Qui gère les exceptions de ressources concrètes? Celui qui les connaît!

static void start() {
    InputResource in1 = new FailingInputResource();
    InputResource in2 = new ConstantInputResource();
    OutputResource out = new StandardOutputResource();

    try {
        ReusableBusinessLogicClass.doMyBusiness(in1, out, 3);
    }
    catch (FailingInputResourceException e)
    {
        System.out.println(e.getMessage());
        System.out.println("retrying...");
        ReusableBusinessLogicClass.doMyBusiness(in2, out, 3);
    }
}

Maintenant supporte avec moi. Le code ci-dessus est simpliste. Vous pourriez dire que vous avez une application d'entreprise/un conteneur Web avec plusieurs étendues de IOC ressources gérées de conteneur, et que vous avez besoin de nouvelles tentatives automatiques et de réinitialisation des sessions ou des ressources d'étendue de demande, etc. La logique de câblage sur le des étendues de niveau inférieur peuvent se voir attribuer des usines abstraites pour créer des ressources, ne connaissant donc pas les implémentations exactes. Seules les étendues de niveau supérieur sauront vraiment quelles exceptions ces ressources de niveau inférieur peuvent lever.

Malheureusement, les exceptions n'autorisent que l'indirection sur la pile d'appels, et différentes étendues avec leurs différentes cardinalités s'exécutent généralement sur plusieurs threads différents. Aucun moyen de communiquer à travers cela avec des exceptions. Nous avons besoin de quelque chose de plus puissant ici. Réponse: passage d'un message asynchrone . Attrapez chaque exception à la racine de l'étendue de niveau inférieur. N'ignorez rien, ne laissez rien passer. Cela fermera et supprimera toutes les ressources créées sur la pile d'appels de la portée actuelle. Ensuite, propagez les messages d'erreur vers les étendues plus haut en utilisant des files/messages de messages dans la routine de gestion des exceptions, jusqu'à ce que vous atteigniez le niveau où les concrétions sont connues. C'est le gars qui sait comment gérer ça.

SUMMA SUMMARUM

Donc, selon mon interprétation attraper tard signifie attraper les exceptions à l'endroit le plus pratique O YOU VOUS NE BRISEZ PLUS D'ABSTRACTION . N'attrapez pas trop tôt! Attrapez les exceptions au niveau de la couche où vous créez les instances concrètes de lancement d'exceptions des ressources abstractions, la couche qui sait les concrétions des abstractions. La couche "câblage".

HTH. Bon codage!

24
Daniel Dinnyes

Pour répondre correctement à cette question, prenons un peu de recul et posons une question encore plus fondamentale.

Pourquoi est-ce que nous avons des exceptions en premier lieu?

Nous lançons des exceptions pour faire savoir à l'appelant de notre méthode que nous ne pouvons pas faire ce qu'on nous a demandé de faire. Le type de l'exception explique pourquoi nous n'avons pas pu faire ce que nous voulions faire.

Jetons un coup d'œil à du code:

double MethodA()
{
    return PropertyA - PropertyB.NestedProperty;
}

Ce code peut évidemment lever une exception de référence null si PropertyB est null. Il y a deux choses que nous pourrions faire dans ce cas pour "corriger" cette situation. Nous pourrions:

  • Créez automatiquement PropertyB si nous ne l'avons pas; ou
  • Laissez l'exception bouillonner jusqu'à la méthode d'appel.

La création de PropertyB ici pourrait être très dangereuse. Quelle est la raison de cette méthode pour créer PropertyB? Cela violerait certainement le principe de la responsabilité unique. Selon toute vraisemblance, si PropertyB n'existe pas ici, cela indique que quelque chose s'est mal passé. La méthode est appelée sur un objet partiellement construit ou PropertyB a été défini sur null de manière incorrecte. En créant PropertyB ici, nous pourrions cacher un bogue beaucoup plus gros qui pourrait nous mordre plus tard, comme un bogue qui provoque une corruption des données.

Si, au lieu de cela, nous laissons la bulle de référence nulle, nous informons le développeur qui a appelé cette méthode, dès que possible, que quelque chose s'est mal passé. Une condition préalable vitale pour appeler cette méthode a été manquée.

Donc en effet, nous lançons tôt car cela sépare beaucoup mieux nos préoccupations. Dès qu'un défaut est survenu, nous en informons les développeurs en amont.

Pourquoi nous "attrapons tard" est une autre histoire. Nous ne voulons pas vraiment attraper tard, nous voulons vraiment attraper dès que nous savons comment gérer le problème correctement. Parfois ce sera quinze couches d'abstraction plus tard et parfois ce sera au moment de la création.

Le fait est que nous voulons intercepter l'exception au niveau de la couche d'abstraction qui nous permet de gérer l'exception au point où nous avons toutes les informations dont nous avons besoin pour gérer correctement l'exception.

10
Stephen

Lancez dès que vous voyez quelque chose qui vaut la peine d'être lancé pour éviter de mettre des objets dans un état invalide. Ce qui signifie que si un pointeur nul a été passé, vous le vérifiez tôt et lancez un NPE avant il a une chance de couler jusqu'au niveau bas.

Attrapez dès que vous savez quoi faire pour corriger l'erreur (ce n'est généralement pas là que vous lancez sinon vous pouvez simplement utiliser un if-else), si un paramètre non valide a été passé, alors la couche qui a fourni le paramètre devrait gérer les conséquences .

6
ratchet freak

Une règle commerciale valide est "si le logiciel de niveau inférieur ne parvient pas à calculer une valeur, alors ..."

Cela ne peut être exprimé qu'au niveau supérieur, sinon le logiciel de niveau inférieur essaie de changer son comportement en fonction de sa propre exactitude, qui ne se terminera que par un nœud.

4
soru

Tout d'abord, les exceptions concernent des situations exceptionnelles. Dans votre exemple, aucun chiffre ne peut être calculé si les données brutes ne sont pas présentes car elles n'ont pas pu être chargées.

D'après mon expérience, il est recommandé d'abstraire les exceptions tout en remontant la pile. Généralement, les points où vous souhaitez effectuer cette opération se produisent chaque fois qu'une exception franchit la frontière entre deux couches.

S'il y a une erreur lors de la collecte de vos données brutes dans la couche de données, lancez une exception pour informer la personne qui a demandé les données. N'essayez pas de contourner ce problème ici. La complexité du code de manipulation peut être très élevée. De plus, la couche de données est uniquement responsable de la demande de données, pas de la gestion des erreurs qui se produisent lors de cette opération. C'est ce que signifiait "jeter tôt".

Dans votre exemple, la couche de capture est la couche de service. Le service lui-même est une nouvelle couche, reposant sur la couche d'accès aux données. Vous voulez donc saisir l'exception. Votre service dispose peut-être d'une infrastructure de basculement et essaie de demander les données à un autre référentiel. Si cela échoue également, enveloppez l'exception dans quelque chose que l'appelant du service comprend (s'il s'agit d'un service Web, cela peut être une erreur SOAP). Définissez l'exception d'origine comme exception interne afin que la dernière les couches peuvent enregistrer exactement ce qui n'a pas fonctionné.

L'erreur de service peut être interceptée par la couche appelant le service (par exemple l'interface utilisateur). Et c'est ce que signifiait "attraper tard". Si vous n'êtes pas en mesure de gérer l'exception dans un calque inférieur, relancez-la. Si la couche la plus haute ne peut pas gérer l'exception, gérez-la! Cela peut inclure la journalisation ou la présentation.

La raison pour laquelle vous devez renvoyer les exceptions (comme décrit ci-dessus en les encapsulant dans des exemptions plus générales) est que l'utilisateur n'est probablement pas en mesure de comprendre qu'il y a eu une erreur car, par exemple, un pointeur pointait vers une mémoire non valide. Et il s'en fiche. Il se soucie seulement que le chiffre ne puisse pas être calculé par le service et ce sont les informations qui devraient lui être affichées.

En allant plus loin, vous pouvez (dans un monde idéal) supprimer complètement le code try/catch de l'interface utilisateur. Utilisez plutôt un gestionnaire d'exceptions global capable de comprendre les exceptions éventuellement levées par les couches inférieures, les écrit dans un journal et les encapsule dans des objets d'erreur qui contiennent des informations significatives (et éventuellement localisées) de l'erreur. Ces objets peuvent facilement être présentés à l'utilisateur sous la forme que vous souhaitez (boîtes de message, notifications, toasts de message, etc.).

2
Aschratt

Le lancement d'exceptions au début est généralement une bonne pratique car vous ne voulez pas que les contrats rompus traversent le code plus que nécessaire. Par exemple, si vous vous attendez à ce qu'un certain paramètre de fonction soit un entier positif, vous devez appliquer cette contrainte au point de l'appel de fonction au lieu d'attendre que cette variable soit utilisée ailleurs dans la pile de code.

Attraper tard je ne peux pas vraiment commenter car j'ai mes propres règles et ça change de projet en projet. La seule chose que j'essaie de faire est de séparer les exceptions en deux groupes. L'un est destiné à un usage interne uniquement et l'autre est destiné à un usage externe uniquement. Les exceptions internes sont interceptées et gérées par mon propre code et les exceptions externes sont censées être gérées par le code qui m'appelle. C'est fondamentalement une forme de rattraper les choses plus tard, mais pas tout à fait parce que cela me donne la flexibilité de dévier de la règle lorsque cela est nécessaire dans le code interne.

1
davidk01