Un des gestionnaires d’erreurs les plus simples jamais écrits

Article original : One of the simplest error handlers ever written | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cette semaine je vais vous présenter un petit projet que j’ai écrit et qui permet de gérer les erreurs, par le biais d’un code très simple et très générique.

Il est certain que ce code n’est pas parfait (avant tout parce que la perfection est subjective), mais il est court et facile à utiliser.

Si vous voulez directement consulter le code source, vous pouvez d’ores et déjà vous rendre sur la page Github qui lui est dédiée : SenuaChloe/SimplestErrorHandler (github.com).

Spécifications

En général, en terme de gestion d’erreurs, mes besoins sont les suivants :

  • Le gestionnaire d’erreur doit écrire un message sur la sortie d’erreur (std::cerr).
  • Le gestionnaire d’erreur doit être capable de prendre plusieurs arguments (peu importe leur type) et les envoyer en tant que flux dans le message d’erreur.
  • Le gestionnaire d’erreur doit lever une exception.
  • Le what() de l’exception doit être le même message que celui qui est affiché sur std::cerr.
  • L’exception levée doit être changeable.
  • L’action de lever une erreur doit être une unique expression (un seul appel de fonction).
  • Le gestionnaire d’erreur ne doit pas utiliser de macros.

Ce seront donc les principaux critère sur lesquels nous allons nous baser pour concevoir le gestionnaire d’erreur (d’autres besoins pourront apparaître en cours de route).

Étape 0 : Mise en place

Pour garder le tout simple et léger, tout le code sera contenu dans un unique fichier d’en-tête (c’est toujours plus simple d’inclure un fichier d’en-tête plutôt qu’une librairie à votre projet). Mais puisqu’il y aura sans doute des fonctions auxiliaires, il faut trouver un moyen de les obfusquer.

C’est pour cela qu’on mettra tout le code dans une classe entièrement statique1. Il y aura des fonctions membres privées (celles qu’on veut obfusquer), des fonctions membres publiques (l’interface) et éventuellement des types et d’autres éléments.

Étape 1 : Récursivité basique et templates variadiques

Pour commencer : une récursivité simple

Pour avoir un message d’erreur complet et entièrement personnalisable, on a besoin d’un nombre variadique d’arguments (et, du coup, des templates variadiques). On effectuera une récursivité sur les arguments2, en envoyant le premier argument de la liste dans un flux et en répétant l’opération avec les arguments restants.

Voici l’implémentation que je suggère :

template<typename THead>
static void raise_error_recursion(const THead & arg_head)
{
    std::cerr << arg_head << std::endl;
    throw;
}
 
template<typename THead, typename ...TTail>
static void raise_error_recursion(const THead & arg_head, const TTail & ...arg_tail)
{
    std::cerr << arg_head;
    raise_error_recursion(arg_tail...);
}

La première raise_error_recursion représente la condition de base de la récursivité : s’il n’y a qu’un seul argument, alors on l’affiche et on throw.

La seconde raise_error_recursion représente la boucle récursive. Tant qu’il y a plus d’un argument dans le paquet de paramètres, on prend le premier, on l’affiche et on rappelle la fonction. Si arg_tail ne contient plus qu’un seul argument, alors c’est la condition de base qui est appelée, sinon c’est de nouveau la boucle récursive qui entre en jeu.

Avec un flux et une vraie exception

Le problème est que, dans le code ci-dessus, on ne lève pas une exception, on ne fait que throw;. Pour rappel, deux des spécifications étaient :

  • Le gestionnaire d’erreur doit lever une exception.
  • Le what() de l’exception doit être le même message que celui qui est affiché sur std::cerr.

Donc on doit lever une « vraie » exception, qui doit être construite à partir du message d’erreur.

À titre d’exemple, on va utiliser std::runtime_error, qui peut être construite à partir d’une std::string.

Le problème est qu’on ne peut pas juste envoyer le message d’erreur dans le flux cerr à chaque boucle récursive, on doit trouver un moyen de « mémoriser » le message d’erreur pour construire la runtime_exception et l’envoyer dans cerr à la fin.

Une solution possible est d’utiliser une stringstream et de la passer en paramètre de chaque fonction récursive.

