Rétrospective sur le gestionnaire d’erreurs

Article original : Retrospective: The simplest error handler | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cette semaine nous allons revenir sur le dernier article publié et essaierons d’être critique à son propos. Il y a plusieurs choses à dire et il m’est apparu (grâce à l’intervention de plusieurs internautes) qu’il peut être amélioré.

Si le ErrorHandler ne vous dit rien, je vous renvoie à l’article qui en fait la présentation : Un des gestionnaires d’erreurs les plus simples jamais écrits | Assurer le C++ (wordpress.com).

Le repo où cette fonctionnalité est publiée est toujours disponible et à jour avec les modifications évoquées ici : SenuaChloe/SimplestErrorHandler (github.com).

Version 2 : futile récursivité

En effet, la récursivité était en réalité inutile.

Parfois, quand on déroule un paquet de paramètre, on a besoin de faire une distinction entre le corps de la boucle et le cas de base. Cela arrive quand on fait plus de choses dans le corps que dans le cas de base.

Pour l’ErrorHandler, le cas de base fait exactement la même chose que le reste de la récursivité (plus quelques autres instructions). Du coup, on n’a pas vraiment besoin de la récursivité: il suffit de déployer tout le paquet de paramètre d’un coup en utilisant ce qu’on appelle une fold expression (comme ce qui est exprimé dans le concept) :

template<typename TExceptionType = BasicException, typename ...TArgs>
requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
void raise_error(const TArgs & ...args)
{
    std::ostringstream oss;
    (oss << ... << args);
    const std::string error_str = oss.str();
    std::cerr << error_str << std::endl;
    throw TExceptionType(error_str);
}

Éviter une récursivité est une bonne pratique en général, quand c’est possible. Les algorithmes récursifs font grossir la pile, ce qu’il est préférable d’éviter pour plusieurs raisons1.

Grâce à cette syntaxe, on peut désormais se passer de fonction auxiliaire (plus besoin de passer un std::ostringstream pour accumuler les flux. Du coup, on n’a plus besoin de fonctions privées. Sans fonction privée, plus besoin de classe, on peut donc utiliser un namespace à la place. L’avantage, c’est qu’il est désormais possible de déclarer le concept dans cet espace de nom (et plus dans l’espace de nom global).

namespace ErrorHandler
{   
    template<typename TExceptionType, typename ...TArgs>
    concept TemplatedTypesConstraints = 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
    };
 
    // ...
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void raise_error(const TArgs & ...args)
    {
        // ...
    }
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void assert(bool predicate, const TArgs & ...args)
    {
       // ...
    }
};

Débat sur le besoin de performance

Les flux de chaînes sont lents. C’est un fait2. De plus, dans notre cas, ils ne sont pas pratiques à utiliser (on a besoin de déclarer un std::ostringstream localement, ce qui oblige à faire un #include spécifiquement pour ça). Est-ce qu’il existe un moyen de s’en débarrasser ?

La principale raison pour laquelle les flux de chaine sont lents sont les conversions objet-vers-chaîne. Cependant, pour rester le plus simple possible (dans le cadre du ErrorHandler), on veut laisser le std::ostringstream gérer lui-même les conversions, même si cela implique un code plus lent.

L’optimisation du temps d’exécution est rarement critique (on peut sans crainte avancer qu’elle est inutile  80% du temps). Ce qu’on développe est un « leveur d’erreurs ». La seule raison pour laquelle un « leveur d’erreurs » pourrait faire partie d’un code sensible en temps d’exécution serait si on l’utilisait comme flux de contrôle.

Mais ce serait une faute. On l’a appelé ErrorHandler, pas FlowControlHandler. Par construction, il n’est pas pensé pour être utilisé dans du code critique. La seule manière viable d’utiliser le gestionnaire d’erreur dans du code critique est pour en sortir (dans le cas où une erreur survient).

Donc non, on ne va pas « optimiser » le gestionnaire pour qu’il ait un meilleur temps d’exécution. On va le laisser simple et concis. On n’a pas besoin de performance.

Le code complet

Voici ce que donne le code complet dans sa version finale :

#pragma once
 
#include <iostream>
#include <sstream>
 
namespace ErrorHandler
{   
    template<typename TExceptionType, typename ...TArgs>
    concept TemplatedTypesConstraints = 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 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 TemplatedTypesConstraints<TExceptionType, TArgs...>
    void raise_error(const TArgs & ...args)
    {
        std::ostringstream oss;
        (oss << ... << args);
        const std::string error_str = oss.str();
        std::cerr << error_str << std::endl;
        throw TExceptionType(error_str);
    }
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void assert(bool predicate, const TArgs & ...args)
    {
        if (!predicate)
            raise_error<TExceptionType>(args...);
    }
};

Conclusion

La version 2 du ErrorHandler est encore plus concise et simple que la première. C’est une amélioration bien appréciable.

Je remercie les quelques personnes qui ont repéré ces erreurs et suggérer des améliorations.

Merci de votre attention et à la semaine prochaine !

Article original : Retrospective: The simplest error handler | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Repo Github

SenuaChloe/SimplestErrorHandler (github.com)

Notes

  1. Principalement pour éviter les dépassements de pile (stack overflow) et rendre le débogage plus lisible. En soi, la récursivité n’est pas fondamentalement mauvaise, surtout dans des cas où les dépassements de pile sont improbables, mais utiliser une fold expression est plus concis et plus clair. De plus, la récursivité ralentit la compilation (puisque le compilateur, dans sa volonté d’optimiser, va lui aussi être récursif) et peut même crasher si elle est mal encadrée. Cela ne peut pas arriver avec une fold expression.
  2. C’est assez compliqué à documenter comme étant un « fait établi », car la plupart des gens préfère parler des solutions à employer à la place de stringstream plutôt que de prouver qu’elles sont bel et bien lentes. Puisque je suis assez mauvaise en benchmarking (j’y travaille), je ne m’étendrai pas sur la question. Si vous voulez partager vos propres recherches (que ce soit pour confirmer ou infirmer mes dires), n’hésitez pas à le faire en commentaires.

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.