Votre propre code d’erreur

Ce qui suit est la première partie d’une série de tutoriels sur l’utilisation de la fonctionnalité std::error_code de la norme C++11. Il s’agit d’une traduction d’un article de blogue dans lequel l’auteur partage une partie de son expérience et de son point de vue.

Commentez Donner une note  l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

C++11 fournit un mécanisme assez sophistiqué pour classer les conditions d’erreur. Vous avez peut-être rencontré des noms comme « error code » (code d’erreur), « error condition » (condition d’erreur), « error category » (catégorie d’erreur), mais il est difficile de savoir à quoi ils servent et comment les utiliser. La seule source d’information valable sur le sujet sur Internet est une série de billets de blog rédigés (en anglais) par Christopher Kohlhoff, l’auteur de la bibliothèque Boost.Asio :

C’était un très bon début pour moi, mais je crois quand même qu’il serait utile d’avoir plus d’une source d’information et plus d’une façon d’expliquer le sujet. Alors c’est parti…

II. Problématique

D’abord, pourquoi en ai-je besoin ? J’ai un service de recherche de correspondances aériennes. Vous me dites d’où vous venez et où vous voulez aller, je vous offre des vols disponibles et un prix. Pour ce faire, mon service fait lui-même appel à d’autres services :

  • un pour trouver la (courte) séquence de vols qui vous conduira à destination ;
  • un pour vérifier s’il reste des places disponibles sur ces vols dans la classe de service demandée (classe économique, classe affaires).

Chacun de ces services peut échouer pour un certain nombre de raisons. Les raisons de cet échec — différentes pour chaque service — peuvent être énumérées. Par exemple, les auteurs de ces deux services ont choisi les énumérations suivantes :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
enum class FlightsErrc
{
  // pas d’erreur 0
  NonexistentLocations = 10, // l’aéroport demandé n’existe pas
  DatesInThePast,            // la réservation est demandée pour hier
  InvertedDates,             // le retour est avant le départ
  NoFlightsFound       = 20, // aucune correspondance n’a été trouvée
  ProtocolViolation    = 30, // par exemple, l’application reçoit un mauvais fichier XML
  ConnectionError,           // n’a pas pu se connecter au serveur
  ResourceError,             // le service est à court de ressources
  Timeout,                   // le service n’a pas répondu dans le délai imparti
};
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
enum class SeatsErrc
{
  // pas d’erreur 0
  InvalidRequest = 1,    // par exemple, l’application reçoit un mauvais fichier XML
  CouldNotConnect,       // l’application n’a pas pu se connecter au serveur
  InternalError,         // le service est à court de ressources
  NoResponse,            // le service n’a pas répondu dans le délai imparti
  NonexistentClass,      // la classe demandée n’existe pas
  NoSeatAvailable,       // tous les sièges sont réservés
};

Quelques points sont à observer ici. Tout d’abord, les raisons de l’échec sont assez similaires dans les deux services, mais on leur attribue des noms différents et des valeurs numériques différentes. En effet, les deux services sont développés indépendamment par deux équipes différentes. Cela signifie également que la même valeur numérique peut faire référence à deux conditions complètement différentes selon le service qui l’a rapporté.

Deuxièmement, comme on peut le constater d’après leurs noms, les causes de l’échec proviennent de différentes sources :

  • environnement : un problème interne au service (par exemple, avec les ressources) ;
  • conflit : entre deux services ;
  • utilisateur : fournissant des données incorrectes dans sa demande ;
  • simple malchance : pas vraiment d’erreur, mais aucune réponse ne peut être rapportée à l’utilisateur — par exemple, toutes les places ont été vendues.

Maintenant, pour quelle raison ai-je vraiment besoin de ces codes d’erreur ? Si l’une de ces erreurs survient, je veux arrêter le traitement de la demande en cours de l’utilisateur. Lorsque je ne peux lui offrir aucune correspondance aérienne, je veux seulement distinguer les situations suivantes :

  1. Vous nous avez fait une demande illogique ;
  2. Aucune compagnie aérienne à notre connaissance n’est en mesure de vous proposer ce trajet ;
  3. Il y a un problème technique avec le système, que vous ne comprendrez pas, mais qui nous empêche de donner une réponse à votre demande.

