Lesson 4: Error Handling

Lesson 0004 — A Tour of C++, Chapter 4 (§4.1–§4.6)

Chapter 3 gave you the organizational structure — separate compilation, modules, and namespaces. But every non-trivial program encounters problems: bad input, missing files, out-of-range indices, memory exhaustion. What should Vector::operator[] do when called with an index that doesn't exist? What should a constructor do when the requested size is negative? This chapter introduces C++'s primary error handling mechanism — exceptions — and the supporting concepts of invariants, assertions, and noexcept. The core idea is elegant: errors are reported by throwing an object that travels up the call stack until a matching catch handler is found. This separates error reporting from error handling cleanly — the alternative (error codes everywhere) clutters every function with bookkeeping that obscures the main logic.

Why exceptions matter now: every lesson from here on uses them implicitly. The standard library throws exceptions for out-of-range access (vector::at), failed allocations (bad_alloc), and many other failures. Understanding the exception mechanism is essential for writing correct C++ and for reading the standard library's error contracts.

4.1 Introduction — The Type System as Safety Net

C++'s major tool against errors is the type system itself. By building types (string, map, thread) and algorithms (sort(), find_if()) that are appropriate for the application domain, we simplify our programming, limit opportunities for mistakes (you are unlikely to try to apply a tree traversal to a dialog box), and increase the compiler's chances of catching errors before the program runs. Higher-level constructs are easier to use correctly and harder to use incorrectly — that is the central thesis of this chapter and the course.

4.2 Exceptions — throw, try, catch

Consider our Vector again. What should happen when operator[] is called with an out-of-range index?

The writer of Vector doesn't know what the user would like to do in this case. The user cannot consistently detect the problem before it happens — if they could, the out-of-range access wouldn't happen in the first place. The solution: the implementer detects the problem at the point where it occurs and throws an exception that the caller can catch and handle.

Throwing and catching

double& Vector::operator[](int i) {
    if (!(0 < i && i < size()))
        throw out_of_range{"Vector::operator[]"};  // throw an exception
    return elem[i];
}

A throw transfers control to a handler for that exception type in some function that directly or indirectly called the throwing function. The implementation unwinds the call stack, invoking destructors (§5.2.2) on the way, until it reaches a catch clause that matches the exception type:

void f(Vector& v) {
    try {                                       // start exception watch
        compute1(v);    // might range-error
        Vector v2 = compute2(v);  // might range-error
        compute3(v2);   // might range-error
    } catch (const out_of_range& err) {        // handle out_of_range
        cerr << err.what() << '\n';          // print what went wrong
    }                                           // handler ends here
}

The catch (const out_of_range& err) clause catches exceptions of type out_of_range (standard library, defined in <stdexcept>) by const reference (to avoid copying the exception object). err.what() returns the error message string supplied at the throw point.

Key points about exception propagation:

Invariants and constructors

A class invariant is what is assumed to be true about a class object throughout its lifetime. It is the constructor's job to establish the invariant and every member function's job to preserve it. Our original Vector constructor didn't check its argument — asking for Vector(-27) would cause chaos (negative-size allocation). A proper constructor throws if the invariant would be violated:

Vector::Vector(int s) {
    if (s < 0) throw length_error{"Vector constructor: negative size"};
    elem = new double[s];
    sz = s;
}

The standard library uses length_error (from <stdexcept>) for negative-size arguments. If operator new can't find memory, it throws bad_alloc.

Catching multiple exception types

void test(int n) {
    try {
        Vector v(n);
    } catch (std::length_error& err) {
        // handle negative size
    } catch (std::bad_alloc& err) {
        // handle memory exhaustion
    }
}

