De lever à gérer
À la page précédente, vous avez appris à throw (lever) une exception lorsqu'un problème survient. Lever n'est que la moitié de l'histoire : une exception qui n'est jamais interceptée appelle std::terminate et fait planter votre programme. L'instruction try/catch est la façon de gérer ce qui a été levé et de continuer l'exécution.
La structure est simple : placez le code risqué dans un bloc try, puis faites-le suivre d'un ou plusieurs blocs catch qui réagissent à des types d'erreur spécifiques. Si le bloc try s'exécute sans encombre, chaque catch est ignoré. Dès qu'une exception est levée, le contrôle saute directement au premier catch correspondant.
Remarquez que "after" ne s'affiche jamais. Dès que le throw se déclenche, le reste du bloc try est abandonné et l'exécution reprend dans le catch correspondant. Une fois le catch terminé, le programme continue normalement en dessous.
Intercepter par référence constante
L'habitude la plus importante dans la gestion d'erreurs en C++ : interceptez les exceptions par référence const, pas par valeur.
Intercepter par valeur copie l'exception et, pire, la tronque (slicing). Les exceptions standard forment une hiérarchie (runtime_error et logic_error dérivent toutes deux de std::exception), donc intercepter une exception dérivée comme une valeur de base supprime la partie dérivée. Intercepter par référence garde l'objet intact et polymorphe :
Ici, nous levons un out_of_range mais l'interceptons comme un const exception&. Comme out_of_range dérive de exception, le gestionnaire de la classe de base correspond, et la référence fait que e.what() renvoie toujours le vrai message. Si vous aviez écrit catch (exception e) (par valeur), l'objet serait tronqué en un simple exception et vous pourriez perdre le message spécifique.
Plusieurs blocs catch
Un seul try peut être suivi de plusieurs blocs catch, chacun pour un type d'exception différent. C++ les essaie de haut en bas et exécute le premier qui correspond ; ordonnez-les donc du plus spécifique au plus général.
Comme invalid_argument est plus spécifique que exception, il doit venir en premier. Si vous inversiez l'ordre et placiez catch (const exception&) en tête, il avalerait toutes les exceptions : le gestionnaire invalid_argument situé en dessous deviendrait du code mort qui ne pourrait jamais s'exécuter. Beaucoup de compilateurs émettent un avertissement à ce sujet, mais le langage ne vous en empêchera pas.
catch (...) et relancement
Parfois, vous voulez un filet de sécurité pour tout ce que vous n'avez pas anticipé. Le gestionnaire fourre-tout catch (...) correspond à tout type d'exception, y compris celles qui ne dérivent pas de std::exception (quelqu'un peut écrire throw 42; ou throw "oops";).
L'inconvénient est que vous n'obtenez aucun objet : il n'y a pas de e à inspecter. C'est pourquoi catch (...) s'utilise au mieux en dernier recours : journaliser que quelque chose a échoué, ou nettoyer et relancer.
Pour relancer l'exception en cours - la transmettre à un gestionnaire externe après avoir effectué un nettoyage ou une journalisation locale - utilisez un throw; nu, sans opérande. Cela préserve l'exception d'origine (son vrai type et son message), contrairement à throw e;, qui relancerait une copie tronquée :
Le gestionnaire interne journalise et relance ; le gestionnaire externe dans main s'en occupe ensuite. Utilisez un throw; nu pour cela, jamais throw e;.
Déroulement de la pile et RAII
Lorsqu'une exception se propage hors d'un bloc try, C++ effectue le déroulement de la pile (stack unwinding) : le destructeur de chaque objet local situé entre le throw et le catch correspondant est appelé, dans l'ordre inverse de la construction. C'est ce qui rend les exceptions sûres : les ressources détenues par les objets de la pile sont libérées automatiquement.
C'est exactement pour cela que vous devriez détenir les ressources dans des types RAII (comme std::vector, std::string et les pointeurs intelligents) plutôt qu'avec des new/delete manuels. Observez ce qui se passe lorsqu'une exception traverse une allocation manuelle :
void leaky() {
int* buffer = new int[1000];
mightThrow(); // si ceci lève, la ligne suivante ne s'exécute jamais...
delete[] buffer; // ...et le buffer fuit
}
Parce que le throw saute par-dessus delete[], la mémoire est perdue. Un pointeur intelligent corrige cela gratuitement : son destructeur s'exécute durant le déroulement :
void safe() {
auto buffer = std::make_unique<int[]>(1000);
mightThrow(); // si ceci lève, le destructeur de buffer libère quand même la mémoire
} // pas de delete manuel, pas de fuite, même sur le chemin de l'exception
À retenir : n'essayez pas d'intercepter une exception juste pour faire un delete. Laissez les destructeurs s'occuper du nettoyage et réservez le catch aux décisions sur la façon de récupérer.
Erreurs courantes et pièges
Une poignée de pièges reviennent sans cesse :
N'utilisez pas les exceptions pour le flux de contrôle normal. Lever et dérouler est bien plus lent qu'un simple if. Réservez les exceptions aux conditions d'erreur véritablement exceptionnelles, pas à « l'utilisateur a saisi une chaîne vide ».
Un bloc catch vide masque les bugs. Écrire catch (...) {} pour faire taire une erreur fait disparaître les échecs sans laisser de trace. Au minimum, journalisez le problème ; en général, vous devriez le relancer ou le traiter correctement.
Un destructeur qui lève est dangereux. Si un destructeur lève une exception pendant le déroulement de la pile (alors qu'une autre exception est déjà en cours), le programme appelle std::terminate. En C++ moderne, les destructeurs sont implicitement noexcept : ne laissez jamais une exception s'en échapper.
Le catch ne voit que ce que le try couvre. Une exception levée avant d'entrer dans le try, ou dans une autre fonction qui n'est pas sur le chemin d'appel à l'intérieur, ne sera pas interceptée ici. Le catch ne protège que le code qui s'exécute à l'intérieur de son propre bloc try (directement ou dans les fonctions qu'il appelle).
Suite : le comportement indéfini
Les exceptions sont la manière définie par laquelle C++ vous signale qu'un problème est survenu : vous levez, vous interceptez, le comportement est prévisible. Mais C++ a aussi un recoin plus sombre où le langage ne fait aucune promesse : déréférencer un pointeur pendant, lire au-delà de la fin d'un tableau, le débordement d'entier signé. La page suivante traite du comportement indéfini : ce qui le déclenche, pourquoi il peut sembler « fonctionner » jusqu'au moment où il échoue de façon catastrophique, et comment le tenir hors de votre code.
Questions fréquentes
Comment fonctionne try-catch en C++ ?
Vous placez le code susceptible de lever une exception à l'intérieur d'un bloc try { }. Si une exception est levée, le programme cesse d'exécuter le reste du bloc try et saute au premier bloc catch correspondant, où vous traitez l'erreur. Si rien n'est levé, les blocs catch sont entièrement ignorés.
Pourquoi faut-il intercepter les exceptions par référence constante en C++ ?
Intercepter par référence (catch (const std::exception& e)) évite de copier l'objet exception et, surtout, préserve le polymorphisme : une exception dérivée interceptée par son type de base appelle toujours le bon what(). Intercepter par valeur (catch (std::exception e)) tronque la partie dérivée et peut faire perdre de l'information.
Comment intercepter n'importe quelle exception en C++ ?
Utilisez catch (...) : les points de suspension interceptent toute exception, quel que soit son type. C'est un gestionnaire utile en dernier recours, mais comme vous n'obtenez aucun objet à inspecter, placez-le après vos blocs catch spécifiques et servez-vous-en surtout pour journaliser ou relancer.