Vérifié vs non contrôlé vs sans exception… Une meilleure pratique de croyances contraires

10

De nombreuses exigences sont nécessaires pour qu'un système transmette et gère correctement les exceptions. Il existe également de nombreuses options pour une langue à choisir pour implémenter le concept.

Conditions d'exceptions (sans ordre particulier):

  1. Documentation : un langage doit avoir un moyen de documenter les exceptions qu'une API peut lancer. Idéalement, ce support de documentation devrait être utilisable par la machine pour permettre aux compilateurs et aux IDE de fournir une assistance au programmeur.

  2. Transmettre des situations exceptionnelles : celle-ci est évidente, pour permettre à une fonction de transmettre des situations qui empêchent la fonctionnalité appelée d'exécuter l'action attendue. À mon avis, il existe trois grandes catégories de telles situations:

    2.1 Bogues dans le code qui rendent certaines données invalides.

    2.2 Problèmes de configuration ou d'autres ressources externes.

    2.3 Ressources intrinsèquement peu fiables (réseau, systèmes de fichiers, bases de données, utilisateurs finaux, etc.). Ce sont un peu un cas de coin, car leur nature peu fiable devrait nous faire attendre leurs échecs sporadiques. Dans ce cas, ces situations doivent-elles être considérées comme exceptionnelles?

  3. Fournissez suffisamment d'informations pour que le code le gère : les exceptions doivent fournir suffisamment d'informations à l'appelé pour qu'il puisse réagir et éventuellement gérer la situation. les informations doivent également être suffisantes pour que, lorsqu'elles sont consignées, ces exceptions fournissent suffisamment de contexte à un programmeur pour identifier et isoler les déclarations incriminées et fournir une solution.

  4. Donner confiance au programmeur quant à l'état actuel de l'état d'exécution de son code : les capacités de gestion des exceptions d'un système logiciel doivent être suffisamment présentes pour fournir les garanties nécessaires tout en restant à l'écart du programmeur afin qu'il puisse rester concentré sur la tâche à main.

Pour les couvrir, les méthodes suivantes ont été mises en œuvre dans différentes langues:

  1. Exceptions vérifiées Fournir un excellent moyen de documenter les exceptions, et théoriquement, lorsqu'elles sont implémentées correctement, elles devraient rassurer amplement que tout va bien. Cependant, le coût est tel que beaucoup pensent qu'il est plus productif de simplement contourner soit en avalant des exceptions, soit en les renvoyant en tant qu'exceptions non contrôlées. Lorsqu'elles sont utilisées de manière inappropriée, les exceptions perdent à peu près tout son utilité. De plus, les exceptions vérifiées rendent difficile la création d'une API stable dans le temps. Les implémentations d'un système générique dans un domaine spécifique entraîneront une charge de situation exceptionnelle qui deviendrait difficile à maintenir en utilisant uniquement des exceptions vérifiées.

  2. Exceptions non vérifiées - beaucoup plus polyvalentes que les exceptions vérifiées, elles ne documentent pas correctement les situations exceptionnelles possibles d'une implémentation donnée. Ils s'appuient sur une documentation ad hoc le cas échéant. Cela crée des situations où la nature peu fiable d'un support est masquée par une API qui donne l'apparence de la fiabilité. De plus, lorsqu'elles sont levées, ces exceptions perdent leur sens lorsqu'elles remontent à travers les couches d'abstraction. Étant donné qu'ils sont mal documentés, un programmeur ne peut pas les cibler spécifiquement et doit souvent lancer un réseau beaucoup plus large que nécessaire pour garantir que les systèmes secondaires, en cas de défaillance, n'effondrent pas tout le système. Ce qui nous ramène au problème de déglutition des exceptions vérifiées fournies.

  3. Types de retour multi-états Ici, il s'agit de s'appuyer sur un ensemble disjoint, un tuple ou un autre concept similaire pour renvoyer le résultat attendu ou un objet représentant l'exception. Ici pas de déroulement de pile, pas de coupure du code, tout s'exécute normalement mais la valeur de retour doit être validée pour erreur avant de continuer. Je n'ai pas encore vraiment travaillé avec cela, donc je ne peux pas commenter par l'expérience.Je reconnais que cela résout certains problèmes, les exceptions contournant le flux normal, mais il souffrira à peu près des mêmes problèmes que les exceptions vérifiées, car il est fatigant et constamment "en face".

