Exceptions : throw, try, catch
Comprenez le mécanisme d'exceptions C++ — le déroulement de pile, la hiérarchie d'exceptions standard, les exceptions personnalisées et quand préférer les exceptions aux codes d'erreur.
Pourquoi les exceptions existent
Avant les exceptions, le code C signalait les erreurs en retournant des valeurs spéciales (comme -1 ou NULL). Cela avait un défaut critique : les appelants pouvaient ignorer silencieusement l'erreur. Les exceptions résolvent cela en forçant le programme à soit gérer l'erreur, soit se terminer. Elles séparent également le site de détection d'erreur (profond dans le code de bibliothèque) du site de gestion d'erreur (dans le code applicatif), qui peut être séparé de nombreux niveaux d'appel.
Les exceptions C++ suivent un modèle « coût zéro sur le chemin de succès » sur la plupart des compilateurs modernes (utilisant le déroulement basé sur des tables). Quand aucune exception n'est levée, il n'y a aucun surcoût à l'exécution — pas de vérifications if cachées, pas de prédictions de branchement manquées. Le coût n'est payé que lorsqu'une exception est effectivement levée, ce qui rend les exceptions idéales pour les conditions d'erreur exceptionnelles (rares).
throw, try et catch
Le mécanisme de base est simple : throw un objet pour signaler une erreur, enveloppez le code risqué dans un bloc try et catch les types d'exception correspondants.
#include <iostream>
#include <stdexcept>
#include <string>
double divide(double numerator, double denominator) {
if (denominator == 0.0) {
throw std::invalid_argument("denominator cannot be zero");
}
return numerator / denominator;
}
int main() {
try {
double result = divide(10.0, 0.0);
std::cout << "Result: " << result << '\n';
} catch (const std::invalid_argument& e) {
std::cerr << "Invalid argument: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << '\n';
} catch (...) {
std::cerr << "Unknown error occurred\n";
}
}Le déroulement de pile en détail
Lorsqu'une exception est levée, le runtime effectue le déroulement de pile : il remonte la pile d'appels, détruisant toutes les variables locales de chaque frame en appelant leurs destructeurs. C'est pourquoi le RAII (Resource Acquisition Is Initialization) est si crucial — si vous détenez des ressources dans des wrappers RAII, elles sont automatiquement libérées pendant le déroulement.
Le processus de déroulement fonctionne comme suit :
1. L'expression throw crée l'objet exception (généralement dans une zone spéciale de mémoire, pas sur la pile).
2. Le runtime cherche un handler catch correspondant dans la fonction courante.
3. S'il n'en trouve pas, il sort de la fonction courante en détruisant tous les objets locaux dans l'ordre inverse de construction.
4. Il répète les étapes 2-3 en remontant la pile d'appels.
5. Si aucun handler n'est trouvé dans aucune frame, std::terminate() est appelé.
Règle clé : lever par valeur, attraper par référence const. Lever par valeur évite les pointeurs pendants. Attraper par référence const évite le tranchage (slicing) (où une exception dérivée est copiée dans un objet de classe de base, perdant les données dérivées) et évite les copies inutiles.
La hiérarchie d'exceptions standard
La bibliothèque standard fournit une hiérarchie enracinée à std::exception. La comprendre vous aide à lever le bon type et attraper au bon niveau.
// Standard exception hierarchy (simplified)
//
// std::exception
// ├── std::logic_error (programmer mistakes - precondition violations)
// │ ├── std::invalid_argument
// │ ├── std::domain_error
// │ ├── std::length_error
// │ └── std::out_of_range
// ├── std::runtime_error (errors detectable only at runtime)
// │ ├── std::range_error
// │ ├── std::overflow_error
// │ ├── std::underflow_error
// │ └── std::system_error (OS-level errors with error_code)
// └── std::bad_alloc (memory allocation failure)
// std::bad_cast (failed dynamic_cast)
// std::bad_typeid (typeid on null pointer)
#include <stdexcept>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3};
try {
// .at() throws std::out_of_range, unlike operator[] which is UB
int val = v.at(10);
} catch (const std::out_of_range& e) {
std::cerr << "Out of range: " << e.what() << '\n';
}
}Classes d'exceptions personnalisées
Dérivez de std::exception ou de l'une de ses sous-classes pour créer des exceptions spécifiques au domaine. Redéfinissez toujours what() pour fournir un message significatif.
#include <stdexcept>
#include <string>
#include <iostream>
class FileParseError : public std::runtime_error {
public:
FileParseError(const std::string& filename, int line, const std::string& msg)
: std::runtime_error(
"Parse error in '" + filename + "' at line " +
std::to_string(line) + ": " + msg),
filename_(filename),
line_(line) {}
const std::string& filename() const noexcept { return filename_; }
int line() const noexcept { return line_; }
private:
std::string filename_;
int line_;
};
void parse_config(const std::string& filename) {
// Simulate a parse failure at line 42
throw FileParseError(filename, 42, "unexpected token '@@'");
}
int main() {
try {
parse_config("app.conf");
} catch (const FileParseError& e) {
std::cerr << e.what() << '\n';
std::cerr << " File: " << e.filename()
<< ", Line: " << e.line() << '\n';
}
}noexcept et spécifications d'exception
Le spécificateur noexcept indique au compilateur (et aux lecteurs humains) qu'une fonction ne lèvera jamais d'exception. Si une fonction noexcept lève malgré tout, std::terminate() est appelé immédiatement — il n'y a pas de déroulement de pile.
Pourquoi noexcept est-il important ?
- Performance : le compilateur peut omettre les tables de déroulement pour les fonctions noexcept, produisant un code plus compact.
- Optimisation STL : les conteneurs comme std::vector ne déplaceront les éléments pendant la réallocation que si le constructeur de déplacement est noexcept. Sinon, ils se rabattent sur la copie pour maintenir la garantie forte d'exception. Cela peut représenter une différence de performance dramatique.
- Documentation : c'est un contrat avec les appelants que la fonction ne peut pas échouer.
Utilisez noexcept sur : les constructeurs de déplacement, les opérateurs d'affectation par déplacement, les fonctions swap, les destructeurs (implicitement noexcept), les accesseurs simples et les opérateurs de comparaison.
L'ancienne spécification throw() (et throw(type-list)) a été dépréciée en C++11 et supprimée en C++20. Utilisez toujours noexcept à la place.
Ne levez jamais d'exceptions dans les destructeurs. Pendant le déroulement de pile causé par une autre exception, lever une seconde exception appelle std::terminate(). Si un destructeur doit effectuer une opération qui peut échouer, attrapez l'exception à l'intérieur du destructeur et gérez-la là (par ex., journalisez et supprimez).
- Attraper par valeur tranche les exceptions dérivées — attrapez toujours par
const& - Lever des pointeurs (par ex.,
throw new std::runtime_error(...)) mène à des fuites mémoire — levez par valeur - Attraper
...en premier masque tous les handlers spécifiques en dessous — mettez-le en dernier - Les exceptions dans les destructeurs pendant le déroulement de pile causent
std::terminate() - Utiliser les exceptions pour le flux de contrôle (chemins non-erreur) annule le modèle coût zéro et nuit aux performances
- Levez par valeur, attrapez par référence
constpour éviter le tranchage et les fuites - Le déroulement de pile détruit les objets locaux dans l'ordre inverse de construction — le RAII assure le nettoyage
- Les exceptions C++ sont à coût zéro sur le chemin de succès (déroulement basé sur tables), coûteuses uniquement lorsqu'elles sont levées
noexceptactive les optimisations du compilateur et est critique pour les opérations de déplacement utilisées par la STL- Dérivez les exceptions personnalisées de
std::runtime_erroroustd::logic_erroret redéfinissezwhat()
Quiz — Testez vos connaissances
(15 XP)1. Pourquoi devriez-vous attraper les exceptions par `const&` plutôt que par valeur ?
2. Que se passe-t-il si une fonction marquée `noexcept` lève une exception ?
3. Pendant le déroulement de pile, que se passe-t-il pour les variables locales dans chaque frame de pile ?