Boring C++
Boring C++

Error Handling

12 min read

Chapter 9: Error Handling

How your code handles failure is as important as how it handles success.

Software fails. Files do not exist. Networks time out. Users enter invalid input. Memory runs out. A parser encounters a string that is not a valid integer. Every function you write has a contract — inputs it expects, outputs it promises — and the question is what happens when that contract is not met.

C++ gives you several mechanisms. This chapter covers all of them, when each is appropriate, and how to combine them cleanly.

9.1 Return codes versus exceptions

The two traditional schools:

Return codes: the function returns a value indicating success or failure. The caller must check it. If the caller does not check, the error is silently ignored — which is usually worse than the error itself.

Exceptions: the function throws an exception on failure. The exception propagates up the call stack until something catches it. If nothing catches it, std::terminate is called and the program aborts. The caller does not need to check for errors at every call site, but it must handle exceptions somewhere.

Both have legitimate uses. Here is the same problem solved both ways:

#include <iostream>
#include <string>
#include <stdexcept>
#include <climits>

// ---- Return code style ----

enum class ParseError { Ok, EmptyString, InvalidChar, Overflow };

ParseError parseIntRC(const std::string& s, int& out) {
    if (s.empty()) return ParseError::EmptyString;
    size_t pos = 0;
    long val;
    try {
        val = std::stol(s, &pos);
    } catch (...) {
        return ParseError::InvalidChar;
    }
    if (pos != s.size()) return ParseError::InvalidChar;
    if (val < INT_MIN || val > INT_MAX) return ParseError::Overflow;
    out = static_cast<int>(val);
    return ParseError::Ok;
}

// ---- Exception style ----

int parseIntEx(const std::string& s) {
    if (s.empty()) throw std::invalid_argument{"Empty string"};
    size_t pos = 0;
    long val = std::stol(s, &pos);  // throws std::invalid_argument or std::out_of_range
    if (pos != s.size()) throw std::invalid_argument{"Not an integer: " + s};
    if (val < INT_MIN || val > INT_MAX) throw std::out_of_range{"Out of int range: " + s};
    return static_cast<int>(val);
}

int main() {
    // Return code style
    int result;
    auto rc = parseIntRC("123", result);
    if (rc == ParseError::Ok) {
        std::cout << "Parsed: " << result << "\n";
    } else {
        std::cout << "Parse failed\n";
    }

    // Exception style
    try {
        int val1 = parseIntEx("456");
        std::cout << "Parsed: " << val1 << "\n";
        int val2 = parseIntEx("abc");  // throws
        std::cout << "This won't print: " << val2 << "\n";
    } catch (const std::invalid_argument& e) {
        std::cerr << "Parse error: " << e.what() << "\n";
    } catch (const std::out_of_range& e) {
        std::cerr << "Range error: " << e.what() << "\n";
    }

    return 0;
}

Neither style is universally correct. Sections 9.6 gives guidelines for choosing.

9.2 Throwing and catching exceptions

The mechanics: throw any value; catch by type. Catching by const reference is the right choice — it avoids copying and prevents slicing.

#include <iostream>
#include <string>
#include <stdexcept>
#include <vector>

// A custom exception — derive from std::exception or one of its subclasses
class DatabaseError : public std::runtime_error {
public:
    DatabaseError(const std::string& message, int errorCode)
        : std::runtime_error{message}, errorCode_{errorCode} {}

    int errorCode() const { return errorCode_; }

private:
    int errorCode_;
};

void connectToDatabase(const std::string& host) {
    if (host.empty()) {
        throw std::invalid_argument{"Host cannot be empty"};
    }
    if (host == "unreachable-host") {
        throw DatabaseError{"Connection refused to " + host, 111};
    }
    std::cout << "Connected to " << host << "\n";
}

int main() {
    std::vector<std::string> hosts{"good-host", "", "unreachable-host", "another-good-host"};

    for (const auto& host : hosts) {
        try {
            connectToDatabase(host);
        } catch (const DatabaseError& e) {
            // Catch DatabaseError first — it is more specific
            std::cerr << "DB error [" << e.errorCode() << "]: " << e.what() << "\n";
        } catch (const std::invalid_argument& e) {
            std::cerr << "Argument error: " << e.what() << "\n";
        } catch (const std::exception& e) {
            // Catch-all for any std::exception
            std::cerr << "Unknown error: " << e.what() << "\n";
        }
        // Note: no catch (...) — we want unknown exceptions to propagate
    }

    return 0;
}