template<typename THead>
static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head)
{
    error_string_stream << arg_head;
    const std::string current_error_str = error_string_stream.str(); 
 
    std::cerr << current_error_str << std::endl;
    throw std::runtime_error(current_error_str);
}
 
template<typename THead, typename ...TTail>
static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head, const TTail & ...arg_tail)
{
    error_string_stream << arg_head;
    raise_error_recursion(error_string_stream, arg_tail...);
}

Ici, dans la boucle récursive, on envoie le message d’erreur dans le flux représenté par error_string_stream à la place de cerr. Ce n’est qu’à la fin qu’on affiche tout le contenu de error_string_stream et qu’on construit notre exception avec.

Étape 2 : Ajout d’une interface

Mais cette ostringstream est peu commode et son utilisation devrait être invisible pour l’utilisateur. Du coup, on va cacher les fonctions précédentes et en implémenter une qui sera publique et qui prendra moins d’argument, prenant le soin de cacher l’instanciation du flux de chaîne :

template<typename ...TArgs>
static void raise_error(const TArgs & ...args)
{
    std::ostringstream error_string_stream;
    raise_error_recursion(error_string_stream, args...);
}

raise_error est désormais une fonction très simple à utiliser.

Étape 3 : Possibilité de changer d’exception

L’exception en tant que template

La seule spécification qu’on n’a toujours pas implémenter est « L’exception levée doit être changeable ».

Pour la réaliser on va ajouter un template à chacune de nos fonctions qui représentera l’exception à lever.

class ErrorHandler
{
    ErrorHandler(); // Private constructor -- this is a full-static class
     
    template<typename TExceptionType, typename THead>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head)
    {
        error_string_stream << arg_head;
        const std::string current_error_str = error_string_stream.str();
 
        std::cerr << current_error_str << std::endl;
        throw TExceptionType(current_error_str);
    }
 
    template<typename TExceptionType, typename THead, typename ...TTail>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head, const TTail & ...arg_tail)
    {
        error_string_stream << arg_head;
        raise_error_recursion<TExceptionType>(error_string_stream, arg_tail...);
    }
 
public:
 
    template<typename TExceptionType, typename ...TArgs>
    static void raise_error(const TArgs & ...args)
    {
        std::ostringstream error_string_stream;
        raise_error_recursion<TExceptionType>(error_string_stream, args...);
    }
 
    template<typename TExceptionType>
    static void raise_error()
    {
        raise_error<TExceptionType>("<Unknown error>");
    }
};

En faisant ça, on peut maintenant appeler raise_error avec n’importe quelle exception qui est constructible avec une std::string, comme ceci :

ErrorHandler::raise_error<std::runtime_error>("Foo ", 42);

Cependant, c’est un peu lourd. Parfois, on veut juste lever une erreur générique sans se soucier qu’il s’agisse d’une runtime_error ou d’une invalid_argument ou autre.

C’est pourquoi on va ajouter une valeur par défaut au template TExceptionType. Malheureusement, on ne pourra pas utiliser std::exception comme valeur par défaut, car elle n’est pas constructible par std::string.

Ce que je suggère, c’est qu’on écrive notre propre exception générique, dans l’espace de nom du ErrorHandler. Avec ça, on aura une exception qu’on pourra utiliser comme valeur par défaut et avec laquelle on pourra créer des exceptions plus personnaliser (en héritant de cette exception générique) qui seront toutes reliées aux mécanismes du ErrorHandler (ce qui peut être pratique pour les attraper).

Une exception générique et personnalisable pour le gestionnaire d’erreurs

class BasicException : public std::exception
{
protected:
    std::string m_what;
public:
    BasicException(const std::string & what): m_what(what) {}
    BasicException(std::string && what): m_what(std::forward<std::string>(what)) {}
    const char * what() const noexcept override { return m_what.c_str(); };
};

Bien sûr, il y a un héritage public à std::exception pour que la BasicException puisse être utilisée comme n’importe quelle autre exception standard3.

J’ai implémenté deux constructeurs, un qui construit le message d’erreur avec une référence constante et une qui construit le message d’erreur avec une r-value reference (pour pouvoir utiliser les sémantiques de move).

