C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
Exceptions, throw, and the cost of throwing
Throw exceptions correctly and reason about their cost: what the throw expression copies, why the happy path is free, and why each throw costs microseconds.
What you will learn
- Throw temporaries and catch by const reference so no exception copy is ever made
- Rethrow with bare throw; because throw e; slices to the operand's static type
- Explain why a try block costs nothing until a throw happens, then about a microsecond
- Judge exceptions by failure frequency, not by how tidy the call site looks
Understanding Exceptions, throw, and the cost of throwing
A throw-expression does not hand over the object you name; it copy-initializes a new object, the exception object, into storage the runtime owns. That storage has to live outside the throwing frame because that frame is about to disappear, so libstdc++ and libc++ go through __cxa_allocate_exception, which normally reaches malloc and falls back to a small emergency buffer when memory is exhausted. The type of the exception object comes from the static type of the operand, not from the dynamic type behind it, which is why throw Derived{} produces a Derived while throw e; inside catch (const Base& e) produces a Base. Throwing a temporary is the most predictable form: since C++17 a prvalue operand initializes the exception object directly, so no copy constructor runs at all.
On the ordinary path a throw costs nothing, and that is literal: with table-driven unwinding the compiler emits no instructions to enter or leave a try block and no flag to test on return. What it emits instead is static data, .gcc_except_table entries plus landing-pad code parked in a cold section, so the price of exception support is binary size and some lost optimization freedom, not time per call. The bill arrives at the throw: the runtime allocates the exception object, then walks the stack twice, first asking each frame's personality routine whether it has a matching handler (std::type_info comparisons, sometimes string comparisons across shared-library boundaries), then walking again to run cleanups and jump into the chosen landing pad. That is why a caught throw usually lands in the microsecond range while a plain return is a nanosecond or two, and the gap widens with the number of frames and destructors between the throw and the handler.
So throw is a pay-per-use feature with a large, mostly fixed per-event price, and the only question that matters is how often the event fires. Three failures per ten million records are free in practice; a throw for every missing key in a lookup loop or every non-digit in a parser turns a nanosecond decision into a microsecond one and shows up immediately in a throughput profile. Part of the cost is outside your control: the first throw in a process may pay to locate unwind tables for the loaded objects, and finding those tables per frame does not scale well when many threads throw at once, so real throughput can be worse than a single-threaded measurement suggests. Use throw when the caller almost certainly cannot continue, and keep the thrown type small so allocating and copying it stays cheap.
<iostream>
<string>
<utility>
struct Failure {
std::string detail;
explicit Failure(std::string d) : detail(std::move(d)) {
std::cout << "Failure built\n";
}
Failure(const Failure& other) : detail(other.detail) {
std::cout << "Failure copied\n";
}
~Failure() { std::cout << "Failure destroyed\n"; }
};
const Failure* first_seen = nullptr;
void write_block() {
throw Failure("disk full"); // prvalue: built straight into the exception object
}
void save() {
try {
write_block();
} catch (const Failure& e) {
first_seen = &e;
std::cout << "save() saw: " << e.detail << "\n";
throw; // the same object continues; nothing is copied
}
}
int main() {
try {
save();
} catch (const Failure& e) {
std::cout << "main() saw: " << e.detail << "\n";
std::cout << "same object: " << (&e == first_seen ? "yes" : "no") << "\n";
}
std::cout << "after the handler\n";
}
throw copy-initializes an exception object into runtime-owned storage and starts a two-phase stack search, which is why exceptions cost nothing until one is thrown and roughly a thousand returns' worth of time when one is.
Worked examples
throw; versus throw e;
Shows that a bare rethrow keeps the original exception object while throwing the caught reference creates a new, sliced one.
<iostream>
struct Base {
virtual const char* name() const { return "Base"; }
virtual ~Base() = default;
};
struct Derived : Base {
const char* name() const override { return "Derived"; }
};
void keeps_type() {
try {
throw Derived{};
} catch (const Base&) {
throw; // rethrow: still the same Derived object
}
}
void loses_type() {
try {
throw Derived{};
} catch (const Base& e) {
throw e; // new exception object, static type Base
}
}
int main() {
try { keeps_type(); }
catch (const Base& e) { std::cout << "after throw; " << e.name() << "\n"; }
try { loses_type(); }
catch (const Base& e) { std::cout << "after throw e; " << e.name() << "\n"; }
}
Example explained
Line 1throw Derived{} makes the exception object a Derived, because the operand's static type is Derived.
Line 2The bare throw; in keeps_type does not create anything; it re-enters propagation with the object that already exists, so name() still resolves to Derived.
Line 3In loses_type the operand e has static type const Base, so the exception object is a Base copy-constructed from the Derived part that matched, and the derived state is gone.
Line 4A handler written as catch (const Derived&) would match the first exception and miss the second, which is how throw e; silently breaks callers.
Measuring the per-throw price
Runs the same failure detection twice, once returning a flag and once throwing, so the ratio between the two paths is visible.
<chrono>
<iostream>
<stdexcept>
bool check_return(int i) { return i % 7 != 0; } // false means failure
void check_throw(int i) {
if (i % 7 == 0) throw std::runtime_error("bad input");
}
int main() {
const int n = 200000;
int failed_return = 0, failed_throw = 0;
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < n; ++i)
if (!check_return(i)) ++failed_return;
auto t1 = std::chrono::steady_clock::now();
for (int i = 0; i < n; ++i) {
try { check_throw(i); }
catch (const std::runtime_error&) { ++failed_throw; }
}
auto t2 = std::chrono::steady_clock::now();
using us = std::chrono::microseconds;
auto plain = std::chrono::duration_cast<us>(t1 - t0).count();
auto thrown = std::chrono::duration_cast<us>(t2 - t1).count();
std::cout << "failures found: " << failed_return << " and " << failed_throw << "\n";
std::cout << "throwing loop at least 20x slower: "
<< (thrown > 20 * plain ? "yes" : "no") << "\n";
}
Example explained
Line 1Both loops detect the same 28572 failures, so the only difference is the mechanism that carries the failure back.
Line 2Raw microsecond counts differ per machine and per compiler, which is why the program prints a ratio test; in practice the throwing loop runs 50 to 200 times slower here.
Line 3Each of those 28572 throws allocates an exception object, searches the frames of check_throw and main for a handler, then unwinds into the landing pad in the catch clause.
Line 4The returning loop pays one integer modulo and a branch per iteration, so nothing in the first loop is comparable to the fixed setup cost of a single throw.
Rethrow with nothing in flight
Demonstrates that a bare throw; outside an active exception ends the program through std::terminate rather than raising anything.
<cstdlib>
<exception>
<iostream>
void bail() {
std::cout << "terminate handler called\n";
std::exit(0); // a terminate handler must not return
}
int main() {
std::set_terminate(bail);
std::cout << "about to rethrow with nothing in flight\n";
throw;
}
Example explained
Line 1std::set_terminate(bail) makes the reaction visible and deterministic instead of relying on the library's default abort message.
Line 2throw; is a rethrow of the currently handled exception, and there is none here, so the runtime calls std::terminate instead of starting an unwind.
Line 3std::exit ends the program and flushes std::cout; returning from a terminate handler is not allowed.
Important notes
Eliding the copy when you throw a named local is permitted but not required, and it is disallowed when the variable outlives the enclosing try block; throwing a temporary is the only form where no copy is guaranteed.
The exception object lives until the last handler that catches it finishes, so references into it stay valid across frames, but a pointer saved out of a handler dangles once that handler exits.
Common mistakes
Writing throw e; inside catch (const std::exception& e): the new exception object has static type std::exception, so what() degrades to the generic base text and an outer catch (const std::runtime_error&) stops matching.
Throwing a pointer, as in throw new ParseError{...}: catch (const ParseError&) never matches it, and any handler that does catch the pointer must remember to delete it, so it normally leaks.
Using throw as a not-found signal or loop exit: the code is correct but a lookup that misses a million times moves from milliseconds to seconds, because every miss pays for an allocation and two stack walks.
Try it yourself
Change, predict, then run
Write parse_int(const std::string&) that throws std::invalid_argument on non-numeric input, then time 100000 calls with the input "abc" against a bool-returning version, and print both microsecond totals and their ratio.
Open the C++ workspaceCheck your understanding
A validation function runs on 10 million records and about 3 of them are invalid. What happens if it reports invalid records by throwing instead of returning a status code?
- The 9,999,997 valid records run at the same speed; only the 3 failures pay, roughly a microsecond each
- Every call gets slower, because a function that can throw must check a hidden error flag before returning
- Total time drops, because the compiler can delete the branch that produced the status code
- Nothing changes either way, since a throw compiles into a jump straight to the handler
Show answer
Table-driven unwinding keeps the machinery in static tables and cold landing-pad code, so entering a try block and returning normally add no instructions; the cost appears only when an exception is actually in flight, and there it is dominated by allocating the exception object and walking the stack. Option 1 describes the old setjmp/longjmp scheme, not what current x86-64 or ARM64 compilers emit. Option 3 is wrong because the validity test itself is still needed, and option 4 ignores the allocation, the type matching and the two-phase stack walk that happen before control reaches the handler.