Pourquoi utiliser l'exception Over (checked)?

17

Il n'y a pas si longtemps, j'ai commencé à utiliser Scala au lieu de Java. Une partie du processus de «conversion» entre les langues pour moi a été d'apprendre à utiliser Eithers au lieu de (coché) Exceptions. J'ai codé de cette façon pendant un certain temps, mais récemment, j'ai commencé à me demander si c'était vraiment une meilleure façon de procéder.

Un avantage majeur Eithera plus de Exceptionmeilleures performances; un Exceptionbesoin de construire une trace de pile et est lancé. Pour autant que je comprends, cependant, lancer la Exceptionn'est pas la partie exigeante, mais construire la pile-trace l'est.

Mais alors, on peut toujours construire / hériter de Exceptions scala.util.control.NoStackTrace, et plus encore, je vois beaucoup de cas où le côté gauche d'un Eitherest en fait un Exception(renoncer à l'augmentation des performances).

Un autre avantage Eithera la sécurité du compilateur; le compilateur Scala ne se plaindra pas des Exceptions non gérés (contrairement au compilateur Java). Mais si je ne me trompe pas, cette décision est motivée par le même raisonnement qui est discuté dans ce sujet, alors ...

En termes de syntaxe, je pense que Exception-style est beaucoup plus clair. Examinez les blocs de code suivants (les deux réalisant les mêmes fonctionnalités):

Either style:

def compute(): Either[String, Int] = {

    val aEither: Either[String, String] = if (someCondition) Right("good") else Left("bad")

    val bEithers: Iterable[Either[String, Int]] = someSeq.map {
        item => if (someCondition(item)) Right(item.toInt) else Left("bad")
    }

    for {
        a <- aEither.right
        bs <- reduce(bEithers).right
        ignore <- validate(bs).right
    } yield compute(a, bs)
}

def reduce[A,B](eithers: Iterable[Either[A,B]]): Either[A, Iterable[B]] = ??? // utility code

def validate(bs: Iterable[Int]): Either[String, Unit] = if (bs.sum > 22) Left("bad") else Right()

def compute(a: String, bs: Iterable[Int]): Int = ???

Exception style:

@throws(classOf[ComputationException])
def compute(): Int = {

    val a = if (someCondition) "good" else throw new ComputationException("bad")
    val bs = someSeq.map {
        item => if (someCondition(item)) item.toInt else throw new ComputationException("bad")
    }

    if (bs.sum > 22) throw new ComputationException("bad")

    compute(a, bs)
}

def compute(a: String, bs: Iterable[Int]): Int = ???

Ce dernier me semble beaucoup plus propre, et le code gérant l'échec (soit la correspondance de modèle sur Eitherou try-catch) est assez clair dans les deux cas.

Donc ma question est - pourquoi utiliser Eitherplus (vérifié)Exception ?

Mise à jour

Après avoir lu les réponses, je me suis rendu compte que je n'avais peut-être pas présenté le cœur de mon dilemme. Je ne m'inquiète pas de l'absence de la try-catch; on peut soit "attraper" un Exceptionavec Try, soit utiliser catchpour envelopper l'exception avecLeft .

Mon principal problème avec Either/Try survient lorsque j'écris du code qui peut échouer à de nombreux points en cours de route; dans ces scénarios, lorsque je rencontre un échec, je dois propager cet échec dans tout mon code, rendant ainsi le code beaucoup plus lourd (comme illustré dans les exemples susmentionnés).

Il existe en fait une autre façon de casser le code sans Exceptions en utilisant return(qui est en fait un autre "tabou" dans Scala). Le code serait encore plus clair que l' Eitherapproche, et tout en étant un peu moins propre que le Exceptionstyle, il n'y aurait pas à craindre que l' Exceptionart.

def compute(): Either[String, Int] = {

  val a = if (someCondition) "good" else return Left("bad")

  val bs: Iterable[Int] = someSeq.map {
    item => if (someCondition(item)) item.toInt else return Left("bad")
  }

  if (bs.sum > 22) return Left("bad")

  val c = computeC(bs).rightOrReturn(return _)

  Right(computeAll(a, bs, c))
}

def computeC(bs: Iterable[Int]): Either[String, Int] = ???

def computeAll(a: String, bs: Iterable[Int], c: Int): Int = ???

implicit class ConvertEither[L, R](either: Either[L, R]) {

  def rightOrReturn(f: (Left[L, R]) => R): R = either match {
    case Right(r) => r
    case Left(l) => f(Left(l))
  }
}

Fondamentalement, les return Leftremplacements throw new Exceptionet la méthode implicite sur l'un ou l'autre rightOrReturnsont un supplément pour la propagation automatique des exceptions dans la pile.