D’autre part, dans le but d’un audit interne ou pour permettre la recherche des bogues, nous voulons que des informations plus détaillées soient consignées dans les journaux, comme le système qui a signalé l’erreur et ce qu’il s’est réellement passé. Ceci peut être codé avec un nombre entier. Tout détail complémentaire, comme la base de données ou les ports auxquels nous avons essayé de nous connecter, est susceptible d’être journalisé séparément, donc les données encodées dans des variables de type integer devraient suffire.

III. std::error_code

Dans la bibliothèque standard, std::error_code est conçu spécialement pour contenir ce type d’informations : un nombre représentant l’état et un « domain » (domaine) dans lequel ce nombre prend une signification. En d’autres termes, un std::error_code est une paire : {int, domain}. Cet aspect transparaît dans son interface :

 
Sélectionnez
void inspect(std::error_code ec)
{
  ec.value();    // la valeur entière
  ec.category(); // le domaine
}

Mais vous ne voulez presque jamais examiner un error_code de cette manière. Comme nous l’avons déjà dit, nous voulons deux choses : journaliser l’état de l’error_code tel qu’il a été construit (sans être transformé, par la suite, par les couches supérieures de l’application) et l’utiliser pour répondre à une question spécifique, comme « cette erreur est-elle causée par l’utilisateur fournissant des données qu’il savait être incorrectes ? ».

Au cas où vous vous le demanderiez, laissez-moi clarifier les raisons pour lesquelles utiliser std::error_code au lieu d’exceptions : ces deux choses ne sont pas mutuellement exclusives. Je souhaite signaler des échecs dans mon programme en utilisant des exceptions. C’est juste que dans l’exception, plutôt que de stocker et parcourir des chaînes de caractères, je préfère contenir un error_code que je peux aisément examiner. std::error_code n’a rien à voir avec une non-utilisation des exceptions. De plus, dans mon cas d’utilisation, je ne me sens pas obligé d’avoir plusieurs types différents d’exceptions. Je n’en ai besoin que d’un seul : je ne vais les intercepter qu’à un ou deux endroits et je vais traiter les différentes situations en examinant l’objet error_code.

IV. Relier votre énumération

À présent, nous voulons adapter le std::error_code afin qu’il puisse contenir les situations d’erreur du service Flights défini précédemment :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
enum class FlightsErrc
{
  // pas d’erreur 0
  NonexistentLocations = 10, // l’aéroport demandé n’existe pas
  DatesInThePast,            // la réservation est demandée pour hier
  InvertedDates,             // le retour est avant le départ
  NoFlightsFound       = 20, // aucune correspondance n’a été trouvée
  ProtocolViolation    = 30, // par exemple, l’application reçoit un mauvais fichier XML
  ConnectionError,           // l’application n’a pas pu se connecter au serveur
  ResourceError,             // le service est à court de ressources
  Timeout,                   // le service n’a pas répondu dans le délai imparti
};

Nous devrions être capables de convertir notre énumération en std::error_code :

 
Sélectionnez
std::error_code ec = FlightsErrc::NonexistentLocations;

Mais notre énumération doit respecter une condition : la valeur numérique 0 ne doit pas représenter une situation d’erreur. 0 représente un succès dans n’importe quel domaine d’erreurs (« catégorie d’erreurs »). Cette attente est exploitée plus tard lorsque nous inspecterons un objet std::error_code :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
void inspect(std::error_code ec)
{
  if (ec) // équivalent de : ec.value() != 0
    handle_failure(ec);
  else
    handle_success();
}

En ce sens, l’article de blog susmentionné a incorrectement  indiqué que la valeur numérique 200 signifie le succès.

Voici donc ce que nous avons fait : nous n’avons pas démarré l’énumération de FlightsErrc avec 0. Cela implique que nous pouvons initialiser une instance de l’énumération avec une valeur qui ne correspond à aucune des valeurs énumérées :

 
Sélectionnez
FlightsErrc fe {};

