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.