web-dev-qa-db-fra.com

Considérations relatives à la gestion des erreurs

Le problème:

Depuis longtemps, je m'inquiète du mécanisme exceptions, 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 à retourner 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

ne erreur signifie que la fonction ne peut pas atteindre son objectif annoncé

RÉCLAMATION: Le mécanisme exception 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, 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 mécanisme exception, principalement à des fins de débogage. Je ne cible pas cette utilisation de 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 avec exceptions et il vous suffit de catch pour avoir un logiciel robuste, capable pour se remettre de toute 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 ni à quel moment 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:

  1. 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).
  2. 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:

  1. Rendre visible dans le code où certains cas spécifiques ne sont pas gérés.
  2. Communiquez le runtime du problème au code associé (au moins à l'appelant) lorsque cette situation se produit.
  3. Fournit des mécanismes de récupération

Le principal défaut de la sémantique exception en tant que mécanisme de gestion des erreurs est IMO: il est facile de voir où se trouve throw dans le code source, mais il n'est absolument pas évident de savoir si une fonction spécifique pourrait jeter 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)

n 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 la classe Success:

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é?

ÉDITER:

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.
31
Adrian Maire

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, le signaler d'une manière qui ne peut être contournée et le gérer de manière appropriée (voir Niveaux de sécurité exceptionnels d'Abrahams ) est vraiment difficile.

En C, la signalisation des erreurs se fait par un code retour, qui est isomorphe à votre solution.

C++ a introduit des exceptions en raison de la à court terme 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:

  • Java a vérifié les exceptions (et les non vérifiées),
  • Go utilise des codes d'erreur/paniques,
  • Rust utilise types de somme /paniques).
  • Langues FP en général.

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 une noexcept(<bool>) de base à la place: soit une fonction est déclarée comme pouvant lancer, 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, allez et Rust adoptez l'approche qui:

  • les erreurs doivent être signalées dans la bande,
  • l'exception doit être utilisée pour des situations vraiment exceptionnelles.

Ce dernier est assez évident dans la mesure où (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é le 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 classe de types Functor de Haskell:

class Functor m where
  fmap :: (a -> b) -> m a -> m b

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 quelque peu similaire à std::function<a,b>. La prochaine chose est un m a. Vous pouvez imaginer m comme quelque chose comme std::vector Et m a Comme quelque chose comme std::vector<a>. Mais la différence est que m a Ne dit pas que cela doit être explicitement std:vector. Il pourrait donc aussi s'agir d'un std::option. En indiquant au langage que nous avons une instance pour la classe de types Functor pour un type spécifique comme std::vector Ou std::option, Nous pouvons utiliser la fonction fmap pour ce type. La même chose doit être faite pour les classes de types Applicative, Alternative et Monad ce qui vous permet de faire des calculs avec états et d'échecs possibles. La classe de types Alternative implémente des abstractions de récupération d'erreur. Par cela, vous pouvez dire quelque chose comme a <|> b Ce qui signifie que c'est soit le terme a soit le terme b. Si aucun des deux calculs ne réussit, c'est toujours une erreur.

Jetons un coup d'œil au type Maybe de Haskell.

data Maybe a
  = Nothing
  | Just a

Cela signifie que lorsque vous vous attendez à un Maybe a, Vous obtenez Nothing ou Just a. En regardant fmap d'en haut, une implémentation pourrait ressembler à

fmap f m = case m of
  Nothing -> Nothing
  Just a -> Just (f a)

L'expression case ... of Est appelée correspondance de modèle et ressemble à ce qui est connu dans le monde OOP comme visitor pattern. Imaginez la ligne case m of Comme m.apply(...) et les points sont l'instanciation d'une classe implémentant les fonctions de répartition. Les lignes sous l'expression case ... of sont les fonctions de répartition respectives qui portent les champs de la classe directement dans la portée par leur nom. Dans le Nothing branche que nous créons Nothing et dans la branche Just a Nous nommons notre seule valeur a et créons une autre Just ... Avec la fonction de transformation f appliqué à a. Lisez-le comme: 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 est l'inspiration pour le Option- Type de Rust.


Je vous y encouragerais à retravailler votre classe Success vers une Result à la place. Alexandrescu a en fait proposé quelque chose de très proche, appelé expected<T>, Pour lequel des propositions standard ont été faites .

Je vais m'en tenir à Rust nommage et API simplement parce que ... c'est documenté et fonctionne. Bien sûr, Rust a un astucieux ? opérateur de suffixe qui rendrait le code beaucoup plus doux; en C++, nous utiliserons la macro TRY et le GCC expression des instructions pour l'émuler.

template <typename E>
struct Error {
    Error(E e): error(std::move(e)) {}

    E error;
};

template <typename E>
Error<E> error(E e) { return Error<E>(std::move(e)); }

template <typename T, typename E>
struct [[nodiscard]] Result {
    template <typename U>
    Result(U u): ok(true), data(std::move(u)), error() {}

    template <typename F>
    Result(Error<F> f): ok(false), data(), error(std::move(f.error)) {}

    template <typename U, typename F>
    Result(Result<U, F> other):
        ok(other.ok), data(std::move(other.data)),  error(std::move(other.error)) {}

    bool ok = false;
    T data;
    E error;
};

#define TRY(Expr_) \
    ({ auto result = (Expr_); \
       if (!result.ok) { return result; } \
       std::move(result.data); })

Remarque: cette Result est un espace réservé. Une implémentation correcte utiliserait l'encapsulation et un union. Il suffit cependant de faire passer le message.

Ce qui me permet d'écrire ( voir en action ):

Result<double, std::string> sqrt(double x) {
    if (x < 0) {
        return error("sqrt does not accept negative numbers");
    }
    return x;
}

Result<double, std::string> double_sqrt(double x) {
    auto y = TRY(sqrt(x));
    return sqrt(y);
}

que je trouve vraiment soigné:

  • contrairement à l'utilisation de codes d'erreur (ou de votre classe Success), oublier de vérifier les erreurs entraînera une erreur d'exécution1 plutôt qu'un comportement aléatoire,
  • contrairement à l'utilisation des exceptions, il est évident sur le site d'appel quelles fonctions peuvent échouer donc il n'y a pas de surprise.
  • avec la norme C++ - 2X, nous pouvons obtenir 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 de std::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 implémentation Result correctement encapsulée;)