void run() {
    test(-27);             // throws length_error (negative size)
    test(1'000'000'000);   // may throw bad_alloc (too large)
    test(10);              // likely OK
}

Rethrowing

Some functions can't complete their task after an exception is thrown; they do local cleanup and rethrow the same exception to let a higher-level handler deal with it:

void test(int n) {
    try {
        Vector v(n);
    } catch (std::length_error&) {
        cerr << "test failed: length error\n";
        throw;              // rethrow (preserves the original exception object)
    } catch (std::bad_alloc&) {
        cerr << "out of memory — cannot recover\n";
        std::terminate();   // this program is not designed to handle this
    }
}

Notice the two different strategies for the two catch blocks: rethrow-and-let-higher-handle for a local problem; immediate termination for a situation the program simply cannot recover from. Also note the difference between catch (...) (catch anything, by type) vs. catching specific types — catching by reference (not by value) avoids object slicing and is the conventional style.

When to throw vs. return an error code vs. terminate — Stroustrup's decision framework from §4.4:

  1. Throw an exception when the error is rare enough that programmers are likely to forget to check for it (unlike printf return codes, which nobody checks). When the error cannot be handled by an immediate caller and must percolate up the call chain to an ultimate handler. When recovery depends on the results of several function calls.
  2. Return an error code when a failure is normal and expected (e.g., a file-not-found error on open) and the immediate caller can reasonably handle it, or when the function runs in a parallel task and you need to know which task failed.
  3. Terminate the program when the error is unrecoverable (e.g., memory exhaustion on many systems) or when the function is declared noexcept and a throw would violate that contract.

Compilers are optimized so that returning a value is much cheaper than throwing the same value as an exception. But for rare error paths, exceptions are often faster than the equivalent chain of error-code tests that would clutter the happy path. The myth that "exceptions are slow" applies to the throw path specifically — which is why it's appropriate for rare failures.

Constructor failure — no return value available

A constructor cannot return an error code to signal that it didn't succeed. The only clean option is to throw an exception; the partially constructed object is destroyed (destructor runs) and the error propagates to the caller. This is precisely why exceptions exist: they give constructors a way to signal failure without requiring the caller to "check" the result — the object simply doesn't exist if the constructor throws.

4.3 Invariants

Every class should have a clear invariant: a condition that is true of every object throughout its lifetime. The constructor establishes the invariant; member functions preserve it. Formulating invariants does three things for us:

For our Vector: the invariant is that elem points to an array of sz doubles. Every member function (especially operator[]) can assume this invariant holds and doesn't need to recheck the pointer sanity — only the index bounds need checking, which is what the out_of_range throw guards.

4.4 Error-Handling Alternatives

There are only three ways a function can report that it cannot complete its task: throw an exception, return an error indicator (error code), or terminate the program. Each has legitimate use cases:

Key insight: error codes clutter the happy path. In a function like compute(), every call site would need an if-check for success, and the real computational result gets tangled with error-status bookkeeping. Exceptions keep the happy path clean — the "normal" code just does its work, and the rare failure case throws.

// error-code style (clutters the happy path)
Matrix add(const Matrix& a, const Matrix& b, ErrorCode& err) {
    if (a.rows() != b.rows()) { err = DimensionMismatch; return {}; }
    if (a.cols() != b.cols()) { err = DimensionMismatch; return {}; }
    Matrix res(a.rows(), a.cols());
    for (int i = 0; i != a.rows(); ++i)
        for (int j = 0; j != a.cols(); ++j)
            res[i][j] = a[i][j] + b[i][j];
    err = Success;
    return res;
}

// exception style (happy path is clean)
Matrix add(const Matrix& a, const Matrix& b) {
    if (a.rows() != b.rows() || a.cols() != b.cols())
        throw std::invalid_argument{"Dimension mismatch in add()"};
    Matrix res(a.rows(), a.cols());
    for (int i = 0; i != a.rows(); ++i)
        for (int j = 0; j != a.cols(); ++j)
            res[i][j] = a[i][j] + b[i][j];
    return res;   // clean return — no error-code out-parameter
}

The exception version is easier to write, easier to read, and harder to get wrong. The compiler even checks that you don't accidentally ignore a return value — with error codes, forgetting to check err is a silent bug. The cost: thrown exceptions are designed for rare error paths, not for routine control flow.

4.5 Assertions

Exceptions handle run-time errors that are expected but exceptional (rare). Sometimes we want to check assumptions that should always hold — the function's preconditions, a class invariant, or an internal consistency check. For these, C++ provides assertion mechanisms.

assert() — the standard debug macro

#include <cassert>

void f(const char* p) {
    assert(p != nullptr);   // p must not be null
    // ... use p safely ...
}

If assert fails in debug mode, the program terminates with a diagnostic (file, line, failed condition). In production builds (when NDEBUG is defined), the assert call is removed entirely — zero runtime cost. This means assert is for conditions that you believe must always hold, not for conditions that a user might reasonably trigger (those are exceptions, not asserts).

Static assertions — catching errors at compile time

When an error can be detected at compile time, it's almost always better to do so than to wait for a run-time failure:

static_assert(4 <= sizeof(int), "integers are too small");
// compiler error if int is smaller than 4 bytes on this platform

Static asserts work on any constant expression (§1.6):

constexpr double C = 299792.458;  // km/s
constexpr double local_max = 160.0 / (60 * 60);  // km/s → 160 km/h
static_assert(local_max < C, "can't go that fast");  // OK — constant expression

One important use of static_assert in generic programming (§8.2, §16.4):

template<typename T>
void process(T value) {
    static_assert(std::is_integral_v<T>, "process() requires an integral type");
    // ...
}

This gives a clear compile-time error message instead of a cryptic substitution failure when someone accidentally passes a double to process().

Custom assertion mechanism with expect() and noexcept

The standard library gives us assert() (debug-only, always terminates on failure) and static_assert (compile-time only). Both are relatively blunt. A more flexible scheme uses a template that accepts an action policy: ignore (release build), throwing (throw exception), or terminating (call terminate()) — selected at compile time with if constexpr (§7.4.3). This gives a single point of control for assertion semantics across a codebase:

enum class Error_action { ignore, throwing, terminating };
constexpr Error_action default_Error_action = Error_action::throwing;

enum class Error_code { range_error, length_error };
constexpr const char* error_code_name[] = { "range error", "length error" };

template<Error_action action = default_Error_action, class C>
constexpr void expect(C cond, Error_code x) {
    if constexpr (action == Error_action::logging)
        if (!cond()) std::cerr << "expect() failure: "
            << int(x) << ' ' << error_code_name[int(x)] << '\n';
    if constexpr (action == Error_action::throwing)
        if (!cond()) throw x;
    if constexpr (action == Error_action::terminating)
        if (!cond()) std::terminate();
}

// Usage:
double& Vector::operator[](int i) {
    expect([i, this] { return 0 <= i && i < size(); },
           Error_code::range_error);
    return elem[i];
}

Setting default_Error_action lets a user pick a deployment strategy: throw (testing), log (staging), terminate (hard real-time). The compile-time if constexpr means exactly one of the three branches compiles — zero runtime cost for the two that don't apply.

assert() vs. expect() vs. exceptions — the spectrum:

Mechanism When checked When fails Cost when enabled Use case
assert() Run time (debug only) Terminate None in release (NDEBUG) "This must never happen" — internal invariants
static_assert() Compile time Compiler error None Type constraints, size assumptions, platform properties
expect() Run time (configurable) Configurable (throw/terminate/log) One branch compiled Public API preconditions (range checks, preconditions)
Exception (throw) Run time Stack unwinding to catch Near-zero for rare paths Errors that the caller might reasonably handle

noexcept — promising not to throw

A function declared noexcept guarantees that it will never throw an exception. If a noexcept function does throw, std::terminate() is called immediately — no stack unwinding, no handler lookup. This is a hard constraint, not a suggestion:

void user(int sz) noexcept {
    Vector v(sz);
    iota(&v[0], &v[sz], 1);     // fill v with 1,2,3,... (§17.3)
}
// If user() somehow throws, std::terminate() is called — no recovery possible.

Thoughtlessly sprinkling noexcept is hazardous:

Like other powerful language features, noexcept should be applied with understanding and caution — typically on move constructors, swap functions, and destructors (which should never fail), and on hot-path functions where the compiler can exploit the guarantee.

4.6 Advice

Stroustrup closes the chapter with a subset of the C++ Core Guidelines. All 18 items, with the section where each is introduced:

# Guideline §
1 Throw an exception to indicate that you cannot perform an assigned task 4.4
2 Use exceptions for error handling only; don't use them for ordinary control flow 4.4
3 Failing to open a file or to reach the end of an iteration are expected events, not exceptional; use error codes for these (or expected<T,E> where available). 4.4
4 Use error codes when an immediate caller is expected to handle the error 4.4
5 Throw an exception for errors expected to percolate up through many function calls 4.4
6 If in doubt, prefer exceptions — their use scales better and doesn't require external tools to verify all errors are handled 4.4
7 Develop an error-handling strategy early in design, not as an afterthought 4.4
8 Use purpose-designed user-defined types as exceptions (not built-in types like int or string) 4.2
9 Don't try to catch every exception in every function — let exceptions propagate to the right level 4.2
10 You don't have to use the standard-library exception hierarchy 4.3
11 Prefer RAII to explicit try-blocks — let destructors handle cleanup automatically 4.2
12 Let a constructor establish an invariant, and throw if it cannot 4.3
13 Design your error-handling strategy around invariants 4.3
14 What can be checked at compile time is usually best checked at compile time 4.5.2
15 Use an assertion mechanism (assert(), expect()) to provide a single point of control for failure semantics 4.5
16 Concepts (§8.2) are compile-time predicates and therefore often useful in static_assert messages 4.5.2
17 If your function may not throw, declare it noexcept 4.5.3
18 Don't apply noexcept thoughtlessly — understand what the function calls and whether they can throw 4.5.3

Retrieval Quiz

When to throw vs. error code

A function encounters an error that its immediate caller can reasonably handle. Which strategy does Stroustrup recommend?

Constructor failure requires throwing

What happens if a constructor cannot establish its invariant?

static_assert vs. assert vs. expect

Which mechanism checks a condition at compile time and produces a compiler error if it fails?

noexcept and what happens on a throw

What happens if a noexcept function throws an exception?

Primary use case for static_assert in generic code

What is a primary use case for static_assert in templates?


Notes

Error handling :-

error mismatch between intended and actual state

exception thrown error propagates up the stack ; try/catch

exception object any type ; prefer user-defined types (not int)

RAII destructors clean up during unwind no leaks on throw

Throw v/s error code :-

throw rare errors percolating many calls (eg deep call chains)

error code expected events ; immediate caller (eg file not found)

constructor failure throw ; no return value to signal

if in doubt prefer exceptions scales better

Invariants :-

invariant always-true property of valid object

constructor establishes invariant ; throw if it cannot

violated invariant undefined behavior

Assertions :-

a) assert() run-time check ; compiled out with NDEBUG

