C++ Exception Handling Overview
Technically? No. Strategically? Absolutely.
C++ doesn't force you to use try-catch blocks. The compiler won't complain if you ignore them, and many systems (like embedded software or high-frequency trading engines) often disable them entirely for performance or predictability.
However, in modern C++, exception handling is the standard way to deal with anomalies—those "uh-oh" moments that your logic can't resolve on its own.
Without exceptions, you usually rely on return codes (e.g., returning -1 or nullptr when something fails). While this works, it has major drawbacks:
Silent Failures: It’s easy to forget to check a return value, allowing the program to continue in a "broken" state.
Cluttered Logic: Your clean code gets buried under if (error) checks at every second line.
Constructor Failures: Constructors don't return values. If an object fails to initialize, throwing an exception is the only clean way to signal that the object shouldn't exist.
There are specific scenarios where you almost have to use (or at least understand) exceptions:
The Standard Library (STL): Many STL functions throw exceptions. For example, std::vector::at() throws std::out_of_range if you miss the index. If you don't catch it, your program simply crashes.
Memory Allocation: If new fails to allocate memory, it throws std::bad_alloc.
RAII (Resource Acquisition Is Initialization): This is the "C++ way" of managing resources. Exceptions work hand-in-hand with destructors to ensure that if a crash happens, files are closed and memory is freed automatically as the stack unwinds.
C++ gives you the freedom to opt-out. You might avoid exceptions if:
Real-time Constraints: In some systems, the time it takes to "find" a catch block is too unpredictable.
Binary Size: Exception handling adds a bit of overhead to the executable size.
No-Throw Guarantees: If you are writing a low-level driver, you might use the noexcept keyword to promise the compiler that your code will never throw.
| Feature | Exception Handling | Return Codes (Manual) |
| Error Propagation | Automatic (bubbles up the stack). | Manual (must be passed up). |
| Readability | High (separates logic from errors). | Low (clutters business logic). |
| Performance | Tiny overhead if no error occurs. | Constant overhead (checking every call). |
| Safety | Harder to ignore. | Very easy to ignore. |
You don't have to use them, but writing robust, professional C++ without them is like tightrope walking without a net. You can do it, but one slip-up and the whole show ends abruptly.
For most applications, the goal is to throw rarely but catch systematically.
Let's compare the two approaches. Imagine we are writing a function that divides two numbers. We need to handle the "division by zero" error.
In this style, the function returns a status (like a boolean or an integer) and uses a separate variable or a pointer to return the actual result.
C++
#include <iostream>
bool safeDivide(double a, double b, double &result) {
if (b == 0) return false; // Error: Manual signal
result = a / b;
return true; // Success
}
int main() {
double res;
if (safeDivide(10, 0, res)) {
std::cout << "Result: " << res << std::endl;
} else {
std::cerr << "Error: Division by zero!" << std::endl;
}
return 0;
}
The Problem: The "real" work is buried inside an if/else block. If safeDivide was called deep inside ten other functions, every single one of those functions would need if statements to pass that error back up the chain.
Here, the function focuses on the math. If something goes wrong, it "throws" the problem out of the local scope.
C++
#include <iostream>
#include <stdexcept>
double divide(double a, double b) {
if (b == 0) {
throw std::runtime_error("Division by zero"); // Signal anomaly
}
return a / b;
}
int main() {
try {
double res = divide(10, 0);
std::cout << "Result: " << res << std::endl;
} catch (const std::runtime_error& e) {
std::cerr << "Caught exception: " << e.what() << std::endl;
}
return 0;
}
The Benefit: The divide function is clean and returns the result directly. If an error occurs, C++ pauses execution and looks for the nearest catch block. This process of jumping out of functions to find a handler is called Stack Unwinding.
Return Codes are like checking your car's oil every 5 miles manually.
Exceptions are like a "Check Engine" light that turns on only when there is actually a problem.
Creating your own exception class is a great way to make your code more readable. Instead of throwing a generic "error," you can throw something specific like DatabaseConnectionError or InvalidSensorReading.
In C++, the best practice is to inherit from std::exception (or its children like std::runtime_error). This ensures your custom error plays nicely with the rest of the C++ ecosystem.
Imagine you are building a banking app. You want to throw an error specifically when a user tries to withdraw more money than they have.
C++
#include <iostream>
#include <exception>
#include <string>
// 1. Create the custom class inheriting from std::exception
class InsufficientFundsException : public std::exception {
private:
std::string message;
public:
// Constructor to store the error details
InsufficientFundsException(double balance, double withdrawal) {
message = "Transaction Failed: Balance is $" + std::to_string(balance) +
", but attempted to withdraw $" + std::to_string(withdrawal);
}
// 2. Override the 'what()' method to return our message
// 'noexcept' promises this function won't throw an error itself
const char* what() const noexcept override {
return message.c_str();
}
};
void withdraw(double balance, double amount) {
if (amount > balance) {
throw InsufficientFundsException(balance, amount);
}
std::cout << "Withdrawal successful! Remaining: $" << (balance - amount) << std::endl;
}
int main() {
try {
withdraw(50.00, 100.00); // This will trigger our exception
} catch (const InsufficientFundsException& e) {
std::cerr << e.what() << std::endl;
}
return 0;
}
std::exception?Inheriting creates a clear hierarchy. This allows you to catch specific errors first, then catch "anything else" later using the base class.
The what() method: Always override this. It’s the standard way for the catch block to get a human-readable string about what went wrong.
Catch by Reference: Notice the const InsufficientFundsException& e in the catch block. Always catch by reference to avoid "slicing" (where the custom parts of your error object get cut off).
Keep it light: Don't do heavy processing inside an exception constructor. If you're already in an "error state," you want the exception to be created as fast and safely as possible.
Since you said "yes," let's look at handling multiple exception types. In a real-world application, a single piece of code might fail for several different reasons. You can stack catch blocks like a switch statement to handle each error differently.
Imagine a function that reads a file and processes its data. Two things could go wrong: the file might not exist (File Error), or the data inside might be corrupted (Validation Error).
C++
try {
// 1. Try to open the file
openFile("data.txt");
// 2. Try to process a specific record
processRecord(-5);
}
catch (const FileNotFoundError& e) {
// Handle missing file (maybe create a default one?)
std::cerr << "File Error: " << e.what() << std::endl;
}
catch (const std::invalid_argument& e) {
// Handle bad data (skip this record?)
std::cerr << "Data Error: " << e.what() << std::endl;
}
catch (const std::exception& e) {
// Catch-all for any other standard exceptions
std::cerr << "Unexpected Error: " << e.what() << std::endl;
}
catch (...) {
// The "Ellipsis" catch: Catches literally anything else
std::cerr << "Unknown disaster occurred!" << std::endl;
}
C++ checks catch blocks from top to bottom. You should always put the most specific exception first and the most general one last.
If you put catch (std::exception& e) at the very top, it would "trap" your custom InsufficientFundsException before it reached its own specific block, because your custom class is technically a type of std::exception.
noexceptSince we're talking about professional coding, we have to mention performance.
In C++, you can mark a function as noexcept. This tells the compiler: "I promise this function will never throw an exception."
C++
void moveRobot(int x, int y) noexcept {
// Optimization happens here!
}
Why use it?
Speed: The compiler can skip generating the "cleanup code" (stack unwinding info) for that function, making the binary smaller and sometimes faster.
Move Semantics: Standard containers (like std::vector) are much faster if your move constructors are marked noexcept. If a vector needs to resize and your move constructor might throw, the vector will safely (but slowly) copy everything instead.
Specifics first: Catch the most detailed error types at the top.
Catch by Reference: Use const MyException& e to avoid object slicing.
Use noexcept: Apply it to move constructors and destructors to help the compiler optimize.
In C++, exceptions are organized in a class hierarchy defined in the <stdexcept> and <exception> headers. All standard exceptions derive from the base class std::exception.
Think of it like a family tree: if your code runs into trouble, it throws one of these "types" of errors to let you know exactly what went wrong.
std::exception: The root of the hierarchy. It provides a virtual member function what() that returns a null-terminated character sequence describing the error.
std::logic_error)These represent errors in the internal logic of the program that could, in theory, be prevented by better coding or pre-condition checking.
| Exception | Meaning |
std::invalid_argument |
An invalid argument was passed to a function. |
std::domain_error |
A mathematical domain error (e.g., using a value outside the allowed range). |
std::length_error |
An attempt to exceed the maximum allowed size for an object (like a std::string). |
std::out_of_range |
An argument is outside the valid range (e.g., at() in a vector). |
std::future_error |
Specific to asynchronous programming (the std::future class). |
std::runtime_error)These are errors that occur due to events beyond the scope of the program's internal logic (e.g., external connectivity or environmental factors).
| Exception | Meaning |
std::range_error |
A computation result is outside the representable range. |
std::overflow_error |
An arithmetic overflow occurred. |
std::underflow_error |
An arithmetic underflow occurred. |
std::system_error |
Thrown by the operating system (e.g., file or network failure). |
These are thrown by the C++ language keywords or standard library components when specific operations fail.
std::bad_alloc: Thrown by new when memory allocation fails.
std::bad_cast: Thrown by dynamic_cast when a reference cast fails.
std::bad_typeid: Thrown when typeid is applied to a null pointer of a polymorphic type.
std::bad_exception: Thrown when an exception is thrown that doesn't match the function's dynamic exception specification (mostly deprecated in modern C++).
std::bad_variant_access: Thrown when accessing a std::variant incorrectly.
std::bad_optional_access: Thrown when accessing a std::optional that contains no value.
noexcept KeywordIn modern C++ (C++11 and later), you can mark functions as noexcept. This tells the compiler that the function will not throw an exception. If it does, the program calls std::terminate() immediately. It's a great way to optimize performance for move constructors and swap functions.
Creating a custom exception is a standard practice when you want to provide specific error context that the standard library doesn't cover. By inheriting from std::exception, your custom error can still be caught by a generic catch (const std::exception& e) block.
Here is a clean, modern way to implement one:
C++
#include <iostream>
#include <exception>
#include <string>
// Inherit from std::exception or a more specific child like std::runtime_error
class DatabaseConnectionException : public std::exception {
private:
std::string message;
int error_code;
public:
// Constructor to pass custom details
DatabaseConnectionException(const std::string& msg, int code)
: message("DB_ERROR: " + msg), error_code(code) {}
// Override the what() method to return your message
// 'noexcept' is required here because the base class defines it that way
const char* what() const noexcept override {
return message.c_str();
}
int get_code() const { return error_code; }
};
In your code, you throw it like any other object and catch it by reference to avoid slicing (losing the custom data if you caught it as a base std::exception).
C++
void connectToDatabase() {
bool connectionFailed = true;
if (connectionFailed) {
throw DatabaseConnectionException("Timeout after 30s", 404);
}
}
int main() {
try {
connectToDatabase();
} catch (const DatabaseConnectionException& e) {
std::cerr << "Specific Catch: " << e.what() << " (Code: " << e.get_code() << ")" << std::endl;
} catch (const std::exception& e) {
std::cerr << "General Catch: " << e.what() << std::endl;
}
return 0;
}
std::exception?Polymorphism: You can catch all standard and custom exceptions in one go using the base class.
Consistency: It forces you to provide a .what() method, making your API predictable for other developers.
Safety: The standard base class is designed to be "exception-safe" itself (it doesn't throw while being copied or destroyed).
Handling exceptions across threads is a bit like passing a baton in a relay race. Because each thread has its own independent stack, a throw in a worker thread won't naturally be caught by a try-catch block in your main() thread.
To bridge this gap, C++ provides a mechanism to "capture" an exception as an object and transport it between threads.
std::exception_ptr: A shared-pointer-like type that holds the exception. It is thread-safe to copy.
std::current_exception(): Captures the exception currently being handled in a catch block and returns a std::exception_ptr.
std::rethrow_exception(ptr): Takes that pointer and throws the exception again in the current thread.
In this scenario, we use a global or shared std::exception_ptr to move the error from a background worker to the main thread.
C++
#include <iostream>
#include <thread>
#include <exception>
#include <stdexcept>
std::exception_ptr globalExceptionPtr = nullptr;
void workerThread() {
try {
// Simulate a failure
throw std::runtime_error("Critical failure in worker thread!");
} catch (...) {
// Capture the exception currently being handled
globalExceptionPtr = std::current_exception();
}
}
int main() {
std::thread t(workerThread);
t.join(); // Wait for the worker to finish
if (globalExceptionPtr) {
try {
// Re-throw the exception in the context of the main thread
std::rethrow_exception(globalExceptionPtr);
} catch (const std::exception& e) {
std::cout << "Caught in main: " << e.what() << std::endl;
}
} else {
std::cout << "Worker thread finished successfully." << std::endl;
}
return 0;
}
std::futureIf you are using std::async, you don't even need to manage the exception_ptr manually. The C++ standard library handles this for you!
A function inside std::async throws an exception.
The library catches it and stores it inside the shared state.
When you call .get() on the std::future object, the exception is automatically re-thrown in your face.
C++
#include <future>
int main() {
auto handle = std::async(std::launch::async, []() {
throw std::out_of_range("Async error!");
return 42;
});
try {
int result = handle.get(); // The exception is re-thrown HERE
} catch (const std::out_of_range& e) {
std::cerr << "Caught: " << e.what() << std::endl;
}
}
Pro Tip: Always prefer std::future and std::promise for simple task-based threading; it makes error handling significantly cleaner and less prone to "silent crashes" where a thread dies and nobody notices.
Exception safety is the art of ensuring that when a function throws an exception, the program doesn't end up in a "zombie state" (leaked memory, corrupted files, or half-finished objects).
In C++, we categorize code into four levels of safety. Think of these as a "contract" between you and the user of your function.
| Level | Name | Description |
| 0 | No Guarantee | If an exception occurs, all bets are off. Memory might leak, or data might be corrupted. |
| 1 | Basic Guarantee | The program remains in a valid state. No memory leaks occur, but data might be modified from its original state. |
| 2 | Strong Guarantee | "Commit or Rollback." If the operation fails, the state of the program is exactly as it was before the call. |
| 3 | Nothrow (noexcept) |
The function is guaranteed to never throw an exception. Essential for destructors and move operations. |
The most common way to provide the Strong Guarantee is the Copy-and-Swap idiom. Instead of modifying the original data directly (which might fail halfway through), you:
Make a copy of the data.
Modify the copy (if this throws, the original is untouched).
Swap the copy with the original using a noexcept operation.
C++
void MyClass::updateData(const Data& newData) {
// 1. Copy (might throw std::bad_alloc)
Data temp = newData;
// 2. Modify temp (might throw logic_error)
temp.validate();
// 3. Swap (Guaranteed not to throw)
std::swap(this->internalData, temp);
}
To provide the Basic Guarantee (preventing leaks), C++ uses RAII. You should almost never use raw new and delete. Instead, wrap resources in objects (like smart pointers) whose destructors clean up automatically.
C++
void processFile() {
// Using a smart pointer ensures memory is freed even if 'doWork' throws.
auto buffer = std::make_unique<char[]>(1024);
// Using a file stream ensures the file is closed automatically.
std::ifstream file("data.txt");
doWork(file, buffer.get());
// If an exception happens here, 'file' and 'buffer' are still destroyed!
}
noexcept" is CriticalYou should mark your destructors, move constructors, and swap functions as noexcept.
If a move constructor isn't marked noexcept, containers like std::vector will default to the slower "copy" operation during resizing to maintain the Strong Guarantee. By adding noexcept, you're telling the compiler: "It's safe to move this; it won't break halfway through."
This is one of those "invisible" performance wins in C++. The std::vector is a paranoid container—it prioritizes data integrity over speed. If it needs to reallocate memory to grow, it will only move your objects if it is 100% sure the move won't throw an exception.
If your move constructor is not marked noexcept, the vector will copy every single element instead, which is significantly slower.
noexcept vs. Throwing| Feature | MoveConstructor() | MoveConstructor() noexcept |
| Vector Resize Strategy | Copy (Safe but slow) | Move (Safe and fast) |
| Performance | $O(n)$ allocations/copies | $O(n)$ pointer swaps |
| Exception Safety | Strong Guarantee | Strong Guarantee |
| Memory Usage | Temporary double-dip during copy | Minimal |
Imagine a std::vector resizing. It allocates a new, larger block of memory and starts moving elements from the old block to the new one.
The Risk: If the 5th element's move constructor throws an exception halfway through, the first 4 elements are already in the new block (and their old versions might be destroyed), while the remaining elements are still in the old block. The vector is now "broken" in two pieces.
The Solution: If the move constructor is noexcept, the vector knows this disaster cannot happen, so it uses the fast move. If not, it copies the elements. If a copy throws, the vector simply deletes the new block and keeps the old one perfectly intact (Strong Guarantee).
Notice how std::move_if_noexcept is the magic utility the vector uses under the hood to make this decision.
C++
#include <iostream>
#include <vector>
#include <utility>
class SlowAndSafe {
public:
SlowAndSafe() {}
// No noexcept here!
SlowAndSafe(SlowAndSafe&& other) {
std::cout << "Copying (simulated) because move might throw!\n";
}
};
class FastAndEfficient {
public:
FastAndEfficient() {}
// Marked noexcept - the green light for the compiler
FastAndEfficient(FastAndEfficient&& other) noexcept {
std::cout << "Moving efficiently!\n";
}
};
int main() {
std::vector<SlowAndSafe> v1(2);
std::cout << "Pushing to v1 (triggering resize):\n";
v1.push_back(SlowAndSafe()); // Will trigger copies
std::vector<FastAndEfficient> v2(2);
std::cout << "\nPushing to v2 (triggering resize):\n";
v2.push_back(FastAndEfficient()); // Will trigger moves
}
If you write a move constructor, always mark it noexcept unless you have a very specific reason not to. Without that keyword, you are essentially opting out of the most significant performance optimizations in the C++ Standard Library.
Using static_assert along with type traits is like setting up an automated security guard for your code. It ensures that if a developer (including your future self) accidentally adds a member to a class that makes it "throwing" or "non-movable," the code simply won't compile.
This prevents performance regressions from ever reaching your production environment.
std::is_nothrow_move_constructibleThe <type_traits> header provides templates that inspect your classes at compile-time. When combined with static_assert, you can enforce the performance standards we just discussed.
C++
#include <type_traits>
#include <vector>
#include <string>
class UserData {
public:
std::string name;
std::vector<int> scores;
// If you forget 'noexcept' here, the static_assert below will fail!
UserData(UserData&& other) noexcept
: name(std::move(other.name)), scores(std::move(other.scores)) {}
};
// This line acts as a compile-time unit test.
// If UserData is NOT noexcept-movable, the build stops here.
static_assert(std::is_nothrow_move_constructible<UserData>::value,
"UserData MUST have a noexcept move constructor for vector performance!");
int main() {
// Guaranteed to be fast during resizes
std::vector<UserData> users;
return 0;
}
static_assert for Exceptions?Zero Runtime Cost: These checks happen while you are building the app. The final binary contains no extra code.
Documentation that Can't Lie: Unlike a comment that says // This is fast, a static_assert proves it.
Catching "Contamination": If you add a new member to UserData that is not noexcept movable (like an old-school custom string class), UserData itself becomes non-noexcept. The static_assert will catch this immediately.
You can use several traits depending on what you want to enforce:
| Trait | What it Checks |
std::is_nothrow_move_constructible<T> |
Can it be moved without throwing? |
std::is_nothrow_destructible<T> |
Is the destructor safe (required for almost everything)? |
std::is_nothrow_copy_constructible<T> |
Can it be copied without throwing? (Rare but useful for small structs). |
std::is_nothrow_assignable<T, U> |
Is the = operator safe? |
Throw by value, catch by const reference, and mark your moves/destructors
noexcept.
By following this, you get the best of both worlds: robust error handling and the blazing-fast performance C++ is known for.
While exceptions are the standard way to handle errors in C++, they aren't always the right tool. In performance-critical loops or embedded systems, the "stack unwinding" process (the overhead of searching for a catch block) can be too heavy.
Modern C++ provides alternatives that give you the safety of an error message with the speed of a simple if statement.
std::error_code (The System Way)Used heavily in networking and filesystem operations, std::error_code is essentially a "smart enum." It combines an integer error value with a category so you know if the error came from the OS, a HTTP header, or your own custom logic.
C++
#include <system_error>
#include <iostream>
std::error_code do_heavy_lifting() {
if (/* hardware failure */ false) {
return std::make_error_code(std::errc::device_or_resource_busy);
}
return {}; // Returns a "success" error_code (value 0)
}
int main() {
auto ec = do_heavy_lifting();
if (ec) { // Converts to true if there is an error
std::cerr << "Error: " << ec.message() << "\n";
}
}
std::expected (The C++23 Way)This is the "modern gold standard" for error handling without exceptions. A std::expected<T, E> either contains the expected value ($T$) or an unexpected error ($E$).
It’s like a box that says: "I'm either a valid Result or an Error, but never both and never neither."
C++
#include <expected>
#include <string>
#include <iostream>
// Returns a double OR a string error message
std::expected<double, std::string> safe_divide(double a, double b) {
if (b == 0.0) {
return std::unexpected("Division by zero!");
}
return a / b;
}
int main() {
auto result = safe_divide(10.0, 0.0);
if (result) {
std::cout << "Result: " << *result << std::endl;
} else {
std::cerr << "Error: " << result.error() << std::endl;
}
}
| Method | Overhead | Use Case |
| Exceptions | High (only when thrown) | Exceptional, rare errors (Out of memory, Disk full). |
std::error_code |
Very Low | System-level APIs, networking, and filesystem. |
std::expected |
Minimal | Business logic where "errors" are common (e.g., Invalid User Input). |
[Image comparing C++ Exception stack unwinding vs std::expected return path]
Exceptions jump across the call stack, potentially skipping many functions.
std::expected returns normally through every function, forcing the caller to acknowledge the possibility of failure.
In C++23, std::expected (and also std::optional) received "monadic" operations. This is a fancy way of saying you can chain operations together without writing a dozen if (result) checks. It makes your code look like a clean pipeline of data.
Instead of an "if-else" ladder, your logic flows through a series of transformations.
The three main functions you'll use are:
.and_then(): If the previous step succeeded, run this next function (which also returns a std::expected).
.transform(): If the previous step succeeded, apply this function to the value (it maps the value but doesn't change the error state).
.or_else(): Only runs if an error occurred, allowing you to catch or fix it.
Imagine a sequence where we need to fetch a user, check their permissions, and then format their name. Any step could fail.
C++
#include <iostream>
#include <expected>
#include <string>
struct User { int id; std::string name; bool isAdmin; };
// Step 1: Find the user
std::expected<User, std::string> findUser(int id) {
if (id == 42) return User{42, "Alice", true};
return std::unexpected("User not found");
}
// Step 2: Check permissions
std::expected<User, std::string> checkAdmin(User u) {
if (u.isAdmin) return u;
return std::unexpected("Access denied: Not an admin");
}
int main() {
int inputId = 42;
auto result = findUser(inputId)
.and_then(checkAdmin) // Only runs if user found
.transform([](User u) { // Only runs if admin check passed
return "Welcome, " + u.name;
})
.or_else([](std::string err) { // Only runs if ANY previous step failed
return std::expected<std::string, std::string>("Guest Access: " + err);
});
std::cout << *result << std::endl;
}
| Style | Readability | Error Handling |
Traditional if |
Can become "Nested If Hell" | Manual at every single line. |
| Exceptions | Clean, but "invisible" jumps | Hard to see exactly where it might fail. |
Monadic (and_then) |
Very clean "Railroad" logic | Explicit, visible, and performance-friendly. |
noexcept ConnectionEven when using std::expected, the functions inside .and_then() should ideally be noexcept. This ensures that your error-handling pipeline doesn't accidentally trigger a heavy stack-unwind exception halfway through a "lightweight" error check.
Before C++20, if you wanted to know which file or line number triggered an error, you had to use ugly preprocessor macros like __FILE__ and __LINE__.
With std::source_location, the compiler handles this as a first-class object. It’s cleaner, safer, and works perfectly with both exceptions and std::expected.
The trick to std::source_location is passing it as a default argument to a constructor or function. Because it is evaluated at the caller's site, it captures the exact line where the error happened, not the line where the exception class was defined.
C++
#include <iostream>
#include <string>
#include <source_location>
#include <stdexcept>
class DetailedException : public std::runtime_error {
public:
DetailedException(const std::string& msg,
const std::source_location& loc = std::source_location::current())
: std::runtime_error(msg), location(loc) {}
void print_debug() const {
std::cerr << "Error: " << what() << "\n"
<< "File: " << location.file_name() << "\n"
<< "Line: " << location.line() << "\n"
<< "Func: " << location.function_name() << "\n";
}
private:
std::source_location location;
};
std::expectedYou can wrap your error type in a struct to carry the location data along with your error code. This gives you "exception-like" debugging info with "return-code" performance.
C++
struct ErrorInfo {
std::string message;
std::source_location location;
// Static helper to capture location automatically
static ErrorInfo create(std::string msg,
std::source_location loc = std::source_location::current()) {
return { std::move(msg), loc };
}
};
std::expected<int, ErrorInfo> performTask(int val) {
if (val < 0) {
return std::unexpected(ErrorInfo::create("Value cannot be negative"));
}
return val * 2;
}
int main() {
auto res = performTask(-5);
if (!res) {
std::cout << "Failure at " << res.error().location.line() << ": "
<< res.error().message << std::endl;
}
}
Macro-Free: No more #define THROW_ERR(msg) .... Your code stays as pure C++.
No Performance Hit: The compiler simply embeds a few constants (strings and ints) at the call site.
Audit Trails: In complex systems, you can log exactly which branch of a deeply nested if statement triggered the failure.
We’ve moved from basic std::exception to:
Safety: RAII and noexcept for move operations.
Performance: std::expected for hot paths.
Elegance: Monadic chaining for clean logic.
Observability: std::source_location for debugging.
Integrating everything we've discussed into a Thread Pool is the ultimate "final boss" of C++ error handling. You need to manage asynchronous execution, transport exceptions across thread boundaries, and maintain performance.
A modern Thread Pool shouldn't just run code; it should return a "Ticket" (a std::future) that eventually contains either your result or an error.
In this design, we use std::packaged_task. It’s a wrapper that links a function to a std::promise. If the function throws an exception, the packaged_task automatically catches it and stores it in the associated std::future.
This example combines RAII for thread management and exception transport for error handling.
C++
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <future>
#include <functional>
#include <condition_variable>
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable cv;
bool stop = false;
public:
ThreadPool(size_t threads) {
for(size_t i = 0; i < threads; ++i)
workers.emplace_back([this] {
for(;;) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->cv.wait(lock, [this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty()) return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task(); // If this throws, std::packaged_task catches it!
}
});
}
// Use templates to handle any function and its arguments
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args) -> std::future<typename std::invoke_result<F, Args...>::type> {
using return_type = typename std::invoke_result<F, Args...>::type;
// Wrap the task in a packaged_task so it can handle exceptions
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace([task](){ (*task)(); });
}
cv.notify_one();
return res;
}
~ThreadPool() {
{ std::unique_lock<std::mutex> lock(queue_mutex); stop = true; }
cv.notify_all();
for(std::thread &worker: workers) worker.join();
}
};
Now, let's see how the main thread handles a crash that happened elsewhere.
C++
int main() {
ThreadPool pool(4);
// Enqueue a job that will fail
auto future = pool.enqueue([](int x) {
if (x < 0) throw std::invalid_argument("Negative value passed to worker!");
return x * 2;
}, -5);
try {
// .get() blocks until the worker is done.
// If the worker threw an exception, .get() re-throws it here.
int result = future.get();
std::cout << "Result: " << result << std::endl;
} catch (const std::exception& e) {
std::cerr << "Caught worker error in main: " << e.what() << std::endl;
}
return 0;
}
No Dead Threads: Because std::packaged_task catches the exception internally, the worker thread's loop doesn't break. It just moves on to the next task in the queue.
Type Safety: We use std::invoke_result to ensure the std::future matches the function's return type perfectly.
Clean Exit: The destructor uses a stop flag and join() to ensure all threads finish their current work before the program exits (RAII).
Notice that we used std::move and std::forward throughout. If your task objects have noexcept move constructors (like we discussed earlier), this thread pool will be incredibly fast, moving tasks into the queue with zero unnecessary copies.
To truly understand why we debate exceptions vs. error codes, we have to look at the "Happy Path" vs. the "Sad Path."
In C++, exceptions are designed on the "Zero-Cost" principle. This doesn't mean they are free; it means you don't pay for them unless you throw them.
Exceptions: Blazing fast. The compiler places "unwind tables" in a separate section of the binary. Your CPU execution doesn't have to check any if conditions.
std::expected / Error Codes: Slightly slower. Every function call must perform a branch check "if(result == error){}" to decide whether to continue.
Exceptions: Extremely slow. The OS must pause execution, search the unwind tables, find the catch block, and destroy all objects on the stack between the throw and the catch.
std::expected / Error Codes: Very fast. It’s just a standard return and an if jump.
If we were to run a tight loop 1,000,000 times, here is how the timing typically looks (relative scale):
| Operation | Time (Happy Path) | Time (Sad Path/Error) |
| Simple Return (int) | $1\times$ | $1\times$ |
std::expected |
$1.2\times$ | $1.2\times$ |
| Exceptions | $0.9\times$ | $100\times$ to $1000\times$ |
The Verdict: If your error happens more than 1% of the time, use
std::expected. If the error is truly rare (less than 0.1%), use exceptions to keep your "Happy Path" as fast as possible.
While exceptions make the "Happy Path" faster, they make the binary size larger. The unwind tables take up space on your disk.
In embedded systems with very limited flash memory (like an Arduino or a custom medical device), developers often compile with "-fno-exceptions". In this mode:
The try, catch, and throw keywords are disabled.
The binary size shrinks significantly (often by 15-30%).
std::expected and std::error_code become the only way to handle errors.
If you want to measure this yourself, you can use a tool like Google Benchmark. You would write two identical functions—one using throw and one using return std::unexpected—and compare the "Mean Time" in the benchmark output.
Game Engine: Use std::expected for logic (hitting a wall) and exceptions for "Engine cannot start."
Financial Trading: Avoid exceptions in the "hot path" (placing an order) to ensure predictable latency (no "spikes" when an error occurs).
Desktop App: Use exceptions freely; they make code more readable and the performance hit on a modern PC is negligible for UI tasks.
We’ve covered the full spectrum of C++ error handling from the 1980s to C++26.