Error Handling Strategies: Choosing the Right Approach
Compare exceptions, error codes, and std::expected — learn when each approach is appropriate and how to handle errors in constructors, destructors, and multithreaded code.
No One-Size-Fits-All Solution
C++ offers multiple error handling mechanisms, each with different tradeoffs. Choosing the right one depends on your domain:
Exceptions are best for errors that are rare and cannot be handled locally. They have zero cost on the success path but are expensive when thrown ($1000s of nanoseconds). They propagate automatically across call frames.
Error codes (return values, errno, std::error_code) are best for errors that are expected and frequent. They have a small, constant cost on every call (the caller must check the return value). They are the standard in C APIs and kernel-level code.
std::expected (C++23) combines the best of both: it forces the caller to acknowledge the error (like error codes) but carries a rich error type (like exceptions) and supports monadic chaining. It is rapidly becoming the preferred approach in new C++ code.
The key insight: these are not competing philosophies — they are tools for different situations, and production code often uses all three.
Comparison: Exceptions vs Error Codes vs std::expected
| Criterion | Exceptions | Error Codes | std::expected |
|---|---|---|---|
| Cost on success | Zero (table-based) | Branch + check | Minimal (stores value) |
| Cost on error | High (unwinding) | Same as success | Same as success |
| Can be ignored? | No (program terminates) | Yes (silent bugs) | Harder (must access .value()) |
| Constructors | Only option | Cannot return a value | Cannot return a value |
| Destructors | Must not throw | Fine | Fine |
| Composability | Limited (try/catch) | Manual if-chains | Monadic (and_then, transform) |
| Binary size | Larger (unwind tables) | Minimal | Minimal |
| Real-time safe? | No (unbounded time) | Yes | Yes |
Error Handling in Constructors
Constructors cannot return error codes — they have no return value. Exceptions are the only clean mechanism for signaling construction failure. A thrown exception means the object was never fully constructed, so its destructor is never called (but destructors of already-constructed sub-objects and bases are called).
#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 for System-Level Errors
std::error_code provides a lightweight, non-throwing mechanism for system-level errors. The library is a great example — most functions have an overload that takes an std::error_code& parameter instead of throwing.
#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";
}
}
}Destructors in C++11 and later are implicitly noexcept. If a destructor throws, std::terminate() is called. Even if you explicitly mark a destructor noexcept(false), throwing during stack unwinding (when another exception is already active) calls std::terminate().
- Log and suppress: If a cleanup operation fails, log the error and continue
- Provide a separate
close()method: Let callers explicitly handle cleanup errors before destruction - Design resources so cleanup cannot fail: For example, closing a file descriptor always succeeds at the OS level
- Never call a throwing function from a destructor without wrapping it in a try/catch
Error Handling in Multithreaded Code
Exceptions do not cross thread boundaries. If a thread throws and the exception is not caught within that thread, std::terminate() is called. To propagate exceptions between threads:
1. Use std::future and std::async: Exceptions thrown in the async task are captured and re-thrown when you call .get() on the future.
2. Use std::exception_ptr with std::current_exception() and std::rethrow_exception() to manually capture and transport exceptions.
3. Use std::promise::set_exception() to send an exception from a producer thread to a consumer.
For high-throughput concurrent code, error codes or std::expected are often preferred because they avoid the cost of exception infrastructure in each thread and are simpler to reason about across thread boundaries.
Match the error handling mechanism to the situation.
- Use exceptions for errors that are rare, cannot be handled locally, and should propagate up the stack (e.g., file not found, database down)
- Use error codes for high-frequency operations where failure is normal and expected (e.g., cache lookup miss, non-blocking I/O would-block)
- Use
std::expectedfor new code where you want explicit error handling with rich error types and monadic composition - Use exceptions in constructors — they are the only way to signal failure before the object exists
- Never throw in destructors — log the error and suppress it
- Exceptions, error codes, and
std::expectedare complementary tools — not competing philosophies - Constructors must use exceptions to signal failure; error codes are not an option
- Destructors must never throw — they are implicitly
noexceptsince C++11 - Exceptions do not cross thread boundaries — use
std::futureorstd::exception_ptrto transfer them std::error_codeis ideal for system-level errors where you want a non-throwing API
Quiz — Test Your Knowledge
(15 XP)1. Why are exceptions the preferred error handling mechanism in constructors?
2. What happens if an exception is thrown in a destructor during stack unwinding from another exception?
3. How can exceptions be transferred between threads?