C’est une importante caractéristique des énumérations en C++ (y compris les classes d’énumérations de C++11) : vous pouvez créer des valeurs en dehors de la plage énumérée. C’est pour cette raison que les compilateurs émettent un avertissement quand, au sein d’une instruction switch « les chemins de contrôle ne retournent pas tous de valeur » (« not all control paths return value ») , même si vous avez un case pour chaque valeur énumérée.

Maintenant, revenons à notre conversion. std::error_code dispose d’un template de constructeur de conversion qui ressemble plus ou moins à ceci :

 
Sélectionnez
1.
2.
3.
4.
5.
template <class Errc>
  requires is_error_code<Errc>::value
error_code(Errc e) noexcept
  : error_code{make_error_code(e)}
  {}

Bien entendu, j’ai utilisé une syntaxe d’un concept inexistant, mais vous voyez l’idée : ce constructeur est seulement visible quand std::is_error_code<Errc>::value est évalué à la valeur true.

Ce constructeur sert de modèle pour faciliter l’ajout d’énumérations d’erreurs personnalisées. Afin de relier FlightsErrc au système, nous devons nous assurer que :

  1. std::is_error_code<Errc>::value retourne true ;
  2. La fonction make_error_code prenant FlightsErrc en argument est définie et accessible via une recherche dépendante de l’argument.

Concernant le premier point, nous devons spécialiser les informations de type standard :

 
Sélectionnez
namespace std
{
  template <>
    struct is_error_code_enum<FlightsErrc> : true_type {};
}

C’est l’une des situations où déclarer quelque chose dans un espace de noms std est autorisé.

Concernant le second point, il nous suffit de déclarer une fonction surchargeant make_error_code dans le même espace de noms que l’énumération FlightsErrc :

 
Sélectionnez
enum class FlightsErrc;
std::error_code make_error_code(FlightsErrc);

Et c’est tout ce que les autres parties du programme ou de la bibliothèque ont besoin de voir, et ce que nous devons fournir dans le fichier d’entête. Le reste est la mise en place de la fonction make_error_code et nous pouvons la mettre dans une unité de compilation séparée (un fichier .cpp).

Avec cela en place, nous pouvons donner l’impression que FlightsErrc est un error_code :

 
Sélectionnez
std::error_code ec = FlightsErrc::NoFlightsFound;
assert (ec == FlightsErrc::NoFlightsFound);
assert (ec != FlightsErrc::InvertedDates);

V. Définir une catégorie d’erreurs

Jusqu’à présent, j’ai seulement dit qu’un error_code est une paire {number, domain}, dont le premier élément identifie une situation particulière unique dans un domaine, le second un unique domaine d’erreurs parmi tous ceux qui seront envisagés. Étant donné que cet identifiant de domaine devrait être stocké dans un mot machine, comment pouvons-nous garantir qu’il restera unique parmi toutes les bibliothèques sur le marché et celles à venir ? Nous dissimulons l’identifiant de domaine en tant que détail d’implantation. Si nous venons à utiliser une bibliothèque tierce avec sa propre énumération d’erreurs, comment pouvons-nous garantir que son identifiant de domaine ne sera pas égal au nôtre ?

La solution choisie pour std::error_code repose sur l’observation que, à chaque objet global (ou plus formellement : objet à portée d’espace de noms « namespace-scope object »), une adresse unique est attribuée. Qu’importe combien de bibliothèques sont utilisées ensemble, ou combien d’objets globaux l’application requiert, chaque objet global a une adresse unique — c’est assez évident.

Pour exploiter cela, nous devons associer à chaque type qui veut être lié au système d’error_code un objet global et ainsi utiliser son adresse comme identifiant. Bien sûr, cela implique d’utiliser des pointeurs pour représenter des domaines, c’est en effet ce que fait std::error_code. Maintenant que nous stockons un type T*, la question est de savoir ce que T est censé être. Le choix est assez astucieux : utilisons un type qui nous apporte des bénéfices supplémentaires. Le type T utilisé est std::error_category, dont le bénéfice supplémentaire est l’interface :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
class error_category {
public:
  virtual const char* name() const noexcept = 0;
  virtual string message(int ev) const = 0;
  // autres membres…
};

