Ch19.3: C++ Legacy Exceptions
1. What the Feature Is
The C++ exception mechanism is the one this book refers to as
legacy exceptions. It has three keywords, one library class to derive
from, and one compiler flag, -fexceptions, which is on by default. It is
taught here because you will read it: it is in the standard library, in
every older codebase, and in every C++ interview. That it is not what you should write is
Ch19.8's subject.
throw some_value; // report a failure
try { ... } catch (::std::exception const &e) { } // handle the ones you know
try { ... } catch (...) { } // handle anything that is left
throw; // re-raise the current exception
Those four lines plus -fexceptions are the entire surface. Everything else is
detail about what the runtime does when the keyword runs.
2. The Rules, Precisely
Because herbceptions are two words on the return path
(Ch3.16), it is easy to assume that
throw is the same shape. It is not, and the differences are what you have to
know to read legacy code without misreading it:
| Rule | What it means |
|---|---|
| Any copyable type may be thrown | throw 42; is legal. Do not do it: an int carries no
message and no type for a handler to match on |
| The object is copied into runtime-owned storage | throw local_variable; throws a copy, and the copy lives until the last
handler for it exits. Throwing a copy also means your type must be copyable, and that
copying happens at the worst possible moment |
Throw an object, catch by const& |
catch (T const &e) binds the runtime copy without another copy.
catch (T e) copies it, and for a polymorphic T it
slices the object — see
Ch19.4 |
| Handlers are tried in order, first match wins | So catch (::std::exception const &e) must come after
catch (::std::invalid_argument const &e), or it swallows it |
| Matching uses the base classes too | A throw ::std::invalid_argument is caught by a handler for
::std::logic_error or ::std::exception, because those are
its bases |
| Frames are destroyed on the way out | Automatic objects between the throw and the handler are destroyed, in
reverse order of construction, and the process of getting there is the
unwinding |
| A throw from a destructor during unwinding terminates | The runtime has two exceptions in flight and no way to choose. This is why
destructors are noexcept in this book
(Ch14) |
3. A Program That Uses All of It
Two things are worth watching in a real run: the order the destructors run in, and the fact
that a handler for a base class accepts a derived exception. A destructor cannot print
— it is noexcept — so this program records into a buffer and prints
the buffer from main:
#include <fast_io.h>
#include <stdexcept>
#include <fast_io_dsal/string_view.h>
namespace
{
char log_buffer[256]{};
::std::size_t log_length{};
void record(char const *data, ::std::size_t size) noexcept
{
for (::std::size_t i{}; i != size && log_length != sizeof(log_buffer); ++i)
{
log_buffer[log_length] = data[i];
++log_length;
}
}
struct tracer
{
::fast_io::string_view name;
explicit tracer(::fast_io::string_view n) noexcept
: name(n)
{}
tracer(tracer const &) = delete;
tracer &operator=(tracer const &) = delete;
~tracer()
{
record(name.data(), name.size());
record(";", 1);
}
};
int parse(char const *text) noexcept(false)
{
tracer const outer{"outer"};
tracer const inner{"inner"};
throw ::std::invalid_argument(text);
}
} // namespace
int main()
{
try
{
parse("7 is not a number");
}
catch (::std::logic_error const &e)
{
::fast_io::io::print("logic_error ", ::fast_io::mnp::os_c_str(e.what()), "\n");
}
catch (::std::exception const &e)
{
::fast_io::io::print("exception ", ::fast_io::mnp::os_c_str(e.what()), "\n");
}
::fast_io::io::print("destructors ", ::fast_io::mnp::os_c_str(log_buffer, log_length), "\n");
}
logic_error 7 is not a number
destructors inner;outer;
inner;outer; is the unwinding: the two tracer objects in
parse were destroyed in reverse order of construction, between the
throw and the handler, before a single line of the handler ran. And the
handler that caught the ::std::invalid_argument was written for
::std::logic_error, its base — which is why the type hierarchy in
Ch19.4 matters.
One thing to notice about the signature: noexcept(false). That is the legacy
spelling of "this function may throw", and it is the only error contract the old mechanism
can state. If the function were plain noexcept, the throw would call
::std::terminate instead of unwinding, which is why every legacy codebase is
full of functions whose contract is invisible.
4. Storing an Exception Instead of Throwing It
Sometimes you must catch an exception and keep it — to rethrow it from another
thread, to store it in a future, to attach it to a result. The type for that
is ::std::exception_ptr, and you get one with
::std::current_exception() inside a handler:
try
{
work();
}
catch (...)
{
::std::exception_ptr saved{::std::current_exception()}; // keeps the exception alive
// ... hand `saved` to another thread or store it ...
::std::rethrow_exception(saved); // re-raise it later
}
exception_ptr is reference-counted, so copying it is cheap-ish, and the
exception object lives as long as the last pointer to it. It is a heap allocation with a
reference count, a type-erased handle and an ABI-defined layout — and it is exactly
the kind of machinery herbceptions replaced with two words, except that a herbception is a
value you can copy freely and compare.
There is also ::std::nested_exception and
::std::rethrow_if_nested for "this failure was caused by that failure". Both
exist because the mechanism has no way to carry a second error value; a herbception domain
just has a code that says what the failure was
(Ch19.1).
5. Reading It Is the Job; Writing It Is Not
You now know everything you need to read a legacy codebase: what a throw
costs you in understanding, what a handler matches, what unwinding destroys, and what
exception_ptr keeps alive. The two pages that follow fill in the two class
libraries that come with the mechanism
(Ch19.4,
Ch19.5), and then
Ch19.6 shows the one supported
way to hand a failure between the two mechanisms.
What you must not take from this page is "so I can use it in my new code". Legacy exceptions are not available on most targets you will ship to, and the build flags in Ch19.7 make that a compile error rather than a surprise. The reasons are in Ch19.8.