Eyal Roth
la source
Lire ceci: mauricio.github.io/2014/02/17/…
Robert Harvey
@RobertHarvey On dirait que la plupart des articles en parlent Try. La partie concernant Eithervs Exceptionindique simplement que Eithers devrait être utilisé lorsque l'autre cas de la méthode est "non exceptionnel". Tout d'abord, c'est une définition très, très vague à mon humble avis. Deuxièmement, cela vaut-il vraiment la peine de syntaxe? Je veux dire, cela ne me dérangerait vraiment pas d'utiliser Eithers s'il n'y avait pas la surcharge de syntaxe qu'ils présentent.
Eyal Roth
Eitherressemble à une monade pour moi. Utilisez-le lorsque vous avez besoin des avantages de composition fonctionnelle qu'offrent les monades. Ou peut - être pas .
Robert Harvey
2
@RobertHarvey: Techniquement parlant, Eitheren soi n'est pas une monade. La projection vers le côté gauche ou le côté droit est une monade, mais Eitherne l'est pas en soi. Vous pouvez en faire une monade, en la "biaisant" du côté gauche ou du côté droit, cependant. Cependant, alors vous donnez une certaine sémantique des deux côtés d'un Either. Scala Eitherétait à l'origine impartiale, mais était biaisée assez récemment, de sorte qu'aujourd'hui, c'est en fait une monade, mais la "monadness" n'est pas une propriété inhérente Eithermais plutôt le résultat de son biais.
Jörg W Mittag
@ JörgWMittag: Eh bien, bien. Cela signifie que vous pouvez l'utiliser pour toute sa bonté monadique, et ne pas être particulièrement dérangé par la netteté du code résultant (bien que je soupçonne que le code de continuation de style fonctionnel serait en fait plus propre que la version d'exception).
Robert Harvey

Réponses:

13

Si vous utilisez uniquement un bloc Eitherexactement comme un impératif try-catch, bien sûr, cela ressemblera à une syntaxe différente pour faire exactement la même chose. Eitherssont une valeur . Ils n'ont pas les mêmes limitations. Vous pouvez:

  • Arrêtez votre habitude de garder vos blocs d'essai petits et contigus. La partie "capture" deEithers n'a pas besoin d'être juste à côté de la partie "try". Il peut même être partagé dans une fonction commune. Cela facilite beaucoup la séparation des préoccupations.
  • Boutique Eithers dans des structures de données. Vous pouvez les parcourir et consolider les messages d'erreur, trouver le premier qui n'a pas échoué, les évaluer paresseusement ou similaire.
  • Étendez-les et personnalisez-les. Vous n'aimez pas un passe-partout avec lequel vous êtes obligé d'écrire Eithers? Vous pouvez écrire des fonctions d'assistance pour les rendre plus faciles à utiliser pour vos cas d'utilisation particuliers. Contrairement à try-catch, vous n'êtes pas bloqué en permanence avec uniquement l'interface par défaut proposée par les concepteurs de langage.

Remarque pour la plupart des cas d'utilisation, a Trya une interface plus simple, présente la plupart des avantages ci-dessus et répond généralement à vos besoins. Un Optionest encore plus simple si vous ne vous souciez que du succès ou de l'échec et n'avez besoin d'aucune information d'état comme des messages d'erreur sur le cas d'échec.

D'après mon expérience, Eithersne sont pas vraiment préférés à Trysmoins que le Leftsoit autre chose qu'un Exception, peut-être un message d'erreur de validation, et que vous vouliez agréger les Leftvaleurs. Par exemple, vous traitez une entrée et vous souhaitez collecter tous les messages d'erreur, pas seulement une erreur sur le premier. Ces situations ne se présentent pas très souvent dans la pratique, du moins pour moi.

Regardez au-delà du modèle try-catch et vous trouverez Eithersbeaucoup plus agréable à travailler.

Mise à jour: cette question retient toujours l'attention, et je n'avais pas vu la mise à jour de l'OP clarifier la question de syntaxe, alors j'ai pensé en ajouter plus.

À titre d'exemple pour sortir de l'état d'esprit d'exception, voici comment j'écrirais généralement votre exemple de code:

def compute(): Either[String, Int] = {
  Right(someSeq)
    .filterOrElse(someACondition,            "someACondition failed")
    .filterOrElse(_ forall someSeqCondition, "someSeqCondition failed")
    .map(_ map {_.toInt})
    .filterOrElse(_.sum <= 22, "Sum is greater than 22")
    .map(compute("good",_))
}

Ici, vous pouvez voir que la sémantique de filterOrElsecapture parfaitement ce que nous faisons principalement dans cette fonction: vérifier les conditions et associer des messages d'erreur aux échecs donnés. Comme il s'agit d'un cas d'utilisation très courant,filterOrElse trouve dans la bibliothèque standard, mais si ce n'était pas le cas, vous pourriez le créer.

