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_errormeans 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_errormeans 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
constreference — 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_errormeans the program is wrong;std::runtime_errormeans the world is wrong.- Derive custom exceptions from
std::runtime_errorand 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.