J’ai utilisé le terme « domain » (domaine), la bibliothèque standard utilise le terme « error category » (catégorie d’erreurs) pour le même propos.

Le domaine dispose de fonctions membres purement virtuelles, ce qui suggère déjà quelque chose : nous allons stocker des pointeurs vers des classes dérivées de std::error_category. Chaque énumération d’erreurs nécessite une nouvelle classe correspondante pour qu’elle dérive de std::error_category. Habituellement, avoir des fonctions virtuelles pures implique d’allouer des objets sur le tas, mais nous n’allons rien faire de tel. Nous allons plutôt créer des objets globaux et pointer vers eux.

Il y a davantage de fonctions membres virtuelles dans std::error_category qui, dans d’autres occasions, devront être adaptées, mais nous ne devrons pas le faire pour notre FlightsErrc.

À présent, pour chaque « domaine » personnalisé d’erreurs représenté par une classe dérivée de std::error_category, nous devons surcharger deux fonctions membres. La fonction name est censée renvoyer un court nom descriptif et mnémotechnique de la catégorie d’erreurs (domaine). La fonction message assigne un texte descriptif pour chaque valeur numérique d’erreur dans ce domaine. Pour mieux illustrer cela, définissons une catégorie d’erreurs pour notre énumération FlightsErrc. Souvenez-vous, cette classe ne doit être visible que dans une unité de compilation. Dans d’autres fichiers, nous utiliserons seulement une adresse vers son instance.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
namespace { // espace de noms anonyme
 
struct FlightsErrCategory : std::error_category
{
  const char* name() const noexcept override;
  std::string message(int ev) const override;
};
 
const char* FlightsErrCategory::name() const noexcept
{
  return "vols";
}
 
std::string FlightsErrCategory::message(int ev) const
{
  switch (static_cast<FlightsErrc>(ev))
  {
  case FlightsErrc::NonexistentLocations:
    return "l’aéroport demandé n’existe pas";
  
  case FlightsErrc::DatesInThePast:
    return "la demande est faite pour une date dans le passé";
 
  case FlightsErrc::InvertedDates:
    return "le retour du vol demandé se situe avant la date de départ";
 
  case FlightsErrc::NoFlightsFound:
    return "aucune correspondance de vol n’a été trouvée";
 
  case FlightsErrc::ProtocolViolation:
    return "la requête reçue est mal formulée";
 
  case FlightsErrc::ConnectionError:
    return "impossible de se connecter au serveur";
 
  case FlightsErrc::ResourceError:
    return "ressources insuffisantes";
 
  case FlightsErrc::Timeout:
    return "temps de traitement écoulé";
 
  default:
    return "(erreur non reconnue)";
  }
}
 
const FlightsErrCategory theFlightsErrCategory {};
 
}

La fonction name fournit un texte court qui est utilisé pour rediriger le flux d’un std::error_code dans des choses comme des journaux : cela peut vous aider à identifier la cause d’une erreur. Ce texte n’a pas besoin d’être unique parmi les différentes énumérations d’erreurs : dans le pire des cas, les entrées de journalisation seront ambiguës.

La fonction message fournit un message descriptif pour toute valeur numérique représentant une erreur dans notre catégorie. Cela peut être utile en débogage ou pour parcourir des journaux, mais vous ne désirez probablement pas laisser ce texte en l’état à la vue des utilisateurs. Ces messages sont proches des commentaires que j’ai initialement placés dans la définition de FlightsErrc.

La fonction FlightsErrc est habituellement appelée indirectement. Les appelants ne peuvent pas savoir que la valeur numérique est un FlightsErrc, nous devons donc explicitement la reconvertir en FlightsErrc. Je crois que l’exemple dans l’article susmentionné ne compile pas à cause d’un static_cast oublié. Suite à la conversion, il y a un risque que nous examinions une valeur hors de l’ensemble énuméré : c’est pourquoi nous aurons besoin de l’étiquette default. (De manière intéressante, dès que je décide d’utiliser des enum class dans mes programmes, je me retrouve immédiatement dans le besoin de les convertir via static_casting depuis ou vers un int).

