Stratégies de gestion d'erreurs : choisir la bonne approche
Comparez les exceptions, les codes d'erreur et std::expected — apprenez quand chaque approche est appropriée et comment gérer les erreurs dans les constructeurs, destructeurs et le code multithreadé.
Pas de solution universelle
C++ offre plusieurs mécanismes de gestion d'erreurs, chacun avec des compromis différents. Choisir le bon dépend de votre domaine :
Les exceptions sont les mieux adaptées aux erreurs qui sont rares et ne peuvent pas être gérées localement. Elles ont un coût zéro sur le chemin de succès mais sont coûteuses lorsqu'elles sont levées (milliers de nanosecondes). Elles se propagent automatiquement à travers les niveaux d'appel.
Les codes d'erreur (valeurs de retour, errno, std::error_code) sont les mieux adaptés aux erreurs qui sont attendues et fréquentes. Ils ont un petit coût constant à chaque appel (l'appelant doit vérifier la valeur de retour). Ils sont le standard dans les API C et le code au niveau du noyau.
std::expected (C++23) combine le meilleur des deux : il force l'appelant à reconnaître l'erreur (comme les codes d'erreur) mais transporte un type d'erreur riche (comme les exceptions) et supporte le chaînage monadique. Il devient rapidement l'approche préférée dans le nouveau code C++.
L'idée clé : ce ne sont pas des philosophies concurrentes — ce sont des outils pour différentes situations, et le code de production utilise souvent les trois.
Comparaison : Exceptions vs Codes d'erreur vs std::expected
| Critère | Exceptions | Codes d'erreur | std::expected |
|---|---|---|---|
| Coût en cas de succès | Zéro (basé sur tables) | Branchement + vérification | Minimal (stocke la valeur) |
| Coût en cas d'erreur | Élevé (déroulement) | Identique au succès | Identique au succès |
| Peut être ignoré ? | Non (le programme se termine) | Oui (bugs silencieux) | Plus difficile (doit accéder à .value()) |
| Constructeurs | Seule option | Ne peut pas retourner de valeur | Ne peut pas retourner de valeur |
| Destructeurs | Ne doit pas lever | Aucun problème | Aucun problème |
| Composabilité | Limitée (try/catch) | Chaînes de if manuelles | Monadique (and_then, transform) |
| Taille binaire | Plus grande (tables de déroulement) | Minimale | Minimale |
| Sûr pour le temps réel ? | Non (temps non borné) | Oui | Oui |
Gestion d'erreurs dans les constructeurs
Les constructeurs ne peuvent pas retourner de codes d'erreur — ils n'ont pas de valeur de retour. Les exceptions sont le seul mécanisme propre pour signaler un échec de construction. Une exception levée signifie que l'objet n'a jamais été complètement construit, donc son destructeur n'est jamais appelé (mais les destructeurs des sous-objets et bases déjà construits sont appelés).
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>
class ConfigFile {
public:
explicit ConfigFile(const std::string& path)
: path_(path), stream_(path) {
if (!stream_.is_open()) {
throw std::runtime_error(
"Cannot open config file: " + path);
}
// Parse the file — if this throws, stream_ is
// already constructed and its destructor will
// close the file automatically (RAII)
parse();
}
std::string get(const std::string& key) const {
auto it = data_.find(key);
if (it == data_.end()) {
throw std::out_of_range("Key not found: " + key);
}
return it->second;
}
private:
void parse() {
std::string line;
while (std::getline(stream_, line)) {
auto pos = line.find('=');
if (pos != std::string::npos) {
data_[line.substr(0, pos)] = line.substr(pos + 1);
}
}
}
std::string path_;
std::ifstream stream_; // RAII: closed by destructor
std::map<std::string, std::string> data_;
};
// Usage: the object is fully valid or does not exist
int main() {
try {
ConfigFile cfg("/etc/myapp.conf");
std::cout << cfg.get("port") << '\n';
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
}std::error_code pour les erreurs système
std::error_code fournit un mécanisme léger et sans levée pour les erreurs système. La bibliothèque est un excellent exemple — la plupart des fonctions ont une surcharge qui prend un paramètre std::error_code& au lieu de lever.
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
fs::copy_file(src, dst); // throws fs::filesystem_error on failure
}
// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
std::error_code ec;
fs::copy_file(src, dst, ec); // sets ec instead of throwing
if (ec) {
std::cerr << "Copy failed: " << ec.message()
<< " (" << ec.category().name()
<< ':' << ec.value() << ")\n";
return false;
}
return true;
}
int main() {
// The error_code version is preferable in loops where
// failure is expected (e.g., batch file processing)
for (const auto& entry : fs::directory_iterator(".")) {
std::error_code ec;
auto sz = fs::file_size(entry.path(), ec);
if (!ec) {
std::cout << entry.path().filename()
<< ": " << sz << " bytes\n";
}
}
}Les destructeurs en C++11 et ultérieur sont implicitement noexcept. Si un destructeur lève, std::terminate() est appelé. Même si vous marquez explicitement un destructeur noexcept(false), lever pendant le déroulement de pile (quand une autre exception est déjà active) appelle std::terminate().
- Journalisez et supprimez : si une opération de nettoyage échoue, journalisez l'erreur et continuez
- Fournissez une méthode
close()séparée : laissez les appelants gérer explicitement les erreurs de nettoyage avant la destruction - Concevez les ressources pour que le nettoyage ne puisse pas échouer : par exemple, fermer un descripteur de fichier réussit toujours au niveau du système d'exploitation
- N'appelez jamais une fonction pouvant lever depuis un destructeur sans l'envelopper dans un try/catch
Gestion d'erreurs dans le code multithreadé
Les exceptions ne traversent pas les frontières de threads. Si un thread lève et que l'exception n'est pas attrapée au sein de ce thread, std::terminate() est appelé. Pour propager les exceptions entre threads :
1. Utilisez std::future et std::async : les exceptions levées dans la tâche asynchrone sont capturées et relevées lorsque vous appelez .get() sur le future.
2. Utilisez std::exception_ptr avec std::current_exception() et std::rethrow_exception() pour capturer et transporter manuellement les exceptions.
3. Utilisez std::promise::set_exception() pour envoyer une exception d'un thread producteur à un consommateur.
Pour le code concurrent à haut débit, les codes d'erreur ou std::expected sont souvent préférés car ils évitent le coût de l'infrastructure d'exception dans chaque thread et sont plus simples à raisonner à travers les frontières de threads.
Adaptez le mécanisme de gestion d'erreurs à la situation.
- Utilisez les exceptions pour les erreurs rares, qui ne peuvent pas être gérées localement et doivent remonter la pile (par ex., fichier non trouvé, base de données indisponible)
- Utilisez les codes d'erreur pour les opérations à haute fréquence où l'échec est normal et attendu (par ex., miss de cache, E/S non bloquante qui bloquerait)
- Utilisez
std::expectedpour le nouveau code où vous voulez une gestion d'erreurs explicite avec des types d'erreur riches et une composition monadique - Utilisez les exceptions dans les constructeurs — c'est le seul moyen de signaler un échec avant que l'objet n'existe
- Ne jamais lever dans les destructeurs — journalisez l'erreur et supprimez-la
- Exceptions, codes d'erreur et
std::expectedsont des outils complémentaires — pas des philosophies concurrentes - Les constructeurs doivent utiliser les exceptions pour signaler un échec ; les codes d'erreur ne sont pas une option
- Les destructeurs ne doivent jamais lever — ils sont implicitement
noexceptdepuis C++11 - Les exceptions ne traversent pas les frontières de threads — utilisez
std::futureoustd::exception_ptrpour les transférer std::error_codeest idéal pour les erreurs système où vous voulez une API sans levée
Quiz — Testez vos connaissances
(15 XP)1. Pourquoi les exceptions sont-elles le mécanisme de gestion d'erreurs préféré dans les constructeurs ?
2. Que se passe-t-il si une exception est levée dans un destructeur pendant le déroulement de pile d'une autre exception ?
3. Comment les exceptions peuvent-elles être transférées entre threads ?