The C++ exception hierarchy (relevant subset):

std::exception
├── std::logic_error
│   ├── std::invalid_argument
│   ├── std::out_of_range
│   └── std::domain_error
└── std::runtime_error
    ├── std::overflow_error
    ├── std::underflow_error
    └── std::system_error

Rules:

  • Throw by value (not by pointer).
  • Catch by const reference (prevents copying, prevents slicing).
  • Catch more specific types before more general ones.
  • Always have a std::exception catch as a fallback.
  • Do not use catch (...) to silently ignore all errors — you will hide bugs.
  • Always derive custom exceptions from std::exception or one of its subclasses.

9.3 Writing exception-safe code

Exception-safe code has three levels:

  • Basic guarantee: if an exception is thrown, the program remains in a valid state. No leaks, no corruption.
  • Strong guarantee: if an exception is thrown, the operation has no effect — rollback semantics.
  • Nothrow guarantee: the operation never throws. Functions marked noexcept should satisfy this.

RAII (Chapter 6) provides the basic guarantee almost automatically: if an exception is thrown, destructors of local objects still run, and resources are released.

#include <iostream>
#include <string>
#include <stdexcept>

class Transaction {
public:
    Transaction() : committed_{false} {
        std::cout << "Transaction started\n";
    }

    ~Transaction() {
        if (!committed_) {
            std::cout << "Transaction rolled back (destructor)\n";
        }
    }

    void commit() {
        committed_ = true;
        std::cout << "Transaction committed\n";
    }

private:
    bool committed_;
};

void processOrder(int orderId, bool simulateFailure) {
    Transaction tx;  // RAII: destructor rolls back if not committed

    std::cout << "Processing order " << orderId << "\n";

    if (simulateFailure) {
        throw std::runtime_error{"Payment gateway timeout"};
        // tx destructor runs here — rollback
    }

    // Do more work...
    tx.commit();
}

int main() {
    try {
        processOrder(101, false);  // succeeds
    } catch (const std::exception& e) {
        std::cerr << "Order 101 failed: " << e.what() << "\n";
    }

    try {
        processOrder(102, true);   // fails — tx is rolled back
    } catch (const std::exception& e) {
        std::cerr << "Order 102 failed: " << e.what() << "\n";
    }

    return 0;
}

The Transaction destructor runs even when the exception is thrown. That is RAII providing the basic guarantee. The rolled-back state is well-defined (no partial commits).

Important: destructors must not throw. If a destructor throws while the stack is already being unwound (during exception handling), std::terminate is called. Mark destructors noexcept (which they are by default in C++11 and later).

9.4 std::optional

std::optional<T> represents "either a value of type T, or nothing." It is the right tool when absence of a value is a normal, expected condition — not an error.

#include <iostream>
#include <optional>
#include <string>
#include <map>

std::optional<int> findAge(const std::string& name) {
    static const std::map<std::string, int> db{
        {"Alice", 30}, {"Bob", 25}, {"Carol", 35}
    };
    auto it = db.find(name);
    if (it == db.end()) return std::nullopt;
    return it->second;
}

std::optional<int> parsePositiveInt(const std::string& s) {
    if (s.empty()) return std::nullopt;
    for (char c : s) {
        if (!std::isdigit(static_cast<unsigned char>(c))) return std::nullopt;
    }
    int val = std::stoi(s);
    return val > 0 ? std::optional<int>{val} : std::nullopt;
}