C'est le point où quelqu'un arrive avec un contre-exemple qui ne peut pas être résolu précisément de la même manière que le mien, mais le point que j'essaie de faire est qu'il n'y a pas qu'une seule façon d'utiliser Eithers, contrairement aux exceptions. Il existe des moyens de simplifier le code pour la plupart des situations de gestion d'erreurs, si vous explorez toutes les fonctions disponibles Eitherset que vous êtes ouvert à l'expérimentation.

Karl Bielefeldt
la source
1. Pouvoir séparer le tryet catchest un argument juste, mais cela ne pourrait-il pas être facilement réalisé avec Try? 2. Le stockage des Eithers dans les structures de données pourrait se faire parallèlement au throwsmécanisme; n'est-ce pas simplement une couche supplémentaire en plus de gérer les échecs? 3. Vous pouvez certainement étendre l' Exceptionart. Bien sûr, vous ne pouvez pas changer la try-catchstructure, mais la gestion des échecs est si courante que je veux vraiment que le langage me donne un passe-partout pour cela (que je ne suis pas obligé d'utiliser). C'est comme dire que la compréhension est mauvaise parce que c'est un passe-partout pour les opérations monadiques.
Eyal Roth
Je viens de réaliser que vous avez dit que les Eithers peuvent être étendus, là où clairement ils ne le peuvent pas (étant un sealed traitavec seulement deux implémentations, les deux final). Pourriez-vous préciser ceci? Je suppose que vous ne vouliez pas dire le sens littéral de l'extension.
Eyal Roth
1
Je voulais dire étendu comme dans le mot anglais, pas le mot-clé de programmation. Ajout de fonctionnalités en créant des fonctions pour gérer les modèles courants.
Karl Bielefeldt
8

Les deux sont en fait très différents. EitherLa sémantique est beaucoup plus générale que de représenter simplement des calculs potentiellement défaillants.

Eithervous permet de renvoyer l'un des deux types différents. C'est ça.

Maintenant, l'un de ces deux types peut ou non représenter une erreur et l'autre peut ou non représenter un succès, mais ce n'est qu'un cas d'utilisation possible parmi tant d'autres. Eitherest beaucoup, beaucoup plus général que cela. Vous pourriez tout aussi bien avoir une méthode qui lit les entrées de l'utilisateur qui est autorisé à saisir un nom ou à renvoyer une date Either[String, Date].

Ou, pensez à lambda littéraux dans C♯: ils évaluent soit à un Funcou Expression, à savoir qu'ils évaluent soit à une fonction anonyme ou ils évaluent à un AST. En C♯, cela se produit "par magie", mais si vous vouliez lui donner un type correct, ce serait le cas Either<Func<T1, T2, …, R>, Expression<T1, T2, …, R>>. Cependant, aucune des deux options n'est une erreur, et aucune des deux n'est "normale" ou "exceptionnelle". Ce ne sont que deux types différents qui peuvent être renvoyés par la même fonction (ou dans ce cas, attribués au même littéral). [Remarque: en fait, Funcdoit être divisé en deux autres cas, car C♯ n'a pas de Unittype, et donc quelque chose qui ne renvoie rien ne peut pas être représenté de la même manière que quelque chose qui renvoie quelque chose,Either<Either<Func<T1, T2, …, R>, Action<T1, T2, …>>, Expression<T1, T2, …, R>>

Maintenant, Either peut être utilisé pour représenter un type de réussite et un type d'échec. Et si vous êtes d' accord sur une commande cohérente des deux types, vous pouvez même biais Either préférer l' un des deux types, permettant ainsi de propager des échecs par l' enchaînement monadique. Mais dans ce cas, vous donnez une certaine sémantique à Eitherce qu'elle ne possède pas nécessairement d'elle-même.

Un Eitherqui a l'un de ses deux types fixé pour être un type d'erreur et est biaisé d'un côté pour propager les erreurs, est généralement appelé une Errormonade et serait un meilleur choix pour représenter un calcul potentiellement défaillant. Dans Scala, ce type de type est appelé Try.

Il est beaucoup plus logique de comparer l'utilisation des exceptions avec Tryqu'avec Either.

Donc, c'est la raison # 1 pour laquelle on Eitherpourrait utiliser les exceptions vérifiées: Eitherest beaucoup plus général que les exceptions. Il peut être utilisé dans les cas où les exceptions ne s'appliquent même pas.

Cependant, vous posiez probablement des questions sur le cas restreint où vous utilisez Either(ou plus probablement Try) en remplacement d'exceptions. Dans ce cas, il y a encore quelques avantages, ou plutôt des approches différentes possibles.

Le principal avantage est que vous pouvez différer la gestion de l'erreur. Vous stockez simplement la valeur de retour, sans même regarder si c'est un succès ou une erreur. (Manipulez-le plus tard.) Ou passez-le ailleurs. (Laissez quelqu'un d'autre s'en préoccuper.) Rassemblez-les dans une liste. (Gérez-les tous en même temps.) Vous pouvez utiliser le chaînage monadique pour les propager le long d'un pipeline de traitement.

La sémantique des exceptions est câblée dans la langue. Dans Scala, les exceptions déroulent la pile, par exemple. Si vous les laissez remonter à un gestionnaire d'exceptions, le gestionnaire ne peut pas revenir là où cela s'est produit et le corriger. OTOH, si vous l'attrapez plus bas dans la pile avant de le dérouler et de détruire le contexte nécessaire pour le corriger, alors vous pourriez être dans un composant trop bas pour prendre une décision éclairée sur la façon de procéder.

Ceci est différent en Smalltalk, par exemple, où les exceptions ne sont pas détendre la pile et sont resumable, et vous pouvez attraper le haut d'exception dans la pile (peut - être aussi haut que le débogueur), réparer l'erreur waaaaayyyyy vers le bas dans la empiler et reprendre l'exécution à partir de là. Ou les conditions CommonLisp où élever, attraper et manipuler sont trois choses différentes au lieu de deux (élever et attraper-manipuler) comme avec des exceptions.

L'utilisation d'un type de retour d'erreur réel au lieu d'une exception vous permet de faire des choses similaires, et plus encore, sans avoir à modifier la langue.

Et puis il y a l'éléphant évident dans la pièce: les exceptions sont un effet secondaire. Les effets secondaires sont mauvais. Remarque: je ne dis pas que cela est nécessairement vrai. Mais c'est un point de vue que certaines personnes ont, et dans ce cas, l'avantage d'un type d'erreur sur les exceptions est évident. Donc, si vous voulez faire de la programmation fonctionnelle, vous pouvez utiliser des types d'erreur pour la seule raison qu'ils sont l'approche fonctionnelle. (Notez que Haskell a des exceptions. Notez également que personne n'aime les utiliser.)

Jörg W Mittag
la source
J'adore la réponse, même si je ne vois toujours pas pourquoi devrais-je renoncer à l' Exceptionart. Eithersemble être un excellent outil, mais oui, je pose spécifiquement des questions sur la gestion des échecs, et je préfère utiliser un outil beaucoup plus spécifique pour ma tâche qu'un outil tout-puissant. Le report de la gestion des échecs est un argument équitable, mais on peut simplement utiliser Tryune méthode qui lance un Exceptions'il souhaite ne pas le gérer pour le moment. Ne pas dérouler pile trace affect Either/ Tryainsi? Vous pouvez répéter les mêmes erreurs lors de leur mauvaise propagation; savoir quand gérer un échec n'est pas anodin dans les deux cas.
Eyal Roth du
Et je ne peux pas vraiment discuter avec un raisonnement "purement fonctionnel", n'est-ce pas?
Eyal Roth du
0

La bonne chose à propos des exceptions est que le code d'origine a déjà classé la circonstance exceptionnelle pour vous. La différence entre un IllegalStateExceptionet un BadParameterExceptionest beaucoup plus facile à différencier dans des langages typés plus forts. Si vous essayez d'analyser "bon" et "mauvais", vous ne profitez pas de l'outil extrêmement puissant à votre disposition, à savoir le compilateur Scala. Vos propres extensions Throwablepeuvent inclure autant d'informations que vous le souhaitez, en plus de la chaîne de message textuel.

Il s'agit de la véritable utilité de Tryl'environnement Scala, avec le Throwablerôle d'enveloppe des éléments laissés pour vous faciliter la vie.

BobDalgleish
la source
2
Je ne comprends pas votre point.
Eyal Roth
Eithera des problèmes de style car ce n'est pas une vraie monade (?); l'arbitraire de la leftvaleur s'éloigne de la valeur ajoutée plus élevée lorsque vous enfermez correctement les informations sur les conditions exceptionnelles dans une structure appropriée. Donc, il n'est pas correct de comparer l'utilisation de, Eitherdans un cas, le retour de chaînes dans le côté gauche et try/catchdans l'autre cas, en utilisant correctement typé Throwables. En raison de la valeur de Throwablesfournir des informations, et de la manière concise qui Trygère Throwables, Tryest un meilleur pari que ... Eitheroutry/catch
BobDalgleish
En fait, je ne suis pas préoccupé par le try-catch. Comme dit dans la question, ce dernier me semble beaucoup plus propre, et le code gérant l'échec (soit le filtrage des motifs sur Either soit le try-catch) est assez clair dans les deux cas.
Eyal Roth