La question est donc:

Quelle est votre expérience en la matière et quel est, selon vous, le meilleur candidat pour faire un bon système de gestion des exceptions pour une langue?


EDIT: Quelques minutes après avoir écrit cette question, je suis tombé sur ce post , effrayant!

Newtopian
la source
2
"il souffrira des mêmes problèmes que les exceptions vérifiées comme étant ennuyeux et constamment en face de vous": Pas vraiment: avec un support linguistique approprié, il vous suffit de programmer le "chemin du succès", avec la machine linguistique sous-jacente qui se charge de propager les erreurs.
Giorgio
"Un langage devrait avoir un moyen de documenter les exceptions qu'une API peut lancer." - weeeel. En C ++ "nous" avons appris que cela ne fonctionne pas vraiment. Tout ce que vous pouvez vraiment faire est d'indiquer si une API peut lever une exception. (Cela coupe vraiment une longue histoire, mais je pense que regarder l' noexcepthistoire en C ++ peut également fournir de très bonnes informations pour EH en C # et Java.)
Martin Ba

Réponses:

10

Dans les premiers jours du C ++, nous avons découvert que sans une sorte de programmation générique, les langages fortement typés étaient extrêmement lourds. Nous avons également découvert que les exceptions vérifiées et la programmation générique ne fonctionnaient pas bien ensemble, et les exceptions vérifiées étaient essentiellement abandonnées.

Les types de retour multisets sont excellents, mais ne remplacent pas les exceptions. Sans exception, le code est plein de bruit de vérification d'erreur.

L'autre problème avec les exceptions vérifiées est qu'un changement dans les exceptions levées par une fonction de bas niveau force une cascade de changements dans tous les appelants et leurs appelants, etc. La seule façon d'empêcher cela est que chaque niveau de code intercepte les exceptions levées par les niveaux inférieurs et les encapsule dans une nouvelle exception. Encore une fois, vous vous retrouvez avec un code très bruyant.

Kevin Cline
la source
2
Les génériques aident à résoudre toute une classe d'erreurs qui sont principalement dues à une limitation du support du langage au paradigme OO. Cependant, les alternatives semblent être d'avoir du code qui fait principalement la vérification des erreurs ou qui fonctionne en espérant que rien ne se passe mal. Soit vous avez constamment des situations exceptionnelles devant vous, soit vous vivez dans un pays de rêve de lapins blancs moelleux qui deviennent vraiment laids lorsque vous déposez un gros méchant loup au milieu!
Newtopian
3
+1 pour le problème en cascade. Tout système / architecture qui rend le changement difficile ne mène qu'à des correctifs de singe et à des systèmes en désordre, quelle que soit la qualité de leur conception par les auteurs.
Matthieu M.
2
@Newtopian: les modèles font des choses qui ne peuvent pas être faites dans une orientation d'objet stricte, comme fournir une sécurité de type statique pour les conteneurs génériques.
David Thornley
2
J'aimerais voir un système d'exceptions avec un concept de "exceptions vérifiées", mais très différent de Java. Check-ness ne doit pas être un attribut d'un type d' exception , mais plutôt lancer des sites, des sites de capture et des instances d'exception; si une méthode est annoncée comme lançant une exception vérifiée, cela devrait avoir deux effets: (1) la fonction devrait gérer un "lancer" de l'exception vérifiée en faisant quelque chose de spécial au retour (par exemple en définissant le drapeau de report, etc. selon plate-forme exacte) pour laquelle le code appelant devrait être préparé.
supercat
7
"Sans exception, le code est plein de bruit de vérification d'erreur.": Je ne suis pas sûr de cela: dans Haskell, vous pouvez utiliser des monades pour cela et tout le bruit de vérification d'erreur a disparu. Le bruit introduit par les "types de retour multi-états" est plus une limitation du langage de programmation que de la solution en soi.
Giorgio
9

Pendant longtemps les langages OO, l'utilisation d'exceptions a été de facto la norme pour communiquer les erreurs. Mais les langages de programmation fonctionnels offrent la possibilité d'une approche différente, par exemple en utilisant des monades (que je n'ai pas utilisées), ou la "programmation orientée ferroviaire" plus légère, comme décrit par Scott Wlaschin.

