Le problème:
Depuis longtemps, je m'inquiète du exceptions
mécanisme, car je pense qu'il ne résout pas vraiment ce qu'il devrait.
RÉCLAMATION: Il y a de longs débats à l'extérieur sur ce sujet, et la plupart d'entre eux ont du mal à comparer exceptions
vs renvoyer un code d'erreur. Ce n'est définitivement pas le sujet ici.
Essayer de définir une erreur, je suis d'accord avec CppCoreGuidelines, de Bjarne Stroustrup & Herb Sutter
Une erreur signifie que la fonction ne peut pas atteindre son objectif annoncé
RÉCLAMATION: Le exception
mécanisme est un langage sémantique pour gérer les erreurs.
RÉCLAMATION: Pour moi, il n'y a "aucune excuse" à une fonction pour ne pas accomplir une tâche: Soit nous avons mal défini les conditions pré / post afin que la fonction ne puisse pas garantir des résultats, soit un cas exceptionnel spécifique n'est pas considéré comme suffisamment important pour passer du temps à développer une solution. Considérant que, l'OMI, la différence entre le code normal et la gestion du code d'erreur est (avant la mise en œuvre) une ligne très subjective.
RÉCLAMATION: L'utilisation d'exceptions pour indiquer quand une condition pré ou post n'est pas conservée est un autre objectif du exception
mécanisme, principalement à des fins de débogage. Je ne cible pas cette utilisation d' exceptions
ici.
Dans de nombreux livres, tutoriels et autres sources, ils ont tendance à montrer que la gestion des erreurs est une science assez objective, qui est résolue exceptions
et que vous avez juste besoin d' catch
eux pour avoir un logiciel robuste, capable de récupérer de n'importe quelle situation. Mais mes quelques années en tant que développeur me font voir le problème d'une approche différente:
- Les programmeurs ont tendance à simplifier leur tâche en lançant des exceptions lorsque le cas spécifique semble trop rare pour être mis en œuvre avec soin. Les cas typiques sont les suivants: problèmes de mémoire insuffisante, problèmes de saturation de disque, problèmes de fichiers corrompus, etc. Cela peut être suffisant, mais n'est pas toujours décidé au niveau architectural.
- Les programmeurs ont tendance à ne pas lire attentivement la documentation sur les exceptions dans les bibliothèques, et ne savent généralement pas à quel moment et quand une fonction est lancée. De plus, même quand ils le savent, ils ne les gèrent pas vraiment.
- Les programmeurs ont tendance à ne pas détecter les exceptions assez tôt, et lorsqu'ils le font, c'est surtout pour se connecter et lancer plus loin. (se référer au premier point).
Cela a deux conséquences:
- Les erreurs qui se produisent fréquemment sont détectées tôt dans le développement et déboguées (ce qui est bien).
- De rares exceptions ne sont pas gérées et font planter le système (avec un joli message de log) au domicile de l'utilisateur. Parfois, l'erreur est signalée, ou même pas.
Considérant que l'OMI, l'objectif principal d'un mécanisme d'erreur devrait être:
- Rendre visible dans le code où certains cas spécifiques ne sont pas gérés.
- Communiquez le runtime du problème au code associé (au moins à l'appelant) lorsque cette situation se produit.
- Fournit des mécanismes de récupération
L' exception
IMO est le principal défaut de la sémantique en tant que mécanisme de gestion des erreurs: il est facile de voir où se throw
trouve a dans le code source, mais il n'est absolument pas évident de savoir si une fonction spécifique pourrait se lancer en regardant la déclaration. Cela apporte tout le problème que j'ai présenté ci-dessus.
Le langage n'applique pas et ne vérifie pas le code d'erreur aussi strictement qu'il le fait pour d'autres aspects du langage (par exemple, des types de variables forts)
Un essai de solution
Dans le but d'améliorer cela, j'ai développé un système de gestion des erreurs très simple, qui essaie de mettre la gestion des erreurs au même niveau d'importance que le code normal.
L'idée est:
- Chaque fonction (pertinente) reçoit une référence à un
success
objet très léger et peut lui attribuer un état d'erreur au cas où. L'objet est très léger jusqu'à ce qu'une erreur de texte soit enregistrée. - Une fonction est encouragée à ignorer sa tâche si l'objet fourni contient déjà une erreur.
- Une erreur ne doit jamais être annulée.
Le design complet prend évidemment en considération chaque aspect (environ 10 pages), ainsi que la façon de l'appliquer à la POO.
Exemple de Success
classe:
class Success
{
public:
enum SuccessStatus
{
ok = 0, // All is fine
error = 1, // Any error has been reached
uninitialized = 2, // Initialization is required
finished = 3, // This object already performed its task and is not useful anymore
unimplemented = 4, // This feature is not implemented already
};
Success(){}
Success( const Success& v);
virtual ~Success() = default;
virtual Success& operator= (const Success& v);
// Comparators
virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}
// Retrieve if the status is not "ok"
virtual bool operator!() const { return status!=ok;}
// Retrieve if the status is "ok"
operator bool() const { return status==ok;}
// Set a new status
virtual Success& set( SuccessStatus status, std::string msg="");
virtual void reset();
virtual std::string toString() const{ return stateStr;}
virtual SuccessStatus getStatus() const { return status; }
virtual operator SuccessStatus() const { return status; }
private:
std::string stateStr;
SuccessStatus status = Success::ok;
};
Usage:
double mySqrt( Success& s, double v)
{
double result = 0.0;
if (!s) ; // do nothing
else if (v<0.0) s.set(Error, "Square root require non-negative input.");
else result = std::sqrt(v);
return result;
}
Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;
J'ai utilisé cela dans beaucoup de mon (propre) code et cela oblige le programmeur (moi) à réfléchir davantage aux cas exceptionnels possibles et à la façon de les résoudre (bien). Cependant, il a une courbe d'apprentissage et ne s'intègre pas bien avec le code qui l'utilise maintenant.
La question
J'aimerais mieux comprendre les implications de l'utilisation d'un tel paradigme dans un projet:
- La prémisse du problème est-elle correcte? ou ai-je raté quelque chose de pertinent?
- La solution est-elle une bonne idée architecturale? ou le prix est trop élevé?
MODIFIER:
Comparaison entre les méthodes:
//Exceptions:
// Incorrect
File f = open("text.txt"); // Could throw but nothing tell it! Will crash
save(f);
// Correct
File f;
try
{
f = open("text.txt");
save(f);
}
catch( ... )
{
// do something
}
//Error code (mixed):
// Incorrect
File f = open("text.txt"); //Nothing tell you it may fail! Will crash
save(f);
// Correct
File f = open("text.txt");
if (f) save(f);
//Error code (pure);
// Incorrect
File f;
open(f, "text.txt"); //Easy to forget the return value! will crash
save(f);
//Correct
File f;
Error er = open(f, "text.txt");
if (!er) save(f);
//Success mechanism:
Success s;
File f;
open(s, "text.txt");
save(s, f); //s cannot be avoided, will never crash.
if (s) ... //optional. If you created s, you probably don't forget it.
la source
Réponses:
La gestion des erreurs est peut-être la partie la plus difficile d'un programme.
En général, il est facile de réaliser qu'il existe une condition d'erreur; cependant, il est très difficile de le signaler d'une manière qui ne peut être contournée et de le manipuler correctement (voir les niveaux de sécurité exceptionnels d'Abrahams ).
En C, la signalisation des erreurs se fait par un code retour, qui est isomorphe à votre solution.
Le C ++ a introduit des exceptions en raison de l' insuffisance d'une telle approche; à savoir, cela ne fonctionne que si les appelants se souviennent de vérifier si une erreur s'est produite ou non et échoue horriblement sinon. Chaque fois que vous vous dites "c'est OK tant que chaque fois ..." vous avez un problème; les humains ne sont pas méticuleux, même lorsqu'ils s'en soucient.
Le problème, cependant, est que les exceptions ont leurs propres problèmes. À savoir, flux de contrôle invisible / caché. Cela était prévu: masquer le cas d'erreur afin que la logique du code ne soit pas obscurcie par le passe-partout de gestion des erreurs. Cela rend le "chemin heureux" beaucoup plus clair (et rapide!), Au prix de rendre les chemins d'erreur presque impénétrables.
Je trouve intéressant de voir comment d'autres langues abordent le problème:
C ++ avait l'habitude d'avoir une certaine forme d'exceptions vérifiées, vous avez peut-être remarqué qu'il a été déprécié et simplifié vers un élément de base à la
noexcept(<bool>)
place: soit une fonction est déclarée comme pouvant lever, soit elle est déclarée comme jamais. Les exceptions vérifiées sont quelque peu problématiques en ce qu'elles manquent d'extensibilité, ce qui peut entraîner des mappages / imbrications maladroits. Et les hiérarchies d'exceptions alambiquées (l'un des principaux cas d'utilisation de l'héritage virtuel est les exceptions ...).En revanche, Go et Rust adoptent l'approche qui:
Cette dernière est assez évidente en ce que (1) ils nomment leurs exceptions paniques et (2) il n'y a pas ici de hiérarchie de type / clause compliquée. Le langage n'offre pas la possibilité d'inspecter le contenu d'une "panique": pas de hiérarchie de types, pas de contenu défini par l'utilisateur, juste un "oups, les choses se sont tellement mal passées qu'il n'y a pas de récupération possible".
Cela encourage efficacement les utilisateurs à utiliser une gestion des erreurs appropriée, tout en laissant un moyen facile de renflouer dans des situations exceptionnelles (telles que: "attendez, je n'ai pas encore implémenté cela!").
Bien sûr, l'approche Go ressemble malheureusement beaucoup à la vôtre en ce que vous pouvez facilement oublier de vérifier l'erreur ...
... l'approche Rust est cependant principalement centrée sur deux types:
Option
, qui est similaire àstd::optional
,Result
, qui est une variante à deux possibilités: Ok et Err.c'est beaucoup plus net car il n'y a aucune possibilité d'utiliser accidentellement un résultat sans avoir vérifié son succès: si vous le faites, le programme panique.
Les langages FP forment leur gestion des erreurs dans des constructions qui peuvent être divisées en trois couches: - Functor - Applicative / Alternative - Monads / Alternative
Jetons un coup d'œil à la
Functor
classe de types de Haskell :Tout d'abord, les classes de caractères sont quelque peu similaires mais pas égales aux interfaces. Les signatures de fonction de Haskell semblent un peu effrayantes à première vue. Mais déchiffrons-les. La fonction
fmap
prend une fonction comme premier paramètre qui est quelque peu similaire àstd::function<a,b>
. La prochaine chose est unm a
. Vous pouvez imaginerm
quelque chose commestd::vector
etm a
quelque chose commestd::vector<a>
. Mais la différence est quem a
cela ne veut pas dire que cela doit être explicitestd:vector
. Cela pourrait doncstd::option
aussi être un . En indiquant au langage que nous avons une instance pour la classeFunctor
de types pour un type spécifique commestd::vector
oustd::option
, nous pouvons utiliser la fonctionfmap
pour ce type. La même chose doit être faite pour les classes de caractèresApplicative
,Alternative
etMonad
ce qui vous permet de faire des calculs avec échec possibles et avec état. LaAlternative
classe de types implémente des abstractions de récupération d'erreur. Par cela, vous pouvez dire quelque chose commea <|> b
signifiant que ce soit un termea
ou un termeb
. Si aucun des deux calculs ne réussit, c'est toujours une erreur.Jetons un coup d'œil au
Maybe
type de Haskell .Cela signifie que lorsque vous attendez un
Maybe a
, vous obtenez soitNothing
ouJust a
. En regardantfmap
d'en haut, une implémentation pourrait ressembler àL'
case ... of
expression est appelée correspondance de motifs et ressemble à ce que l'on appelle dans le monde de la POOvisitor pattern
. Imaginez la ligne aucase m of
furm.apply(...)
et à mesure que les points sont l'instanciation d'une classe implémentant les fonctions de répartition. Les lignes sous l'case ... of
expression sont les fonctions de répartition respectives qui amènent les champs de la classe directement dans la portée par leur nom. Dans laNothing
branche que nous créonsNothing
et dans laJust a
branche nous nommons notre seule valeura
et en créons une autreJust ...
avec la fonction de transformationf
appliquéea
. Lisez aussi:new Just(f(a))
.Cela peut désormais gérer les calculs erronés tout en faisant abstraction des vérifications d'erreur réelles. Il existe des implémentations pour les autres interfaces ce qui rend ce type de calcul très puissant. En fait,
Maybe
c'est l'inspiration pour le type de RustOption
.Je vous y encouragerais plutôt à retravailler votre
Success
classe vers unResult
. Alexandrescu a en fait proposé quelque chose de très proche, appeléexpected<T>
, pour lequel des propositions standard ont été faites .Je m'en tiendrai à la dénomination et à l'API de Rust simplement parce que ... c'est documenté et ça marche. Bien sûr, Rust a un
?
opérateur de suffixe astucieux qui rendrait le code beaucoup plus doux; en C ++, nous utiliserons laTRY
macro et l' expression des instructions de GCC pour l'émuler.Remarque: il
Result
s'agit d'un espace réservé. Une bonne mise en œuvre utiliserait l'encapsulation et aunion
. Il suffit cependant de faire passer le message.Ce qui me permet d'écrire ( voir en action ):
que je trouve vraiment bien:
Success
classe), oublier de vérifier les erreurs entraînera une erreur d'exécution 1 plutôt qu'un comportement aléatoire,concepts
dans la norme. Cela rendrait ce type de programmation beaucoup plus agréable car nous pourrions laisser le choix sur le type d'erreur. Par exemple, avec une implémentation destd::vector
comme résultat, nous pourrions calculer toutes les solutions possibles à la fois. Ou nous pourrions choisir d'améliorer la gestion des erreurs, comme vous l'avez proposé.1 Avec une
Result
implémentation correctement encapsulée ;)Remarque: contrairement à l'exception, ce poids léger
Result
n'a pas de traces, ce qui rend la journalisation moins efficace; il peut être utile d'enregistrer au moins le numéro de fichier / ligne auquel le message d'erreur est généré et d'écrire généralement un message d'erreur riche. Cela peut être aggravé en capturant le fichier / la ligne chaque fois que laTRY
macro est utilisée, en créant essentiellement la trace manuelle ou en utilisant du code et des bibliothèques spécifiques à la plate-forme de manièrelibbacktrace
à répertorier les symboles dans la pile d'appels.Il y a cependant une grosse mise en garde: les bibliothèques C ++ existantes, et même
std
, sont basées sur des exceptions. Ce sera une bataille difficile pour utiliser ce style, car toute API de bibliothèque tierce doit être enveloppée dans un adaptateur ...la source
({...})
s'agit d'une extension gcc, mais même ainsi, ne devrait-il pas en être ainsiif (!result.ok) return result;
? Votre état apparaît à l'envers et vous faites une copie inutile de l'erreur.({...})
c'est l' expression des déclarations de gcc .std::variant
pour implémenter leResult
si vous utilisez C ++ 17. De plus, pour obtenir un avertissement si vous ignorez une erreur, utilisez[[nodiscard]]
std::variant
ou non est quelque peu une question de goût étant donné les compromis autour de la gestion des exceptions.[[nodiscard]]
est en effet une pure victoire.les exceptions sont un mécanisme de flux de contrôle. La motivation de ce mécanisme de contrôle de flux était de séparer spécifiquement la gestion des erreurs du code de non-gestion des erreurs, dans le cas commun où la gestion des erreurs est très répétitive et n'a que peu de pertinence pour la partie principale de la logique.
Considérez: j'essaie de créer un fichier. Le périphérique de stockage est plein.
Maintenant, ce n'est pas un échec à définir mes conditions préalables: vous ne pouvez pas utiliser "il doit y avoir suffisamment de stockage" comme condition préalable en général, car le stockage partagé est soumis à des conditions de concurrence qui rendent cela impossible à satisfaire.
Alors, mon programme devrait-il libérer de l'espace et continuer avec succès, sinon je suis juste trop paresseux pour "développer une solution"? Cela semble franchement absurde. La "solution" pour gérer le stockage partagé est en dehors de la portée de mon programme , et permettre à mon programme d'échouer avec élégance et d'être réexécuté une fois que l'utilisateur a libéré de l'espace ou ajouté du stockage, est correct .
Ce que fait votre classe de réussite est d'entrelacer la gestion des erreurs de manière très explicite avec la logique de votre programme. Chaque fonction doit vérifier, avant de s'exécuter, si une erreur s'est déjà produite, ce qui signifie qu'elle ne devrait rien faire. Chaque fonction de bibliothèque doit être enveloppée dans une autre fonction, avec un argument de plus (et, espérons-le, une transmission parfaite), qui fait exactement la même chose.
Notez également que votre
mySqrt
fonction doit renvoyer une valeur même si elle a échoué (ou si une fonction précédente avait échoué). Donc, soit vous retournez une valeur magique (commeNaN
), soit vous injectez une valeur indéterminée dans votre programme et vous espérez que rien ne l'utilise sans vérifier l'état de réussite que vous avez appliqué à votre exécution.Pour la justesse - et les performances - il est préférable de laisser le contrôle hors de portée une fois que vous ne pouvez pas progresser. Les exceptions et la vérification d'erreur explicite de style C avec retour anticipé accomplissent toutes les deux cela.
À titre de comparaison, un exemple de votre idée qui fonctionne vraiment est la monade d'erreur dans Haskell. L'avantage par rapport à votre système est que vous écrivez la majeure partie de votre logique normalement, puis l'enveloppez dans la monade qui se charge d'arrêter l'évaluation lorsqu'une étape échoue. De cette façon, le seul code touchant directement le système de gestion des erreurs est le code qui peut échouer (lancer une erreur) et le code qui doit faire face à l'échec (intercepter une exception).
Je ne suis pas sûr que le style monade et l'évaluation paresseuse se traduisent bien en C ++.
la source
and allowing my program to fail gracefully, and be re-run
qu'il vient de perdre 2 heures de travail:std::exception
au niveau supérieur de l'opération logique, dites à l'utilisateur «X a échoué en raison d'ex.what ()» et proposez de réessayer l'opération entière quand et si elle est prête.showing the Save dialog again along with an error message and allowing the user to specify an alternative location to try
. Il s'agit d'une gestion gracieuse d'un problème qui ne peut généralement pas être effectué à partir du code qui détecte que le premier emplacement de stockage est plein.Votre approche apporte de gros problèmes dans votre code source:
il s'appuie sur le code client en se souvenant toujours de vérifier la valeur de
s
. Ceci est courant avec l' utilisation des codes retour pour l' approche de gestion des erreurs , et l'une des raisons pour lesquelles des exceptions ont été introduites dans le langage: avec les exceptions, si vous échouez, vous n'échouez pas en silence.plus vous écrivez de code avec cette approche, plus vous devrez ajouter de code passe-partout pour la gestion des erreurs (votre code n'est plus minimaliste) et votre effort de maintenance augmente.
Les solutions à ces problèmes doivent être abordées au niveau du responsable technique ou au niveau de l'équipe:
Si vous vous retrouvez à gérer chaque type d'exception qui peut être levé, tout le temps, alors la conception n'est pas bonne; Les erreurs traitées doivent être décidées en fonction des spécifications du projet, et non en fonction de ce que les développeurs ont envie de mettre en œuvre.
Adresse en mettant en place des tests automatisés, en séparant la spécification des tests unitaires et la mise en œuvre (faire faire deux personnes différentes).
Vous ne réglerez pas cela en écrivant plus de code. Je pense que votre meilleur pari est des revues de code méticuleusement appliquées.
La gestion correcte des erreurs est difficile, mais moins fastidieuse avec des exceptions qu'avec des valeurs de retour (qu'elles soient réellement retournées ou passées en tant qu'arguments d'E / S).
La partie la plus délicate de la gestion des erreurs n'est pas la façon dont vous recevez l'erreur, mais comment vous assurer que votre application conserve un état cohérent en présence d'erreurs.
Pour y remédier, une plus grande attention doit être accordée à l'identification et à l'exécution dans des conditions d'erreur (davantage de tests, davantage de tests unitaires / d'intégration, etc.).
la source