int main() {
    // Check with has_value()
    auto age = findAge("Alice");
    if (age.has_value()) {
        std::cout << "Alice is " << age.value() << "\n";
    }

    // Implicit bool conversion
    auto unknownAge = findAge("Dave");
    if (unknownAge) {
        std::cout << "Dave is " << *unknownAge << "\n";
    } else {
        std::cout << "Dave not found\n";
    }

    // value_or: default if absent
    std::cout << "Eve's age: " << findAge("Eve").value_or(-1) << "\n";  // -1

    // Parsing
    auto good = parsePositiveInt("42");
    auto bad  = parsePositiveInt("hello");
    auto zero = parsePositiveInt("0");

    std::cout << "42: " << good.value_or(-1) << "\n";     // 42
    std::cout << "hello: " << bad.value_or(-1) << "\n";   // -1
    std::cout << "0: " << zero.value_or(-1) << "\n";      // -1

    // Modifying through optional
    if (auto result = findAge("Bob")) {
        *result += 1;  // modify the copy (original is unchanged)
        std::cout << "Bob's age next year: " << *result << "\n";
    }

    return 0;
}

Never call .value() on an empty optional — it throws std::bad_optional_access. Always check first with if (opt) or has_value(), or use value_or(default).

9.5 std::expected (C++23)

std::expected<T, E> holds either a value of type T (success) or an error of type E (failure). It is like optional but carries the reason for failure, and unlike exceptions it keeps the error information in the return value.

#include <iostream>
#include <expected>   // C++23
#include <string>
#include <vector>

enum class ParseError {
    EmptyInput,
    InvalidCharacter,
    Overflow,
    TrailingChars
};

std::string errorMessage(ParseError e) {
    switch (e) {
        case ParseError::EmptyInput:      return "Empty input";
        case ParseError::InvalidCharacter:return "Invalid character";
        case ParseError::Overflow:        return "Value overflow";
        case ParseError::TrailingChars:   return "Trailing characters after number";
    }
    return "Unknown error";
}

std::expected<int, ParseError> parseInteger(const std::string& s) {
    if (s.empty()) return std::unexpected{ParseError::EmptyInput};

    size_t pos = 0;
    long val;
    try {
        val = std::stol(s, &pos);
    } catch (const std::out_of_range&) {
        return std::unexpected{ParseError::Overflow};
    } catch (...) {
        return std::unexpected{ParseError::InvalidCharacter};
    }

    if (pos != s.size()) return std::unexpected{ParseError::TrailingChars};
    if (val < INT_MIN || val > INT_MAX) return std::unexpected{ParseError::Overflow};

    return static_cast<int>(val);
}

int main() {
    std::vector<std::string> inputs{"42", "", "abc", "99999999999", "12abc"};

    for (const auto& input : inputs) {
        auto result = parseInteger(input);
        if (result) {
            std::cout << "'" << input << "' -> " << *result << "\n";
        } else {
            std::cout << "'" << input << "' -> Error: " << errorMessage(result.error()) << "\n";
        }
    }

    // value_or
    auto n = parseInteger("42");
    std::cout << "Value or default: " << n.value_or(-1) << "\n";

    return 0;
}

std::expected requires C++23 (-std=c++23). Check your compiler version: GCC 12+, Clang 16+, and MSVC 2022 17.5+ support it.

The advantage over std::optional: the error carries information. The advantage over exceptions: the error is part of the function's return type — callers must consciously decide what to do with it.

9.6 When to use which strategy

Here is a practical guide:

Exceptions: for unexpected failures that callers cannot easily anticipate or check for. A file that should exist but does not. A network connection that drops. Running out of memory. Logic errors that indicate a programming mistake (not user input error). The overhead of exception handling (when no exception is thrown) is zero in most ABIs.

std::optional<T>: when absence of a value is a normal outcome. Searching for an element that may not exist. Parsing that might fail on invalid input where failure is routine. Do not use exceptions for "I searched and found nothing."

std::expected<T, E>: when the operation might fail and you need to tell the caller why. Richer than optional, cheaper than exceptions (no stack unwinding), and makes the error type part of the API contract.

Return codes (enum or int): in performance-critical code where exceptions are banned (embedded systems, some game engines, safety-critical code). Also when interfacing with C APIs. Modern C++ code generally avoids this in favor of optional or expected.

assert: for preconditions that are programming mistakes — things that should never happen in correct code. assert(index < size) in a function that expects a valid index. Asserts are removed in release builds and are not for user input validation.