Et bien sûr, le what() est une surcharge virtuelle qui renvoie le message d’erreur.

En utilisant cette exception par défaut, la fonction raise_error ressemble désormais à ça :

template<typename TExceptionType = BasicException, typename ...TArgs>
static void raise_error(const TArgs & ...args)
{
    std::ostringstream error_string_stream;
    raise_error_recursion<TExceptionType>(error_string_stream, args...);
}
 
template<typename TExceptionType = BasicException>
static void raise_error()
{
    raise_error<TExceptionType>("<Unknown error>");
}

Maintenant on peut lever une erreur sans avoir à fournir une exception :

ErrorHandler::raise_error("Foo ", 42);

Cela lèvera, comme voulu, l’exception ErrorHandler::BasicException.

Étape 4 : Ajout d’une fonction d’assertion

Le cas le plus courant où on doit lever une erreur est si <quelque chose va mal> alors <on lève une erreur>. On peut aussi le voir comme ça : j’affirme que <expression> est vraie. Si ce n’est pas le cas, <on lève une erreur>.

Cette deuxième forme est extrêmement courante dans les tests unitaires par exemple, et s’écrit souvent comme ça : assert(expression, message_if_false);

C’est pourquoi je pense que c’est une bonne idée d’écrire une surcharge simple qui permettra d’évaluer une expression et de lever l’erreur que si l’expression est fausse.

template<typename TExceptionType = BasicException, typename ...TArgs>
static void assert(bool predicate, const TArgs & ...args)
{
    if (!predicate)
        raise_error<TExceptionType>(args...);
}

Avec cette nouvelle fonction, plutôt que d’écrire ceci :

bool result = compute_data(data);
if (result != ErroCode::NO_ERROR)
    ErrorHandler::raise_error("Error encountered while computing data. Error code is ", result);

On pourra écrire cela :

bool result = compute_data(data);
ErrorHandler::assert(result == ErroCode::NO_ERROR, "Error encountered while computing data. Error code is ", result);

Étape 5 : concepts et contraintes

On a utilisé beaucoup de templates. Avoir beaucoup de templates signifie qu’on a un gros risque de mauvaises utilisations. Mauvaises utilisations qui mènent à des erreurs de compilations. Et quand on parle de templates, les erreurs de compilation sont souvent illisibles.

Mais comme nous sommes chanceux (et bien préparés), il y a un moyen en C++20 de rendre ces erreurs plus lisibles tout en protégeant nos fonctions : les concepts et les contraintes.

Fonctionnellement, nous avons actuellement deux contraintes :

  • TExceptionType doit être constructible avec une std::string.
  • Tous les TArgs... doivent pouvoir être envoyés dans un flux de sortie.

On va donc implémenter ces deux contraintes dans un seul concept4 :

template<typename TExceptionType, typename ...TArgs>
concept ErrorHandlerTemplatedTypesConstraints = requires(std::string s, std::ostringstream oss, TArgs... args)
{
    TExceptionType(s); // TExceptionType must be constructible using a std::string
    (oss << ... << args); // All args must be streamable
};

Maintenant, il ne reste plus qu’à utiliser ce concept en tant que contrainte pour les deux fonctions de l’interface :

template<typename TExceptionType = BasicException, typename ...TArgs>
requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
static void raise_error(const TArgs & ...args)
{
    std::ostringstream error_string_stream;
    raise_error_recursion<TExceptionType>(error_string_stream, args...);
}
 
template<typename TExceptionType = BasicException, typename ...TArgs>
requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
static void assert(bool predicate, const TArgs & ...args)
{
    if (!predicate)
        raise_error<TExceptionType>(args...);
}

Le code complet

Si on emboîte tout ça dans un seul fichier d’en-tête, on obtiendra ceci :

#pragma once
 
#include <iostream>
#include <sstream>
 
template<typename TExceptionType, typename ...TArgs>
concept ErrorHandlerTemplatedTypesConstraints = requires(std::string s, std::ostringstream oss, TArgs... args)
{
    TExceptionType(s); // TExceptionType must be constructible using a std::string
    (oss << ... << args); // All args must be streamable
};
 
class ErrorHandler
{
    ErrorHandler(); // Private constructor -- this is a full-static class
     
