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.

1. The "Why" Behind Exceptions

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.

2. When It Is "Necessary"

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.

3. When to Avoid Them

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.

Comparison: Exceptions vs. Return Codes

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.

The Verdict

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.

Approach 1: Return Codes (The Old Way)

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.

Approach 2: Exception Handling (The Modern Way)

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.

Key Takeaway

  • 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.

Example: A Custom "Insufficient Funds" Exception

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;
}

Why inherit from std::exception?

Inheriting creates a clear hierarchy. This allows you to catch specific errors first, then catch "anything else" later using the base class.

Best Practices for Custom Exceptions

  • 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.

1. Handling Multiple Exceptions

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;
}

2. Why the Order Matters

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.

3. The Performance Side: noexcept

Since 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.

Summary Checklist

  • 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.

The Base Class

  • std::exception: The root of the hierarchy. It provides a virtual member function what() that returns a null-terminated character sequence describing the error.

Logic Errors (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).

Runtime Errors (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).

Language & Library Support Exceptions

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.

Professional-Tip: The noexcept Keyword

In 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:

Custom Exception Implementation

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; }
};

How to Use It

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;
}

Why inherit from std::exception?

  1. Polymorphism: You can catch all standard and custom exceptions in one go using the base class.

  2. Consistency: It forces you to provide a .what() method, making your API predictable for other developers.

  3. 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.

Key Components

  • 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.

Practical Example: Cross-Thread Transport

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;
}

The Modern Alternative: std::future

If you are using std::async, you don't even need to manage the exception_ptr manually. The C++ standard library handles this for you!

  1. A function inside std::async throws an exception.

  2. The library catches it and stores it inside the shared state.

  3. 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.

The Four Safety Guarantees

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.

Achieving the Strong Guarantee: "Copy-and-Swap"

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:

  1. Make a copy of the data.

  2. Modify the copy (if this throws, the original is untouched).

  3. 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); 
}

Resource Acquisition Is Initialization (RAII)

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!
}

Why "noexcept" is Critical

You 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.

The Comparison: 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

Why the Vector is Paranoid

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.

  1. 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.

  2. 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).

Code Demonstration

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
}

Key Takeaway

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.

The Power of std::is_nothrow_move_constructible

The <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;
}

Why use static_assert for Exceptions?

  1. Zero Runtime Cost: These checks happen while you are building the app. The final binary contains no extra code.

  2. Documentation that Can't Lie: Unlike a comment that says // This is fast, a static_assert proves it.

  3. 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.

Common Traits for Exception Safety

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?

The "Golden Rule" of Modern C++ Exceptions

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;
    }
}

Which one should you use?

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).

Comparison of Flow

[Image comparing C++ Exception stack unwinding vs std::expected return path]

  1. Exceptions jump across the call stack, potentially skipping many functions.

  2. 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 "Monadic" Pipeline

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.

Chaining Example: User Authentication

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;
}

Comparison: Traditional vs. Monadic

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.

Pro Tip: The noexcept Connection

Even 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 "Magic" Default Argument

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;
};

Using it with std::expected

You 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;
    }
}

Why this is a Game Changer

  1. Macro-Free: No more #define THROW_ERR(msg) .... Your code stays as pure C++.

  2. No Performance Hit: The compiler simply embeds a few constants (strings and ints) at the call site.

  3. Audit Trails: In complex systems, you can log exactly which branch of a deeply nested if statement triggered the failure.

Summary of our Modern C++ Journey

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.

The Architecture

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.

A Modern Thread Pool Implementation

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();
    }
};

Handling the Error in Main

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;
}

Why this is Robust

  1. 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.

  2. Type Safety: We use std::invoke_result to ensure the std::future matches the function's return type perfectly.

  3. Clean Exit: The destructor uses a stop flag and join() to ensure all threads finish their current work before the program exits (RAII).

Efficiency Check

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.

The Hidden Costs

The Happy Path (No Error)

  • 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.

The Sad Path (Error Occurs)

  • 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.

Benchmark Comparison

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.

The "Code Bloat" Factor

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:

  1. The try, catch, and throw keywords are disabled.

  2. The binary size shrinks significantly (often by 15-30%).

  3. std::expected and std::error_code become the only way to handle errors.

Profiling in Practice

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.

How to choose for your project:

  • 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.