Remarque: contrairement à l'exception, ce Result léger 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 la macro TRY est utilisée, créant essentiellement la trace manuelle, ou en utilisant du code spécifique à la plate-forme et des bibliothèques telles que libbacktrace pour 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 ...

32
Matthieu M.

CLAIM: Le mécanisme d'exception est une langue sémantique pour gérer les erreurs

les exceptions sont un mécanisme de flux de contrôle. La motivation de ce mécanisme de contrôle de flux était précisément de séparer 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 porte peu de pertinence pour la partie principale de la logique.

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é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 permet à mon programme d'échouer gracieusement et d'être réexécuté une fois que l'utilisateur a soit libéré de l'espace, soit ajouté du stockage, est bien .


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 fonction mySqrt doit retourner une valeur même si elle a échoué (ou qu'une fonction précédente avait échoué). Donc, soit vous retournez une valeur magique (comme NaN), soit vous injectez une valeur indéterminée dans votre programme et en espérant que rien n'utilise cela sans vérifier l'état de réussite que vous avez traversé votre exécution.

Pour la justesse - et les performances - il est beaucoup mieux de passer le contrôle hors de portée une fois que vous ne pouvez faire aucun progrès. 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 le Error monad 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++.

46
Useless

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é?

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'approche tiliser des codes retour pour la gestion des erreurs, et l'une des raisons pour lesquelles des exceptions ont été introduites dans le langage: avec des 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.

Mais mes quelques années en tant que développeur me font voir le problème d'une approche différente:

Les solutions à ces problèmes doivent être abordées au niveau du responsable technique ou au niveau de l'équipe:

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.

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 (demandez à deux personnes différentes de le faire).

Les programmeurs ont tendance à ne pas lire attentivement la documentation [...] De plus, même lorsqu'ils savent, ils ne les gèrent pas vraiment.

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.

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).

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 comme 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 au fonctionnement dans des conditions d'erreur (davantage de tests, davantage de tests unitaires/d'intégration, etc.).

15
utnapistim