C'est vraiment une variante du type de résultat multi-états.

  • Une fonction renvoie soit un succès, soit une erreur. Il ne peut pas retourner les deux (comme c'est le cas avec un tuple).
  • Toutes les erreurs possibles ont été succinctement documentées (au moins en F # avec des types de résultats comme unions discriminées).
  • L'appelant ne peut pas utiliser le résultat sans prendre en compte si le résultat a été un succès ou un échec.

Le type de résultat pourrait être déclaré comme ceci

type Result<'TSuccess,'TFail> =
| Success of 'TSuccess
| Fail of 'TFail

Ainsi, le résultat d'une fonction qui renvoie ce type serait soit un, Successsoit un Failtype. Ça ne peut pas être les deux.

Dans les langages de programmation plus impératifs, ce type de style peut nécessiter une grande quantité de code sur le site de l'appelant. Mais la programmation fonctionnelle vous permet de construire des fonctions de liaison ou des opérateurs pour lier plusieurs fonctions afin que la vérification des erreurs ne prenne pas la moitié du code. Par exemple:

// Create an updateUser function that takes an id, and new state
// as input, and updates an existing user.
let updateUser id input =
    validateInput input
    >>= loadUser id
    >>= updateUser input
    >>= saveUser id
    >>= notifyAboutUserUpdated

La updateUserfonction appelle chacune de ces fonctions successivement et chacune d'entre elles peut échouer. S'ils réussissent tous, le résultat de la dernière fonction appelée est renvoyé. Si l'une des fonctions échoue, le résultat de cette fonction sera le résultat de la updateUserfonction globale . Tout cela est géré par l'opérateur >> = personnalisé.

Dans l'exemple ci-dessus, les types d'erreur peuvent être

type UserValidationErrorType =
| InvalidEmail of string
| MissingFirstName of string
... etc

type DbErrorType =
| RecordNotFound of int
| ConcurrencyError of int

type UpdateUserErrorType =
| InvalidInput of UserValidationErrorType
| DbError of DbErrorType

Si l'appelant de updateUserne gère pas explicitement toutes les erreurs possibles de la fonction, le compilateur émet un avertissement. Vous avez donc tout documenté.

Dans Haskell, il existe une donotation qui peut rendre le code encore plus propre.

Pete
la source
2
Très bonne réponse et références (programmation orientée chemin de fer), +1. Vous voudrez peut-être mentionner la donotation de Haskell , qui rend le code résultant encore plus propre.
Giorgio
1
@Giorgio - Je l'ai fait maintenant, mais je n'ai pas travaillé avec Haskell, seulement F #, donc je ne pouvais pas vraiment écrire beaucoup à ce sujet. Mais vous pouvez ajouter à la réponse si vous le souhaitez.
Pete
Merci, j'ai écrit un petit exemple mais comme il n'était pas assez petit pour être ajouté à votre réponse, j'ai écrit une réponse complète (avec quelques informations supplémentaires).
Giorgio
2
Le Railway Oriented Programmingcomportement est exactement monadique.
Daenyth
5

Je trouve la réponse de Pete très bonne et j'aimerais ajouter quelques considérations et un exemple. Une discussion très intéressante concernant l'utilisation des exceptions par rapport au retour de valeurs d'erreur spéciales peut être trouvée dans Programmation en standard ML, par Robert Harper , à la fin de la section 29.3, page 243, 244.

Le problème est d'implémenter une fonction partielle fretournant une valeur d'un certain type t. Une solution consiste à avoir le type de fonction

f : ... -> t

et lève une exception lorsqu'il n'y a aucun résultat possible. La deuxième solution consiste à implémenter une fonction de type

f : ... -> t option

et retour SOME vsur succès et NONEsur échec.

Voici le texte du livre, avec une petite adaptation faite par moi-même pour rendre le texte plus général (le livre fait référence à un exemple particulier). Le texte modifié est écrit en italique .

Quels sont les compromis entre les deux solutions?

  1. La solution basée sur les types d'options rend explicite dans le type de la fonction fla possibilité d'échec. Cela oblige le programmeur à tester explicitement l'échec en utilisant une analyse de cas sur le résultat de l'appel. Le vérificateur de type s'assurera que l'on ne peut pas utiliser t optionlà où unt est attendu. La solution basée sur des exceptions n'indique pas explicitement une défaillance dans son type. Cependant, le programmeur est néanmoins obligé de gérer l'échec, sinon une erreur d'exception non interceptée serait déclenchée au moment de l'exécution plutôt qu'à la compilation.
  2. La solution basée sur les types d'options nécessite une analyse de cas explicite sur le résultat de chaque appel. Si «la plupart» des résultats aboutissent, le contrôle est redondant et donc excessivement coûteux. La solution basée sur des exceptions est libre de cette surcharge: il est biaisé vers la « normale » cas de retour d' un tcas de ne pas retourner un, plutôt que le « échec » résultat du tout. L'implémentation d'exceptions garantit que l'utilisation d'un gestionnaire est plus efficace qu'une analyse de cas explicite dans le cas où l'échec est rare par rapport au succès.

[cut] En général, si l'efficacité est primordiale, nous avons tendance à préférer les exceptions si l'échec est rare, et à préférer les options si l'échec est relativement courant. Si, d'autre part, la vérification statique est primordiale, alors il est avantageux d'utiliser des options car le vérificateur de type imposera au programmeur de vérifier l'échec, plutôt que de faire en sorte que l'erreur ne survienne qu'au moment de l'exécution.

Ceci en ce qui concerne le choix entre les exceptions et les types de retour d'options.

En ce qui concerne l'idée que représenter une erreur dans le type de retour conduit à des vérifications d'erreurs réparties dans tout le code: ce n'est pas nécessairement le cas. Voici un petit exemple à Haskell qui illustre cela.

Supposons que nous voulions analyser deux nombres, puis diviser le premier par le second. Il pourrait donc y avoir une erreur lors de l'analyse de chaque nombre, ou lors de la division (division par zéro). Nous devons donc vérifier une erreur après chaque étape.

import Text.Read

parseInt :: String -> Maybe Int
parseInt s = readMaybe s :: Maybe Int

safeDiv :: Int -> Int -> Maybe Int
safeDiv n d = if d /= 0 then Just (n `div` d) else Nothing

toString :: Maybe Int -> String
toString (Just i) = show i
toString Nothing  = "error"

main = do
         -- Get two lines from the terminal.
         nStr <- getLine
         dStr <- getLine

         -- Parse each string and divide.
         let r = do n <- parseInt nStr
                    d <- parseInt dStr
                    safeDiv n d

         -- Print the result.
         putStrLn $ toString r

L'analyse et la division sont effectuées dans le let ...bloc. Notez qu'en utilisant la Maybemonade et la donotation, seul le chemin de réussite est spécifié: la sémantique de la Maybemonade propage implicitement la valeur d'erreur ( Nothing). Pas de frais généraux pour le programmeur.

Giorgio
la source
2
Je pense que dans des cas comme celui-ci où vous souhaitez imprimer une sorte de message d'erreur utile, le Eithertype serait plus approprié. Que faites-vous si vous arrivez Nothingici? Vous obtenez simplement le message "erreur". Pas très utile pour le débogage.
sara
1

Je suis devenu un grand fan des exceptions vérifiées et je voudrais partager ma règle générale sur le moment de les utiliser.

Je suis arrivé à la conclusion qu'il y a essentiellement 2 types d'erreurs que mon code doit traiter. Il y a des erreurs qui peuvent être testées avant que le code s'exécute et il y a des erreurs qui ne sont pas testables avant que le code s'exécute. Un exemple simple pour une erreur qui peut être testée avant l'exécution du code dans une NullPointerException.

//... bad code below.  the runnable variable
// tries to call the run() method before the variable
// is instantiated.  Running the code below will cause
// a NullPointerException.
Runnable runnable = null;
runnable.run();

Un simple test aurait pu éviter l'erreur comme ...

Runnable runnable = null;
...
if (runnable != null)
{   runnable.run(); }

Il y a des moments dans l'informatique où vous pouvez exécuter un ou plusieurs tests avant d'exécuter le code pour vous assurer que vous êtes en sécurité ET VOUS OBTIENDREZ TOUJOURS UNE EXCEPTION. Par exemple, vous pouvez tester un système de fichiers pour vous assurer qu'il y a suffisamment d'espace disque sur le disque dur avant d'écrire vos données sur le lecteur. Dans un système d'exploitation multiprocesseur, comme ceux utilisés aujourd'hui, votre processus pourrait tester l'espace disque et le système de fichiers renverra une valeur indiquant qu'il y a suffisamment d'espace, puis un changement de contexte vers un autre processus pourrait écrire les octets restants disponibles pour le système d'exploitation. système. Lorsque le contexte du système d'exploitation revient à votre processus en cours où vous écrivez votre contenu sur le disque, une exception se produit simplement parce qu'il n'y a pas assez d'espace disque sur le système de fichiers.

Je considère le scénario ci-dessus comme un cas parfait pour une exception vérifiée. C'est une exception dans le code qui vous oblige à faire face à quelque chose de mauvais même si votre code peut être parfaitement écrit. Si vous choisissez de faire de mauvaises choses comme «avaler l'exception», vous êtes le mauvais programmeur. Soit dit en passant, j'ai trouvé des cas où il est raisonnable d'avaler l'exception, mais veuillez laisser un commentaire dans le code expliquant pourquoi l'exception a été avalée. Le mécanisme de gestion des exceptions n'est pas à blâmer. Je plaisante souvent en disant que je préférerais que mon stimulateur cardiaque soit écrit avec un langage qui a des exceptions vérifiées.

Il y a des moments où il devient difficile de décider si le code est testable ou non. Par exemple, si vous écrivez un interpréteur et qu'une SyntaxException est levée lorsque le code ne s'exécute pas pour une raison syntaxique, la SyntaxException doit-elle être une exception vérifiée ou (en Java) une RuntimeException? Je répondrais que si l'interpréteur vérifie la syntaxe du code avant que le code ne soit exécuté, l'exception devrait être une RuntimeException. Si l'interpréteur exécute simplement le code «à chaud» et frappe simplement une erreur de syntaxe, je dirais que l'exception devrait être une exception vérifiée.

Je dois admettre que je ne suis pas toujours heureux de devoir attraper ou lancer une exception vérifiée car il y a du temps où je ne sais pas quoi faire. Les exceptions vérifiées sont un moyen de forcer un programmeur à être conscient du problème potentiel qui peut se produire. L'une des raisons pour lesquelles je programme en Java est qu'il contient des exceptions vérifiées.

James Moliere
la source
1
Je préférerais que mon stimulateur cardiaque soit écrit dans un langage qui n'a pas d'exceptions du tout, et toutes les lignes de code gèrent les erreurs via les codes retour. Lorsque vous lâchez une exception, vous dites que "tout s'est mal passé" et que le seul moyen sûr de continuer le traitement est d'arrêter et de redémarrer. Un programme qui se retrouve si facilement dans un état invalide n'est pas quelque chose que vous voulez pour les logiciels critiques (et Java interdit explicitement son utilisation pour les logiciels critiques dans le CLUF)
gbjbaanb
Utiliser l'exception et ne pas les vérifier vs utiliser le code retour et ne pas les vérifier à la fin, tout cela entraîne le même arrêt cardiaque.
Newtopian du
-1

Je suis actuellement au milieu d'un projet / API basé sur la POO plutôt grand et j'ai utilisé cette disposition des exceptions. Mais tout dépend vraiment de la profondeur à laquelle vous voulez aller avec la gestion des exceptions et autres.

ExpectedException
- AuthorisedException
- EmptySetException
- NoRemainingException
- NoRowsException
- NotFoundException
- ValidationException

Exception inattendue
- ConnectivityException
- EnvironmentException
- ProgrammerException
- SQLException

EXEMPLE

   $valid_types = array('mysql', 'oracle', 'sqlite');
       if (!in_array($type, $valid_types)) {
           throw new ecProgrammerException(
        'The database type specified, %1$s, is invalid. Must be one of: %2$s.',
    $type,
    join(', ', $valid_types)
    );
}
MattyD
la source
11
Si l'exception est attendue, ce n'est pas vraiment une exception. "NoRowsException"? Cela ressemble à un flux de contrôle pour moi, et donc une mauvaise utilisation d'une exception.
quentin-star
1
@qes: Il est logique de lever une exception chaque fois qu'une fonction n'est pas en mesure de calculer une valeur, par exemple double Math.sqrt (double v) ou User findUser (long id). Cela donne à l'appelant la liberté de détecter et de gérer les erreurs lorsque cela est pratique, au lieu de vérifier après chaque appel.
Kevin Cline
1
Attendu = flux de contrôle = anti-modèle d'exception. L'exception ne doit pas être utilisée pour le flux de contrôle. Si l'on s'attend à ce qu'il produise une erreur pour une entrée spécifique, il suffit de lui transmettre une partie de la valeur de retour. Nous avons donc NANou NULL.
Eonil
1
@Eonil ... ou Option <T>
Maarten Bodewes