Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 1 of 5

A function that cannot do its job has to tell someone. Returning a sentinel means every caller must remember to check; an out-parameter means the same. An exception cannot be ignored — it propagates until something handles it, or the program ends.

The mechanics

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

// A custom exception type: derive from std::runtime_error to inherit what().
class InsufficientFunds : public std::runtime_error {
    double shortfall_;

public:
    InsufficientFunds(double shortfall)
        : std::runtime_error("insufficient funds, short by " + std::to_string(shortfall)),
          shortfall_(shortfall) {}

    double shortfall() const { return shortfall_; }
};

void withdraw(double balance, double amount) {
    if (amount <= 0) {
        throw std::invalid_argument("amount must be positive");
    }
    if (amount > balance) {
        throw InsufficientFunds(amount - balance);
    }
    std::cout << "  withdrew " << amount << ", left " << (balance - amount) << "\n";
}

int main() {
    const std::vector<double> attempts = {50.0, -10.0, 500.0};

    for (double amount : attempts) {
        std::cout << "attempting " << amount << ":\n";
        try {
            withdraw(100.0, amount);
        } catch (const InsufficientFunds& e) {
            // Most derived first: this must precede the runtime_error handler.
            std::cout << "  " << e.what() << " (shortfall " << e.shortfall() << ")\n";
        } catch (const std::invalid_argument& e) {
            std::cout << "  bad argument: " << e.what() << "\n";
        } catch (const std::exception& e) {
            std::cout << "  other: " << e.what() << "\n";
        }
    }

    // Rethrowing preserves the original exception object.
    try {
        try {
            throw std::logic_error("inner");
        } catch (const std::exception& e) {
            std::cout << "caught, logging, rethrowing: " << e.what() << "\n";
            throw;                          // rethrow, not `throw e;`
        }
    } catch (const std::logic_error& e) {
        std::cout << "outer still sees logic_error: " << e.what() << "\n";
    }
    return 0;
}
attempting 50:
  withdrew 50, left 50
attempting -10:
  bad argument: amount must be positive
attempting 500:
  insufficient funds, short by 400.000000 (shortfall 400)
caught, logging, rethrowing: inner
outer still sees logic_error: inner

Four rules are visible there.

Handlers are tried in order, so put the most derived type first. Move the catch (const std::exception&) to the top and it would catch everything — the specific handlers below it become unreachable. GCC warns about this with -Wall, but the ordering is yours to get right.

Catch by const reference. Always. The next section shows what happens otherwise.

A custom exception derives from std::runtime_error (or std::logic_error), which supplies what(). Adding a member like shortfall() lets a handler recover structured data rather than parsing a string.

throw; with no operand rethrows the current exception, preserving its dynamic type. throw e; would copy e as its static type and lose everything derived — the same slicing bug, in a different place.

What happens on the way out

Between the throw and the matching catch, the runtime unwinds the stack: it destroys every local object in every frame it leaves, in reverse order of construction.

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

struct Tracer {
    std::string name;
    explicit Tracer(std::string n) : name(std::move(n)) {
        std::cout << "  + " << name << " constructed\n";
    }
    ~Tracer() { std::cout << "  - " << name << " destroyed\n"; }
};

void inner() {
    Tracer c("inner-local");
    throw std::runtime_error("boom");
}

void middle() {
    Tracer b("middle-local");
    inner();
    std::cout << "  (never reached)\n";
}

int main() {
    std::cout << "stack unwinding:\n";
    try {
        Tracer a("outer-local");
        middle();
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << "\n";
    }
    return 0;
}
stack unwinding:
  + outer-local constructed
  + middle-local constructed
  + inner-local constructed
  - inner-local destroyed
  - middle-local destroyed
  - outer-local destroyed
caught: boom

Every destructor ran, in reverse order, across three frames. middle's "never reached" line did not print — control left through the throw.

