Les exceptions

Les exceptions permettent de dévier du flot normal d'exécution, en remontant les appels de fonction jusqu'au premier bloc try / catch, ou jusqu'au main (auquel cas le programme crash). Elles permettent donc de séparer le chemin d'exécution classique de celui du traitement des erreurs.

int get_int(int* ptr) {
    if (ptr == nullptr)
        throw std::logic_error{"get_int() received a null pointer"};
        
    return *ptr;
}
// L'erreur remonte automatiquement.
int task() {
    return get_int(nullptr);
}
// L'erreur remonte dans le block try / catch. Son type correspond à la signature du
// paramètre de 'catch', elle y est donc récupérée.
int main() {
    try {
        return task();
    }
    catch (std::logic_error const& e) {
        std::cout << e.what();
        return -1;
    }
}

Stack unwinding

Lorsqu'une exception remonte, elle détruit tous les objets stockés sur la pile qu'elle croise : il s'agit du phénomène de stack unwinding. Cela permet de déclencher des actions via les destructeurs automatiquement, pour libérer de la mémoire ou fermer un fichier par exemple.

Cependant, si un destructeur lance à ce moment une exception, on se retrouve avec deux exceptions, ce qui fait crash le programme (std::terminate() est appelé). Les destructeurs ne doivent donc jamais lancer d'exceptions.

std::set_terminate() permet de passer une fonction qui sera appelée par std::terminate(). Par défaut, std::abort() est appelé.

De plus, certains ressources peuvent être perdues lorsqu'une exception est déclenchée :

void send_resource(std::string const& ip, Resource&& resource) {
    auto session = new Session();
    // Si 'connect' ou 'send' lance une exception, 'delete session' ne sera jamais
    // appelé ! L'objet 'session' sera leak (hors de portée, et jamais détruit).
    session.open(ip);
    session.send(std::move(resource));
    delete session;
}

Dans la plupart des autres langages, on remédierait à ce problème en plaçant dans un bloc try catch les fonctions pouvant lancer une exception, afin de bien fermer puis détruire la session dans tous les cas :

void send_resource(std::string const& ip, Resource&& resource) {
    auto session = new Session();
    
    try {
        session.open(ip);
        session.send(std::move(resource));
    }
    // On catch n'importe quelle exception, on ferme la session puis on relance
    // l'exception courante.
    catch(...) {
        delete session;
        throw;
    }
    delete session;
}

En c++, on préfèrera gérer le cleanup automatiquement avec le destructeur d'une variable locale si on ne souhaite pas gérer à cet endroit l'erreur (et la laisser remonter) :

void send_resource(std::string const& ip, Resource&& resource) {
    // session est un std::unique_ptr<Session>, qui va détruire la session lorsqu'il
    // est détruit. On obtient un code plus clair, plus performant et tout aussi
    // sûr que la seconde version ci-dessus.
    auto session = std::make_unique<Session>();
    session.open(ip);
    session.send(std::move(resource));
}

La notion de pile ne fait pas partie du langage. Dépasser la capacité de la pile (comme avec une récursion infinie) va donc faire crash le programme, et non lancer une exception.

Les garanties possibles

Les fonctions peuvent avoir 4 niveaux de garanties vis-à-vis des exceptions :

- No safety ne garantit rien, de façon similaire à la première version de la fonction send_resource ci-dessus : des fuites de mémoire peuvent arriver, et des objets peuvent être dans des états invalides.

- Weak safety garantit qu'en cas d'exception, les objets sont dans un état valide, et aucune ressource n'est perdue. C'est la garantie minimale à viser lorsqu'on écrit du code. Les autres versions de send_resource assurent cette garantie.

- Strong safety garantit qu'en cas d'exception, aucun effet visible ne s'est produit : c'est comme si on avait jamais appelé la fonction. Cela a un aspect transactionnel : soit l'opération a réussie, soit elle n'a pas eu d'effet. send_resource ne possède pas cette garantie (pour les versions 2 et 3) : même si les fonctions open et send procurent elles-même cette strong safety. En effet, si send lance une exception, open s'est bien déroulé et a pu avoir des effets visibles.

- Nothrow garantit que la fonction ne lancera jamais d'exceptions. On marque alors la fonction noexcept, ce qui va appeler std::terminate() si une exception est lancée dans la fonction. De plus, de légères optimisations sont parfois permises si le compilateur sait qu'une fonction ne peut pas échouer.

Par exemple, les collections de la librairie standard offrent souvent la strong safety, qui comporte quelques contraintes. Par exemple, std::vector<T>::push_back(T&&) garantit cette strong safety : cela signifie que même si tout le vecteur doit être recréé, l'opération soit échoue complètement soit réussi complètement.

Pour que cela fonctionne, il faut alors que le constructeur par move de T ait la garantie nothrow. Si il n'est pas marqué noexcept, std::vector<T> va alors choisir de copier les valeurs. Move l'objet si il est noexcept, sinon le copier est un pattern courant pour garantir la strong safety, qui est facilité grâce à std::move_if_noexcept<T>(t& value).

Hiérarchie des exceptions et types d'erreurs

N'importe quelle classe peut être throw, et la clause catch(...) les intercepte toutes. Cependant, toues les exceptions lancées par la librairie standard héritent de std::exception, qui possède une unique fonction virtuelle, char const* what() const noexcept. Cela permet de catch std::exception pour intercepter toutes les erreurs de la librairie standard et d'afficher leurs erreurs.

