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.

Laisser un commentaire