That is the entire reason exceptions are usable in C++. A std::vector, a std::unique_ptr, a std::lock_guard — every RAII object in every abandoned frame releases its resource automatically. Lesson 15.4 makes this the central point; for now, notice that nothing in middle or inner had to know an exception was possible.

The slicing bug

// try {
//     throw std::out_of_range("specific type");
// } catch (std::exception e) {          // BY VALUE — slices
//     std::cout << e.what();
// }
//
// try {
//     throw std::out_of_range("specific type");
// } catch (const std::exception& e) {   // by reference — correct
//     std::cout << e.what();
// }

The two print different things:

by value:     std::exception
by reference: specific type

Catching by value copies the exception into a std::exception, discarding the out_of_range part — including its message. The handler is left with a generic object that knows nothing.

GCC catches this one:

warning: catching polymorphic type 'class std::exception' by value [-Wcatch-value=]

That warning is not in -Wall or -Wextra by default in every version, so it is worth enabling explicitly. The rule is simple enough not to need it: throw by value, catch by const reference.

The standard hierarchy

std::exception
├── std::logic_error          — a bug: the caller broke a precondition
│   ├── std::invalid_argument
│   ├── std::domain_error
│   ├── std::length_error
│   └── std::out_of_range     — thrown by vector::at, map::at
└── std::runtime_error        — a condition detectable only at run time
    ├── std::range_error
    ├── std::overflow_error
    └── std::system_error     — thrown by filesystem, threads

Plus std::bad_alloc (allocation failed) and std::bad_cast, which derive from std::exception directly.

The logic_error / runtime_error split is a real distinction and worth respecting:

  • logic_error means the program is wrong — an index out of range, a null where one was promised. In principle it could have been prevented by checking beforehand. These usually should not be caught; they should be fixed.
  • runtime_error means the world is wrong — a missing file, a refused connection, a malformed input. The program is correct and the situation is not. These are worth catching.

catch (...)

// try {
//     risky();
// } catch (const std::exception& e) {
//     log(e.what());
//     throw;
// } catch (...) {
//     log("unknown exception");
//     throw;                    // rethrow — you cannot handle what you cannot name
// }

catch (...) catches anything, including types not derived from std::exception — someone throwing an int, or an exception from a third-party library with its own hierarchy.

Use it to clean up and rethrow, at a boundary where the program must not terminate, or in main to log before exiting. Do not use it to swallow errors: a handler that cannot name what it caught cannot know whether continuing is safe.

What not to throw

// throw 42;                      // legal, and useless — what does 42 mean?
// throw "file not found";        // a const char*, not an exception
// throw MyError();               // fine, if MyError derives from std::exception

Throwing a non-exception type is legal C++ and a bad idea: catch (const std::exception&) will not catch it, so it sails past every well-written handler and terminates the program.

Derive from std::runtime_error or std::logic_error. It costs one line and makes your exception catchable by code that has never heard of it.

Try it

Write a configuration parser that throws distinct exception types for a missing file, a malformed line, and an unknown key — each carrying structured data about the failure. Then write a caller that handles each differently, and show what happens when the handler order is wrong.

Checkpoint

  • An exception cannot be ignored — it propagates until handled, or the program terminates.
  • Handlers are tried in order; put the most derived type first.
  • Throw by value, catch by const reference — catching by value slices.
  • throw; rethrows the current exception; throw e; copies and slices it.
  • Between throw and catch the stack unwinds, destroying every local in reverse order.
  • That unwinding is why RAII makes exceptions survivable (lesson 15.4).
  • std::logic_error means the program is wrong; std::runtime_error means the world is wrong.
  • Derive custom exceptions from std::runtime_error and add structured accessors.
  • A what() string should be self-contained; handlers should use accessors, not re-parse it.
  • catch (...) catches anything — use it to clean up and rethrow, not to swallow.
  • Throwing a non-exception type sails past every catch (const std::exception&).
  • -Wexceptions (in -Wall) catches a handler shadowed by an earlier base-class handler.

Questions about this lesson

Log in to ask a question.