Finalement, notez que nous avons initialisé un objet global de notre type FlightsErrCategory. Ce sera le seul objet de ce type dans le programme. Nous aurons besoin de son adresse (pour indiquer les error_code de différents domaines), mais nous allons aussi utiliser ses propriétés polymorphiques.

Bien que la classe std::error_category ne soit pas un type constant, elle dispose d’un constructeur constexpr par défaut. Le constructeur implicitement déclaré de notre classe FlightsErrCategory hérite de cette propriété constexpr. Ainsi, nous sommes assurés que l’initialisation de notre objet global est effectuée lors de l’initialisation constante, comme décrite dans ce post, et qu’elle est par conséquent préservée de tout fiasco de l’ordre d’initialisation static. 

À présent, la dernière chose à faire est d’implanter make_error_code :

 
Sélectionnez
1.
2.
3.
4.
std::error_code make_error_code(FlightsErrc e)
{
  return {static_cast<int>(e), theFlightsErrCategory};
}

Et c’est terminé. Notre FlightsErrc peut être utilisé comme si c’était un std::error_code :

 
Sélectionnez
1.
2.
3.
4.
5.
int main()
{
  std::error_code ec = FlightsErrc::NoFlightsFound;
  std::cout << ec << std::endl;
}

Ce programme affichera :

vols:20

Un exemple complet et fonctionnel, illustrant tout ce qui précède, peut être trouvé ci-dessous.

Exemple complet
TéléchargerCacher/Afficher le codeSélectionnez

C’est tout pour aujourd’hui. Nous n’avons pas encore traité la réalisation des requêtes utiles sur des objets std::error_code, ce sera le sujet d’un autre article.

VI. Remerciements

Je suis reconnaissant envers Tomasz Kamiński pour m’avoir expliqué le principe de std::error_code. En plus de la série d’articles de Christopher Kohlhoff, j’ai pu en apprendre davantage sur std::error_code à partir de la documentation de la bibliothèque Outcome de Niall Douglas.

#signet1

Le code 200 en tant que succès prend en réalité tout son sens quand on constate que l’article mentionné par l’auteur prend comme exemple des codes HTTP. En effet, le standard HTTP définit les codes en 2xx comme des codes de succès. La remarque de l’auteur est à prendre surtout comme un regret vis-à-vis du standard C++, qui définit la valeur 0 comme un succès sans permettre au développeur de définir une ou plusieurs autre(s) valeur(s). Un commentaire de l’auteur à la suite de son article semble confirmer cette interprétation.

#signet2

Message d’avertissement issu de Visual C++, produit lorsqu’une fonction traite un switch-case où chaque cas retourne une valeur, mais pas la fonction. g++ réagira plutôt à ce dernier aspect.

#signet3

Le lien pointe vers un autre article de l’auteur, ainsi que vers le site de l’ISO C++ qui décrit le problème de l’ordre d’initialisation static ainsi :

« Une manière subtile de faire planter votre programme.

Le problème de l’ordre d’initialisation static est un aspect très subtil et communément mal compris du C++. Malheureusement, il est très difficile à déclarer — les erreurs surviennent souvent avant le début du main().

En résumé, supposez que vous avez deux objets static x et y qui existent dans des fichiers sources séparés, disons x.cpp et y.cpp. Supposez également que l’initialisation pour l’objet y (typiquement le constructeur de l’objet y) appelle une méthode de l’objet x.

C’est tout. C’est aussi simple que ça.

Le plus difficile, c’est que vous avez 50% de chances de corrompre le programme. S’il se trouve que l’unité de compilation x.cpp est initialisée en premier, tout va bien. Cependant si l’unité de compilation y.cpp est initialisée en premier, alors l’initialisation d’y sera effectuée avant celle de x et c’est grillé. Par exemple, le constructeur d’y pourrait appeler une méthode de l’objet x, sans que l’objet x ait été construit.

Pour savoir comment résoudre ce problème, voyez la FAQ suivante.

Note : le problème de l’ordre d’initialisation static peut aussi, dans certains cas, s’appliquer à des types fondamentaux ou intrinsèques. »

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2020 Andrzej Krzemieński. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.