Exceptions and Error Handling

Learning Objectives

Explain throw/catch, stack unwinding and exception hierarchies.

Apply the no-throw, strong and basic exception-safety guarantees.

Choose between exceptions, status codes, optionals and std::expected/result types.

Handle constructor failure without leaving half-built objects.

Prerequisites: Modules 5, 13.

Intuition and Definition

An exception signals that a function cannot fulfil its contract. Throwing transfers control to the nearest matching catch, unwinding the stack and destroying local objects on the way.

Exception-safety guarantees (what state is the program in if an operation throws?):

No-throw (noexcept) The operation never throws (destructors, swaps, moves).

Commit-or-rollback: if it throws, state is exactly as before.

Invariants hold and nothing leaks, but state may have changed.

Anything can happen — unacceptable for production code.

Expected vs exceptional failures: a user typing an invalid number is *expected* — return a result type. Running out of memory or a corrupted invariant is *exceptional* — throw.

Failures that can't be handled locally; constructors "No value" without a reason

std::expected<T, E> (C++23) / result types Expected failures with a reason, explicit at the call site

C APIs, real-time code where exceptions are disabled

Why It Matters

Error handling is where most designs leak resources or leave objects inconsistent. A clear policy — what throws, what returns errors, what each operation guarantees — makes failure behaviour predictable.

Syntax and Language Notes

std::expected<int, std::string> parsePort(std::string_view s);   // expected failure → value
void Config::load(const std::string& path);                       // I/O failure → exception
// Java (not run): checked exceptions must be declared or caught
public Config load(Path p) throws IOException { ... }
public bool TryParsePort(string s, out int port);   // .NET "Try" pattern for expected failures
class ConfigError(Exception): ...
raise ConfigError("missing key 'port'") from original_error   # exception chaining
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };   // result union

Complete Example

#include <charconv>
#include <expected>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

// expected failure: return a reason instead of throwing
std::expected<int, std::string> parsePort(std::string_view s) {
    int v = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::unexpected("not a number: '" + std::string(s) + "'");
    if (v < 1 || v > 65535) return std::unexpected("out of range: " + std::to_string(v));
    return v;
}

// exception hierarchy for exceptional failures
struct AppError : std::runtime_error { using std::runtime_error::runtime_error; };
struct ConfigError : AppError { using AppError::AppError; };

// strong guarantee: build the new state aside, then commit with a no-throw swap
class Playlist {
    std::vector<std::string> songs_;
public:
    void addAll(const std::vector<std::string>& more) {
        std::vector<std::string> next = songs_;                 // may throw: no harm done yet
        for (const auto& s : more) {
            if (s.empty()) throw std::invalid_argument("empty song title");
            next.push
…
port 8080
error: not a number: 'http'
error: out of range: 70000
AppError caught: missing key 'port'
addAll failed: empty song title
playlist still has 2 songs (strong guarantee)

Step-by-Step Walkthrough

parsePort uses std::from_chars (no exceptions, no locale) and returns std::unexpected(reason) for bad input; the caller checks with if (port).

ConfigError derives from AppError, so one catch (const AppError&) handles the whole family. Catch by reference to avoid slicing.

addAll copies the list, modifies the copy and only swaps it in after everything succeeded. The failing call leaves the playlist with 2 songs — the strong guarantee.

flowchart LR
  A[copy current state] --> B[apply all changes to the copy]
  B -->|throws| C[original untouched]
  B -->|success| D[noexcept swap commits]

Common Mistakes

Catching by value (catch (std::exception e)) — slices derived exceptions.

Swallowing exceptions (catch (...) {}) — the failure disappears and state may be inconsistent.

Throwing from destructors — terminates the program if another exception is active.

Using exceptions for normal control flow (end of loop, "user not found" in a search box).

Half-updated objects: modifying members one by one so that a throw in the middle breaks invariants (basic guarantee violated).

Constructor failure: members already constructed are destroyed automatically, but raw resources acquired in the body leak — acquire them in RAII members instead.

Trade-offs and Alternatives

Exceptions + RAII; std::expected for expected failures; noexcept on moves and destructors

Checked exceptions for recoverable I/O, unchecked for programming errors; many teams now prefer unchecked + result types

Exceptions, plus the TryXxx(out value) pattern for expected failures

EAFP style ("easier to ask forgiveness"): try the operation, catch specific exceptions

Exceptions are untyped (unknown in catch); result unions make expected failures visible in types

Safety-critical embedded code (MISRA C++, AUTOSAR C++14) often disables exceptions; errors are returned explicitly and checked.

Real-World Use

Parsers return result types, transaction managers provide the strong guarantee with commit/rollback, and long-running services catch exceptions at a boundary (request handler) to log and convert them into an error response.

Practice

Q1. (Easy) Which guarantee does noexcept express?

Answer: The no-throw guarantee.

Q2. (Medium) Why catch exceptions by const&?

Answer: To avoid copying and slicing — a derived exception caught by value is copied into a base object and loses its dynamic type.

Q3. (Advanced) Write withdraw for an account that returns std::expected<long long, std::string> with the new balance, and show success and failure.

#include <expected>
#include <iostream>
#include <string>
class Account {
    long long cents_;
public:
    explicit Account(long long c) : cents_(c) {}
    std::expected<long long, std::string> withdraw(long long amount) {
        if (amount <= 0) return std::unexpected("amount must be positive");
        if (amount > cents_) return std::unexpected("insufficient funds");
        cents_ -= amount;
        return cents_;
    }
};
int main() {
    Account a(5000);
    for (long long amt : {1200LL, 9999LL, -1LL}) {
        auto r = a.withdraw(amt);
        std::cout << amt << ": " << (r ? "ok, balance " + std::to_string(*r) : "error, " + r.error()) << "\n";
    }
}
1200: ok, balance 3800
9999: error, insufficient funds
-1: error, amount must be positive

Interview Question

Q1. "How do you give a function the strong exception-safety guarantee?"

Answer: Do all work that can fail on new objects or copies without touching the observable state, then commit with operations that cannot throw (pointer swaps, std::swap of containers, noexcept moves). The copy-and-swap idiom for assignment is the textbook example. RAII ensures the temporary state is cleaned up if anything throws. The cost is the extra copy, which is why many standard operations only promise the basic guarantee.

Quick Quiz

Q1. What happens to local objects when an exception propagates?

Answer: Their destructors run (stack unwinding).

Q2. When should you prefer std::expected over exceptions?

Answer: For expected, recoverable failures that the immediate caller should handle (validation, parsing).

Q3. What does the basic guarantee promise?

Answer: No leaks and all invariants hold, but the state may differ from before the call.

Summary

Exceptions + RAII handle exceptional failures; result types make expected failures explicit.

Know and document the guarantee each operation gives; commit with no-throw operations for the strong guarantee.

Catch by reference, don't swallow, never throw from destructors.

Related: Module 13, Module 69 (reliability), Module 63 (API error models).