b) static_assert compile-time check ; constexpr condition + message

c) noexcept function may not throw ; throw terminate()

d) expect() configurable failure semantics (throw / terminate / log)


Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed., Chapter 4: "Error Handling." Addison-Wesley.
Reference: Chapter 1–4 Quick Reference & Glossary — keep it beside you while you study.
Recommended supplement: cppreference on exception handling, terminate(), and noexcept.

Questions? Ask your agent — your teacher — about anything unclear: how except differs from except in the standard library, when noexcept is appropriate for move constructors, or how RAII interacts with exceptions when the stack unwinds. Follow-ups are expected, not optional.

Chapters

  1. Lesson 1: The Basics (Ch. 1)
  2. Lesson 2: User-Defined Types (Ch. 2)
  3. Lesson 3: Separate Compilation (Ch. 3)
  4. Lesson 4: Error Handling (Ch. 4 — this lesson)
  5. Lesson 5: Classes (Ch. 5)
  6. Lesson 6: Essential Operations (Ch. 6)
  7. Lesson 7: Templates (Ch. 7)
  8. Lesson 8: Concepts and Generic Programming (Ch. 8)
  9. Lesson 9: Library Overview (Ch. 9)
  10. Lesson 10: Strings and Regular Expressions (Ch. 10)
  11. Lesson 11: Input and Output (Ch. 11)
  12. Lesson 12: Containers (Ch. 12)
  13. Lesson 13: Algorithms (Ch. 13)
  14. Lesson 14: Ranges (Ch. 14)
  15. Lesson 15: Pointers and Containers (Ch. 15)
  16. Lesson 16: Utilities (Ch. 16)
  17. Lesson 17: Numerics (Ch. 17)
  18. Lesson 18: Concurrency (Ch. 18)
  19. Lesson 19: History and Compatibility (Ch. 19)
← Course Dashboard ← Ch. 3: Separate Compilation Ch. 5: Classes → Quick Reference →