The C++ Error Handling Battleground:
Exceptions vs. std::expected vs. Outcomes
Error handling in C++ has long been a contentious topic. For decades, the community was split between the "C-style" approach (returning error codes and passing output parameters by reference) and C++ Exceptions. Both had significant drawbacks.
Today, modern C++ has expanded the toolbox. We now have powerful alternatives that aim to combine the explicitness of return codes with the expressiveness of exceptions. This article explores the three major players in contemporary C++ error handling: standard Exceptions, the C++23 newcomer std::expected, and the powerful Boost.Outcome library.
1. The Traditionalist: C++ Exceptions
Exceptions are the standard, built-in mechanism for reporting errors that cannot be handled locally. When an error occurs, you throw an object. The runtime then unwinds the stack, looking for a matching catch block, destroying local objects along the way (RAII).
The "Happy Path" Philosophy
The primary design goal of exceptions is to separate error-handling code from mainline logic. In theory, your code reads cleanly, assuming everything goes right, and errors are handled in dedicated sections elsewhere.
// A traditional function using exceptions
double divide(double a, double b) {
if (b == 0.0) {
throw std::runtime_error("Division by zero!");
}
return a / b;
}
void business_logic() {
try {
// Clean "happy path" code
auto result = divide(10.0, 0.0);
process(result);
} catch (const std::exception& e) {
// Error handling isolated here
std::cerr << "Error: " << e.what() << '\n';
}
}
The Pros
- Clean Code: Main logic isn't cluttered with endless `if (error)` checks.
- Hard to Ignore: Unlike a return code, if you ignore an exception, the program terminates. It forces a decision.
- Constructors: The only way to report a failure during object construction.
The Cons
- Invisible Control Flow: Looking at a function signature
void foo(), you have no idea what it might throw. This makes reasoning about program state difficult. - Performance Costs: While the "happy path" is fast, actually throwing an exception is very expensive (dynamic memory allocation, stack walking). They violate the "zero-overhead principle" if used for common control flow.
- Binary Size & Embedded: Exception support adds significant bloat to the binary and requires runtime support required, making them unusable in many embedded contexts (often disabled via
-fno-exceptions).
2. The Modern Challenger: std::expected (C++23)
Entering the standard in C++23, std::expected<T, E> is a vocabulary type designed for operations that usually succeed but might fail. It is a "sum type" (like std::variant) that holds either a value of type T (success) or an error of type E (failure).
It brings error handling back into the type system, making it explicit in the function signature, but without the clunky output parameters of C-style code.
#include <expected>
#include <string>
// Requires C++23 compatible compiler
enum class MathError { DivisionByZero, NegativeSqrt };
// The signature explicitly states: returns a double OR a MathError
std::expected<double, MathError> safe_divide(double a, double b) {
if (b == 0.0) {
// Return an unexpected value (the error type)
return std::unexpected(MathError::DivisionByZero);
}
return a / b;
}
void modern_business_logic() {
auto result = safe_divide(10.0, 0.0);
if (result.has_value()) {
// Access the success value with * or .value()
process(*result);
} else {
// Handle the error explicitly
handle_error(result.error());
}
}
The Monadic Twist
Where std::expected truly shines is with "monadic operations" like .and_then() and .transform(). These allow you to chain operations that might fail without a pyramid of nested if statements.
// Chaining operations cleanly
auto final_result = get_input()
.and_then(parse_input) // if success, run parse_input
.and_then(calculate) // if success, run calculate
.transform(format_output); // if success, convert result
// If any step failed, final_result holds the first error encountered.
The Pros
- Explicit Interfaces: The function signature tells the whole story. No hidden surprises.
- Zero-Overhead: It is essentially a
struct { union { T val; E err; }; bool has_val; };. No dynamic allocation, no stack walking. Suitable for real-time and embedded systems. - Value Semantics: Errors are just values. They can be stored, passed around threads easily, and don't require special runtime support.
The Cons
- Verbose Happy Path: You cannot ignore the error. You must check
.has_value()before accessing data, which can clutter code if monadic chaining isn't used. - Not for Constructors: You still cannot return values from constructors.
3. The Power User: Boost.Outcome (and ready-to-go Outcome)
Before C++23 standardized std::expected, there was Boost.Outcome (also available as a standalone, non-Boost library). Outcome is essentially the spiritual predecessor to std::expected, but it is significantly more powerful and flexible.
Outcome was designed specifically for mixed environments where you need to interoperate between libraries that use exceptions and libraries that use return codes. Its primary type is outcome::result<T, E = std::error_code, P = std::exception_ptr>.
It can hold:
- A success value (T)
- A failure code (E, usually an enum or
std::error_code) - A catastrophic exception pointer (P)
Why use Outcome over std::expected?
While std::expected is great for pure modern C++ codebases, Outcome excels as a bridge. It provides seamless mechanisms to convert failure codes *into* thrown exceptions if the caller prefers that style.
#include <boost/outcome.hpp>
namespace outcome = boost::outcome_v2;
// Define an enum and register it with std::error_code system (omitted for brevity)
enum class DbError { ConnectionLost, QueryFailed };
// Typical Outcome signature using std::error_code by default
outcome::result<int> query_database() {
if (connection_down) {
// Returns an error state
return DbError::ConnectionLost;
}
return 42; // Returns success
}
void hybrid_consumer() {
// Option 1: Handle like std::expected
auto res = query_database();
if(res) {
use(res.value());
} else {
log(res.error());
}
// Option 2: Convert to exception automatically!
// .value() throws Bad Result Access if it holds an error
try {
int val = query_database().value();
} catch (const std::system_error& e) {
// Outcome automatically converted the error enum to a system_error
}
}
The Pros
- Extreme Flexibility: Handles values, error codes, and exceptions simultaneously.
- The Bridge: Perfect for modernizing old codebases or gluing together disparate libraries.
- Mature: Battle-tested for years before
std::expectedexisted.
The Cons
- Complexity: The API surface is much larger and more complex than
std::expected. - Dependency: Requires adding Boost or the standalone Outcome library to your project.
Summary Comparison Table
| Mechanism | Type of Failure | Performance (Failure Case) | Explicitness | Best Use Case |
|---|---|---|---|---|
| C++ Exceptions | Fatal, Rare, "Run-time" | Slow (allocation, stack unwind) | Low (Invisible in signature) | When failure is truly exceptional and you cannot recover locally. Failsafe. |
| std::expected (C++23) | Business Logic, Common | Fast (Zero overhead) | High (Visible in signature) | The default for modern C++ APIs. Expected failures (file not found, parse error). |
| Boost.Outcome | Hybrid / Interop | Fast (Tunable) | High | Complex library boundaries needing to bridge error codes and exception worlds. |
Conclusion: Which to Choose?
The C++ community is moving away from using Exceptions for control flow. The general consensus for modern C++ development is:
- Use
std::expected(or a polyfill) by default for any failure that is a reasonable outcome of the function call (e.g., parsing failures, network timeouts, validation errors). It makes your APIs honest. - Reserve Exceptions for truly exceptional circumstances. Things like running out of memory (
std::bad_alloc), logic errors that should have been caught by assertions, or constructor failures. - Reach for Outcome if you are building complex library infrastructure that needs maximum flexibility to interoperate with older codebases or different user preferences regarding error handling strategies.
Next article: Writing Readable C++ Code