Il existe de nombreuses façons de gérer les erreurs dans un programme, et les exceptions ne sont pas toujours la meilleure solution :

Lorsque les préconditions d'une fonction ne sont pas respectées, il s'agit d'une erreur de logique, un "bug". Ce n'est généralement pas une erreur qui est récupérable (pour continuer l'exécution du programme après l'avoir catch), donc on souhaite stopper le programme rapidement, avec un appel à std::terminate() par exemple. Cette fonction arrêtera le programme sans lancer d'exceptions, les objets ne seront donc pas détruits : on peut alors préférer lancer une exception.

Les exceptions héritant de std::logic_error correspondent à ces erreurs logiques. Par exemple, accéder à un std::vector<T> avec la méthode at(size_type pos) en passant un mauvais index va lancer l'exception std::out_of_range héritant de std::logic_error.

La macro assert(x) va appeler std::abort() (ce que fait std::terminate() par défaut) si l'expression passée n'est pas évaluée à true. Elle est désactivable en définissant la macro NDEBUG.

Lorsque la précondition d'une fonction est remplie mais qu'elle échoue quand même, on est dans le cas d'utilisation le plus propice aux exceptions. Par exemple, si une allocation demande trop de mémoire par rapport à celle disponible (comme avec new int[10'000'000'000]), l'exception std::bad_allocsera lancée.

Beaucoup de fonctions peuvent lancer une exception juste parce qu'elles font des allocations dynamiques. Si on ne souhaite pas gérer le cas où le programme ne peut plus allouer de mémoire, on peut ignorer cette erreur (en marquant les fonctions noexcept et/ou avec une clause catch(std::bad_alloc const& e) qui relance l'exception).

Fonctions noexcept

On peut marquer une fonction conditionnellement noexcept, pratique dans le code template :

template <class T>
struct wrapped {
    T value;
    int id;
    
    // La fonction est noexcept si T(T&&) est marqué noexcept.
    static wrapped make(T&& value, int id) 
        noexcept(std::is_nothow_move_constructible_v<T>)
    {
        return { std::move(value), id };
    }
};

Le mot-clé noexcept peut également vérifier si une expression ou une fonction est noexcept :

template <class T>
constexpr bool has_nothrow_boop = noexcept(T::boop());

// has_nothrow_boop<booper> = false
struct booper { static void boop(); };

// has_nothrow_boop<safe_booper> = true
struct safe_booper { static void boop() noexcept; };

On peut donc utiliser noexcept(noexcept(expression)) pour marquer une fonction noexcept :

template <class T1, class T2>
auto add(T1 const& t1, T2 const& t2) noexcept(noexcept(t1 + t2)) {
    return t1 + t2;
}

On a déjà vu que les destructeurs ne doivent pas lancer d'exceptions (afin de ne pas en avoir deux en même temps, ce qui appellerait std::terminate()). De même, les constructeurs et assignements par move ne devraient pas lancer d'exceptions.

D'autres fonctions gagnent à être noexcept, comme la fonction swap (échangeant le contenu de deux objets) ou les constructeurs par défaut.

// swap était beaucoup utilisée et spécialisée avant C++11 pour obtenir un move,
// en swappant l'objt consommable avec un nouvel objet vide.
// Exemple d'implémentation de swap équivalente à celle de la librairie standard :
template <class T>
void swap(T& lhs, T& rhs) 
    noexcept(std::is_nothrow_move_construtible_v<T> &&
             std::is_nothrow_move_assignable_v<T>)
{
    T tmp = std::move(lhs);
    lhs = std::move(rhs);
    rhs = std::move(tmp);
}

Exceptions vs codes d'erreur

De nombreuses fonctions signalent une erreur sans lancer d'exceptions, en retournant ou en modifiant un code d'erreur. Cela comporte plusieurs désavantages, notamment :

  • Le chemin d’exécution normal du code n'est pas séparé du traitement des erreurs

  • Les erreurs doivent être remontées manuellement

  • Les interfaces sont moins claires

  • L'erreur peut être ignorée si le résultat de la fonction est ignoré

  • Les constructeurs ou des opérateurs overload ne peuvent pas retourner de code d'erreur

Cependant, vérifier un code d'erreur est rapide, alors que le mécanisme d'exceptions est très lent. Les codes d'erreur sont donc parfois préféré lorsque l'erreur arrive souvent. Cependant, si les exceptions ne sont pas lancées, le code correspondant sera aussi (voire plus) rapide que le code équivalent retournant un code d'erreur.

De plus, utiliser les exceptions nécessite de penser aux niveaux de garantie offerts, ce qui peut compliquer le développement. Les codes d'erreurs peuvent alors être préférés dans du code ancien ne permettant pas de lancer une exception sans leak de ressources.

Des librairies comme Expected ou Outcome permettent de signaler des exceptions en conservant les performances des codes d'erreur, mais en limitant leurs défauts.

Pour aller plus loin

std::exception_ptr permet de stocker une exception. C'est utilisé par exemple pour propager une exception depuis un thread : le pointeur sera ensuite vérifié depuis un autre thread, et pourra relancer l'erreur stockée.

Une proposition de système d'exceptions a été créé, pour garder le traitement des erreurs semblable aux exceptions mais obtenir les performances des codes d'erreur.

Last updated