#include <iostream>
#include <optional>
#include <cassert>
#include <stdexcept>
#include <string>

// Use optional: "might not find anything" is a normal outcome
std::optional<std::string> findUserEmail(int userId) {
    if (userId == 1) return "[email protected]";
    return std::nullopt;  // not found — normal, not an error
}

// Use exception: file not found is unexpected and unrecoverable here
std::string readConfig(const std::string& path) {
    // In a real program, open the file and read it
    if (path.empty()) throw std::runtime_error{"Config path cannot be empty"};
    return "config_data";
}

// Use assert: programming contract violation
int getElement(const std::vector<int>& v, size_t index) {
    assert(index < v.size());  // bug if this fires — remove in release
    return v[index];
}

int main() {
    auto email = findUserEmail(1);
    if (email) std::cout << "Email: " << *email << "\n";
    else std::cout << "User not found\n";

    try {
        std::string config = readConfig("settings.json");
        std::cout << "Config loaded\n";
    } catch (const std::exception& e) {
        std::cerr << "Config error: " << e.what() << "\n";
    }

    std::vector<int> data{10, 20, 30};
    std::cout << getElement(data, 1) << "\n";  // 20
    // getElement(data, 10);  // assert fires in debug mode

    return 0;
}

9.7 Exercises

Easy

  1. Write a safeDivide(double a, double b) -> std::optional<double> function that returns nullopt when b is zero and the result otherwise. Test with several cases.

  2. Write three custom exception classes (UserAlreadyExistsError, InvalidEmailError, WeakPasswordError) each derived from std::runtime_error. Write a registerUser(name, email, password) function that throws the appropriate exception for each error case.

Medium

  1. Write a readFileContents(const std::string& path) -> std::optional<std::string> function that opens a file, reads all its content, and returns the content wrapped in optional. Return nullopt if the file cannot be opened. Do not throw.

Stretch

  1. Using std::expected<T, E>, write a CSV row parser: parseCSVRow(const std::string& line) -> std::expected<std::vector<std::string>, ParseError>. Define an appropriate ParseError enum. Handle empty lines, unclosed quotes, and consecutive delimiters.

9.8 Common mistakes

  • Throwing std::string or int. Throw objects that derive from std::exception. Callers can catch std::exception& as a fallback; they cannot catch arbitrary types that way.

  • Catching by value. catch (std::exception e) copies the exception and loses the derived type (slicing). Always catch by const reference.

  • Using catch (...) to silence all errors. You are hiding bugs, not handling them. At minimum, log the error and rethrow.

  • Accessing an empty optional without checking. opt.value() throws std::bad_optional_access if opt is empty. Always check if (opt) or use value_or.

  • Using exceptions for expected/normal control flow. Throwing when a search finds no result is expensive and misleading. Use optional instead.

  • Throwing from destructors. If a destructor throws during stack unwinding (another exception is active), std::terminate is called. Destructors must not throw. Catch exceptions inside destructors and handle or swallow them.

  • Forgetting that noexcept is a promise. If you mark a function noexcept and it throws, std::terminate is called. Only mark functions noexcept if they genuinely cannot throw.

  • Memory leaks when an exception is thrown. If you call new and then an exception is thrown before the corresponding delete, you leak. Use smart pointers — RAII automatically handles this.

9.9 Summary

C++ offers multiple error-handling mechanisms, each suited to different situations.

Exceptions propagate errors up the call stack. They are for unexpected failures that callers cannot easily anticipate. Throw by value; catch by const reference; always derive from std::exception.

RAII ensures resources are released even when exceptions are thrown. Destructors run regardless of how a scope is exited.

std::optional<T> represents "a value or nothing." Use it when absence is a normal outcome, not an error.

std::expected<T, E> (C++23) represents "a value or a typed error." More expressive than optional, cheaper than exceptions for high-frequency failure paths.

Asserts are for programming contract violations — bugs, not runtime errors. They disappear in release builds.

In Chapter 10 we turn to concurrency. It is the hardest chapter, and we will say so again when we get there.

All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter09

Boring C++

Modern C++ Mastery for Programmers Who Prefer Clarity Over Cleverness.

View book →