C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
try, catch, and matching handler types
Predict which catch clause runs for a given throw, order handlers correctly, and catch by reference so derived types are not sliced.
What you will learn
- Order catch clauses most-derived first; the first viable handler wins, not the best.
- Catch by const reference to avoid a copy and to keep the exception's dynamic type.
- Know that catch(long) never catches a thrown int, since no arithmetic conversions apply.
- Use a bare throw; inside a handler to rethrow the original object without slicing.
Understanding try, catch, and matching handler types
A try block marks a region of code and attaches an ordered list of handlers to it. When something inside throws, the runtime creates an exception object of the thrown expression's type, then walks the handlers of the innermost enclosing try block from top to bottom and stops at the first one whose declared type can accept that object. This is deliberately unlike overload resolution: there is no ranking of candidates and no ambiguity error, so a catch (const std::exception&) written first absorbs every standard exception and leaves the more specific clauses below it dead code.
The set of conversions allowed during matching is narrow. A handler for T, T&, or const T& matches a thrown T, and it also matches any type that derives from T publicly and unambiguously; pointer handlers additionally accept a derived-class pointer, a void* handler accepts any object pointer, and cv-qualifiers may be added. Nothing else applies, in particular no integral promotions, no floating conversions and no user-defined conversion operators, which is why throw 1; walks straight past catch (long) and catch (double), and why throw "boom"; needs catch (const char*) rather than catch (std::string).
Handler parameters are initialised like ordinary variables, so catch (Base b) copy-initialises b from the exception object and slices off everything the derived type added, including its overridden what(). Binding const Base& instead names the exception object itself, which stays alive until the handler finishes, so virtual dispatch still reaches the derived override and no copy is made. That is the whole reason the convention is catch (const std::exception& e): by reference for correct behaviour, const because a handler that may rethrow should not be quietly mutating an object that outer handlers will still see.
<iostream>
<stdexcept>
<string>
struct ConfigError : std::runtime_error {
explicit ConfigError(const std::string& key)
: std::runtime_error("bad value for key: " + key) {}
};
void parse(int which) {
if (which == 0) throw ConfigError("timeout");
if (which == 1) throw std::out_of_range("index 7 of 3");
throw 42;
}
int main() {
for (int i = 0; i < 3; ++i) {
try {
parse(i);
}
catch (const ConfigError& e) {
std::cout << "ConfigError: " << e.what() << '\n';
}
catch (const std::exception& e) {
std::cout << "std::exception: " << e.what() << '\n';
}
catch (...) {
std::cout << "unknown exception type\n";
}
}
}
A handler is picked by first-viable-in-order matching against the thrown object's type, using only identity, derived-to-base and pointer conversions.
Worked examples
Order decides, and by-value handlers slice
A base-class handler placed first swallows a derived exception and copying into it destroys the derived behaviour.
<iostream>
struct Base {
virtual const char* name() const { return "Base"; }
virtual ~Base() = default;
};
struct Derived : Base {
const char* name() const override { return "Derived"; }
};
int main() {
try {
throw Derived{};
}
catch (Base b) {
std::cout << "by value: " << b.name() << '\n';
}
catch (Derived&) {
std::cout << "never reached\n";
}
try {
throw Derived{};
}
catch (const Base& b) {
std::cout << "by reference: " << b.name() << '\n';
}
}
Example explained
Line 1throw Derived{}; copies the object into storage owned by the runtime, so the exception object's dynamic type is Derived.
Line 2catch (Base b) is viable through the public derived-to-base conversion and wins purely on position; initialising b slices the object, so name() resolves to Base::name.
Line 3catch (Derived&) can never run here; GCC and Clang warn that it will be caught by an earlier handler, but the program still compiles and runs.
Line 4catch (const Base& b) binds directly to the live Derived exception object, so the virtual call dispatches to Derived::name.
Rethrowing keeps the original type
A bare throw; in a base-class handler preserves the exception object so an outer, more specific handler still matches.
<iostream>
<stdexcept>
void inner() {
throw std::invalid_argument("not a number");
}
void middle() {
try {
inner();
}
catch (const std::exception& e) {
std::cout << "middle saw: " << e.what() << '\n';
throw;
}
}
int main() {
try {
middle();
}
catch (const std::invalid_argument& e) {
std::cout << "outer matched invalid_argument: " << e.what() << '\n';
}
catch (const std::exception&) {
std::cout << "outer matched exception\n";
}
}
Example explained
Line 1std::invalid_argument derives from std::logic_error and so from std::exception, which is why the handler in middle is viable.
Line 2Binding the exception to const std::exception& only changes how middle views the object; it does not change the object's type.
Line 3throw; with no operand rethrows that same object rather than a copy, so its type is still invalid_argument when it reaches main.
Line 4Writing throw e; instead would copy-construct a plain std::exception and the second handler in main would run.
Important notes
catch (...) must be the last handler of its try block; putting it earlier is a compile error, not just a dead-handler warning.
Matching applies no user-defined conversions, so a throw of a string literal is a const char* and is caught by catch (const char*), never by catch (std::string).
Common mistakes
Writing catch (const std::exception&) above catch (const MyError&): the specific handler becomes unreachable and its recovery code silently never runs, with only a compiler warning to hint at it.
Catching a polymorphic exception by value, as in catch (std::runtime_error e): the derived part is sliced away, so what() reports the base message and any extra fields you attached are gone.
Using throw e; inside a handler to pass the problem on: that throws a copy of the handler parameter's static type, so an outer catch of the derived type no longer matches.
Try it yourself
Change, predict, then run
Define struct ParseError : std::logic_error and throw it inside a try block with three handlers in this order: const ParseError&, const std::logic_error&, and catch (...). Run it, then swap the first two handlers and see which message the program prints.
Open the C++ workspaceCheck your understanding
A try block has handlers in this order: catch (const std::exception&) then catch (const AppError&), where AppError publicly derives from std::runtime_error. What happens when an AppError is thrown inside the try block?
- The std::exception handler runs, because handlers are tried in order and it can accept an AppError
- The AppError handler runs, because it is the more specific match for the thrown type
- The program calls std::terminate, because handler order like this is ill-formed
- Both handlers run, in the order they are written
Show answer
Handler selection stops at the first clause whose type can accept the thrown object, and the derived-to-base conversion makes const std::exception& viable, so the AppError clause is unreachable. Option 1 is tempting because it assumes handlers are ranked like overload candidates, but no best-match rule exists for catch clauses; only exactly one handler ever runs for a given throw, which also rules out option 3.