    template<typename TExceptionType, typename THead>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head)
    {
        error_string_stream << arg_head;
        const std::string current_error_str = error_string_stream.str();
 
        std::cerr << current_error_str << std::endl;
        throw TExceptionType(current_error_str);
    }
 
    template<typename TExceptionType, typename THead, typename ...TTail>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head, const TTail & ...arg_tail)
    {
        error_string_stream << arg_head;
        raise_error_recursion<TExceptionType>(error_string_stream, arg_tail...);
    }
 
public:
 
    class BasicException : public std::exception
    {
    protected:
        std::string m_what;
    public:
        BasicException(const std::string & what): m_what(what) {}
        BasicException(std::string && what): m_what(std::forward<std::string>(what)) {}
        const char * what() const noexcept override { return m_what.c_str(); };
    };
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
    static void raise_error(const TArgs & ...args)
    {
        std::ostringstream error_string_stream;
        raise_error_recursion<TExceptionType>(error_string_stream, args...);
    }
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
    static void assert(bool predicate, const TArgs & ...args)
    {
        if (!predicate)
            raise_error<TExceptionType>(args...);
    }
};

Aller plus loin

On pourrait augmenter la généricité de ce code d’un cran en rendant possible le fait de pouvoir utiliser n’importe quel flux sortant à la place de std::cerr (avec std::cerr comme valeur par défaut pour ce flux).

Cependant, ça voudrait dire implémenter plus de fonction, donc un code-source plus long, et un de mes objectifs était de le garder aussi court de possible.

À vous de voir donc si vous voulez aller plus loin ou pas.

Conclusion

Ce n’est certainement pas la manière la plus complète de gérer les erreurs dans un programme mais c’est, d’après moi, une des manières les plus simples et propres de le faire tout en répondant aux spécifications que je me suis posé.

Charge à vous maintenant de définir vos propres spécifications et de les implémenter dans votre propre gestionnaire d’erreurs si vos besoins sont différents des miens.

Vous pouvez utiliser mon code (presque) aussi librement que vous le souhaitez, puisque je l’ai mis sous Licence CC0-1.0.

Merci de votre attention et à la semaine prochaine.

Article original : One of the simplest error handlers ever written | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Repo Github

SenuaChloe/SimplestErrorHandler (github.com)

Documentation utile

J’ai utilisé pas mal de notions avancées de C++ dans cet article. Pour apprendre à mieux les connaître, voici quelques liens :

Notes

  1. Une classe « entièrement statique » est une classe qu’il n’est pas possible d’instancier. Tous ces membres (fonctions et variables) sont statiques et le constructeur privé. C’est pour cela qu’on utilise une classe et pas simplement un espace de nom : on a besoin de cacher certaines fonctions. Avec un espace de nom, on ne pourrait pas cacher les fonctions auxiliaires.
    Si vous ne voulez pas utiliser une classe entièrement statique et voulez quand même cacher les fonctions auxiliaires, vous pourriez les extraire du fichier d’en-tête et le mettre dans un fichier source. Mais en faisant cela vous auriez besoin de compiler le gestionnaire d’erreur à part et d’en faire une librairie pour pouvoir l’importer dans d’autres projets.
  2. Pour en apprendre plus sur la récursivité, lisez cette page : Recursion – GeeksforGeeks. Pour en apprendre plus sur les templates variadiques, allez voir Variadic arguments – cppreference.com et Parameter pack(since C++11) – cppreference.com.
  3. La page std::exception – cppreference.com vous permettra de mieux comprendre comment fonctionnent les exceptions en C++.
  4. Le seul petit défaut est qu’on doit implémenter le concept dans l’espace de nom global, on ne peut pas le faire dans le gestionnaire d’erreur. C’est pour ça que je lui ai donné un nom assez long : pour éviter les collisions de noms autant que possible.

3 réflexions au sujet de « Un des gestionnaires d’erreurs les plus simples jamais écrits »

  1. Bonjour,

    En C++17, la syntaxe du raise_error peut-être simplifié avec les fold expressions :

    template
    static void raise_error(const T &…args) {
    (std::cerr << … << args) << std::endl;
    throw;
    }

Laisser un commentaire