Abstract

P0709[1] states that "[d]ivergent error handling has fractured the C++ community into incompatible dialects, because of long-standing unresolved problems in C++ exception handling", and proposes a new, efficient mechanism, communicating the exception object in terms of an alternative function return value, which must be of the new type std::error.

This solution would enable teams that at present use error codes, to transition to using the try/catch syntax. While it is difficult to imagine the -fno-exceptions crowd to agree to throwing exceptions, it is true that the transition could in theory be made without any loss of functionality or efficiency.

To indicate different error conditions, teams that at present use exception handling would have to transition from throwing objects of different types, to throwing objects with different values of type std::error. However, exception objects can carry custom state as well. For example, an object of a user-defined exception type may hold a relevant file name, or a port number or any other data relevant to a failure. This can not be supported if exception objects are required to be of type std::error.

This means that P0709 effectively introduces a new error-handling dialect, further fracturing the C++ community.

This paper presents a different approach that allows exception objects of arbitrary types to be transported efficiently when the program uses catch-by-value. We also introduce a novel exception handling approach which can be used in place of exception type hierarchies to classify error conditions dynamically. In new programs, this would remove any need to use catch-by-reference (which of course will be supported for compatibility with legacy programs).

Catching Values

It is possible to addresses exception handling overhead to a degree comparable to what P0709 proposes, while preserving the ability to throw objects of arbitrary types. To this end, we will make two assumptions that will appear unreasonable at first. This will later be extended to a complete system for dynamic matching of error conditions without a loss of functionality, compared to the current catch-by-reference approach.

For now:

  1. We will only use catch-by-value, e.g. catch( E e ) rather than catch( E& e ).

  2. We change the semantics of catch( E e ): it now matches exception objects by their static, rather than dynamic type.

With this in mind, the first thing to notice is that the exception object can use automatic storage duration, in the scope of the try / catch block. Consider:

try
{
	f(); // Throws
}
catch( E1 e1 )
{
	// Use e1
}
catch( E2 e2 )
{
	// Use e2
}

Due to our assumptions, the type of the exception objects is known statically, and we can reserve storage on the stack, suitable for storing e1 and e2. We will need the ability to know if each of e1 and e2 is available (thrown), so instead of storing objects of type E1 or E2, we will be storing objects of type optional<E1> and optional<E2> (or some equivalent).

Next, we need a mechanism to enable a throw E1{} or throw E2{}, invoked from within f, to access that storage efficiently. This is possible by means of thread local storage. In the try block, before invoking f, we set a thread-local pointer pE1 of type optional<E1> to point the storage reserved for e1, and another thread-local pointer pE2 of type optional<E2> to point the storage reserved for e2.

Current exception handling implementations use thread-local storage as well.

To implement e.g. throw E1{}, we access the thread-local pointer pE1:

  • If pE1 is null, this means that no catch( E1 e1 ) statement is currently available in the call stack; in this case we simply invoke the legacy throw machinery.

  • Otherwise, we initialize an object of type E1 in the storage pointed by pE1.

Now we need to unwind the stack to the top-most exception-handling scope. This could be implemented just as proposed by P0709, except that what needs to be communicated as an alternative return value is no longer an exception object, but rather the failure flag,[2] the single bit of information that indicates success or failure.

When we reach a try..catch scope, we examine each of the optional<> objects, in order; in this case we first examine the object storing e1 and, if it is not empty, we execute that scope. Otherwise we examine the object storing e2, and so on. If we can’t find a suitable catch, we simply continue unwinding the stack (we know we will eventually find a suitable catch, or else we would have invoked the legacy throw mechanism).

In addition to the faster stack unwinding, efficiency is further improved because our catch statements effectively match error objects by their static, rather than dynamic type. However, we still want a catch( E e ) to match objects of type E as well as of any other type that derives from E. Therefore, a throw E{} doesn’t only examine the thread-local pointer pE but also the thread-local pointers pB that correspond to each of the public bases of E. If any of them are non-null, we initialize each pointed optional<B> object via slicing using the copy constructor. For example, consider the following type hierarchy:

struct file_error { };

struct file_open_error: file_error
{
	std::string file_name;
};

....
throw file_open_error { "file.txt" };

The throw statement above will first initialize a file_open_error object, and then examine the thread-local pointer to optional<file_open_error> and the thread-local pointer to optional<file_error>, and if either is non-null, initialize each corresponding object (by slicing, if needed). That way, the thrown exception could be handled by either catch( file_error ) or catch( file_open_error ), even though at that time the type is matched statically. Effectively, we’re slicing at the point of the throw rather than at the point of the catch, but the behavior is otherwise equivalent.

This implementation should be similar to what P0709 proposes in terms of stack unwinding efficiency, but probably better because the alternative return value is not burdened with transporting a std::error. We just need to communicate the failure flag, while each exception object is initialized directly by the throw statement, regardless of how deep the call stack is at that point.

Catching More Values

The presented system of exception handling can be easily extended to allow a single catch-by-value statement to work with more than one object. Consider:

try
{
	f(); // throws
}
catch( E1 e1, E2 e2 )
{
	// Use e1 and e2
}
catch( E1 e1 )
{
	// Use e1
}
catch( E2 e2 )
{
	// Use e2
}

When the stack unwinding reaches the error handling scope above, the catch statements are examined in order. The first one will be matched if both the optional<E1> and optional<E2> are non-empty (see Catching Values), while the others require only one of them to be non-empty.

The throw statement can be similarly extended to allow multiple objects of different types to be thrown simultaneously, using the following syntax:

throw { E1{}, E2{} }; // Throws both objects

Why is this useful? Because it can render exception type hierarchies — and all inefficiencies associated with them — unnecessary. Let’s consider an example.

Using the exception type hierarchy from the previous section, we could write the following function:

std::shared_ptr<FILE> open_file( char const * name )
{
	assert( name != 0 );

	if( FILE * f = std::fopen( name, "rb" ) )
		return std::shared_ptr<FILE>( f, &std::fclose );

	throw file_open_error { name };
}

The throw statement above effectively classifies the failure as "file error" as well as "open error". To handle such failures, we could use the following:

try
{
	auto f = open_file( n );
	....
}
catch( file_open_error & e )
{
	// Handle a "file error" that is also an "open error".
	std::cerr << "Failed to open file " << e.file_name << std::endl;
}
catch( file_error & )
{
	// Handle any "file error".
	std::cerr << "Failed to access file" << std::endl;
}

Can the proposed catch-by-value semantics be used to similarly classify failures dynamically? It very much can, minus the cost of the memory allocation and the dynamic cast. We simply eliminate the exception type hierarchy, and replace it with individual types:

struct file_error { };
struct open_error { };
struct file_name { std::string value; };

With this in place, to classify the error as what would have previously been file_open_error, open_file would now look like this:

std::shared_ptr<FILE> open_file( char const * name )
{
	assert( name != 0 );

	if( FILE * f = std::fopen( name, "rb" ) )
		return std::shared_ptr<FILE>( f, &std::fclose );

	throw { file_error{}, open_error{}, file_name { name } };
}

To handle such failures, we could use the following catch statements:

try
{
	f(); // Calls open_file internally
}
catch( file_error, open_error, file_name fn )
{
	// Handle a "file error" that is also an "open error" and has an associated file name.
	std::cerr << "Failed to open file " << fn.value << std::endl;
}
catch( file_error, file_name fn )
{
	// Handle any "file error" that has an associated file name.
	std::cerr << "Failed to to access file " << fn.value << std::endl;
}
catch( file_error )
{
	// Handle any "file error".
	std::cerr << "Failed to access file" << std::endl;
}

Note that we always catch by value, but because we can throw and catch multiple values of different types, this novel error-classification system is more suitable than a type hierarchy in describing what went wrong: we can now associate a file_name with any failure, not only with "file open" errors.

In fact, we will now demonstrate that it is even more powerful, because it allows the program to refine the classification of failures dynamically, after the initial throw.

Dynamic Error Classification

A new standard function, std::on_error, could be used in any scope to either refine the classification of an active failure or to associate additional state with it. Let’s rewrite the open_file function from the previous section:

std::shared_ptr<FILE> open_file( char const * name )
{
	assert( name != 0 );

	auto attach = std::on_error( file_error{}, file_name{ name } );

	if( FILE * f = std::fopen( name, "rb" ) )
		return std::shared_ptr<FILE>( f, &std::fclose );

	throw open_error{};
}

The std::on_error function returns an object of unspecified type which caches, in a std::tuple, all of the values passed as arguments. In that object’s destructor, if there was no error (detected by means of e.g. std::uncaught_exceptions), all of the cached values are simply discarded. But if we’re unwinding the stack due to an exception, each of the cached values is "thrown" in addition to whatever other values were passed to the throw statement that put us on the exception path.

In this case, the effect is that any error that escapes the open_file function:

  1. Is classified as a file_error, in addition to any previous classification; and

  2. A file_name object will be available for catch-by-value at any exception-handling scope.

The key point here is that on_error is dynamic, it is able to "attach" additional state to any failure. As another example, consider this parse_file function:

parsed_info parse_file( FILE & f )
{
	auto attach = std::on_error( parse_error{} ); // Additionally classify as parse_error...

	std::string s = read_line( f ); // ...any exception thrown by read_line.
	....
	....
}

A Few Important Details

Propagation of Unmatched Exception Objects

It is important to consider the case when a try / catch block fails to match the current exception:

try
{
	try
	{
		throw E1{};
	}
	catch( E1 e1, E2 e2 )
	{
	}
}
catch( E1 e1 )
{
}

As explained in Catching More Values, the throw statement will initialize the e1 object in the scope of the inner try / catch block. However, that catch block will not be matched, because it requires two exception objects, one of type E1 and another of type E2.

Since no catch statement in the inner block matches the failure, the e1 object initialized in that scope needs to be moved in the storage reserved for that type by an outer scope — or discarded if no such storage exists. This works as described in Catching Values: for each available exception object in the inner scope, we check the thread-local pointer that corresponds to its type, and if it is non-null, we move the exception object into that storage.

A special case of this behavior is when rethrowing the original exception(s) in a catch statement. For example:

struct Base { };
struct Derived: Base { };

try
{
	try
	{
		throw Derived{};
	}
	catch( Base b )
	{
		throw;
	}
}
catch( Derived d )
{
}
catch( Base b )
{
}

The inner scope provides storage for a Base object, but not for a Derived object. Therefore, the throw statement will initialize both the b object in the inner scope and the d object in the outer scope. When we rethrow, the b object in the outer scope is initialized by moving from the b object in the inner scope.

Error ID

We need a way to uniquely identify a specific failure initiated by a throw statement. Consider:

try
{
	try
	{
		throw { A{}, B{} };
	}
	catch( A a )
	{
		throw C{};
	}
}
catch( B b )
{
}
catch( C c )
{
}

The first throw statement will initialize the a object in the scope of the inner try / catch, and the b object in the scope of the outer try / catch. Then, the second throw statement initializes the c object, also in the outer scope.

When handling exceptions in the outer scope, if we simply check whether an object of type B is available by examining the optional<B> object, we would execute the catch( B b ) scope. This is incorrect, because there is no B object passed to the second throw statement.

The solution is to assign each failure a unique integer identifier, and store it together with each exception object initialized (in the scope of the corresponding catch statement) at the time of the throw. This enables the catch( B b ) in the outer scope to determine that the B object it does contain is in fact not associated with the current failure (because the error identifier stored with b does not match the current error identifier). The unique identifier can be implemented in terms of a thread-local counter incremented by throw.

Communication of the Failure Flag

Like P0709, we need the ability to communicate an alternative "return value" in case of an error. Unlike P0709, this return value is simply the failure flag, a single bit of information.

In short, after calling a function, we need to be able to check if a failure has occurred. In the previous section, we explained that we also need to associate a unique integer Error ID value with each failure.

Current ABIs are already able to associate data with the current failure. This data uses the following struct:

struct __cxa_eh_globals
{
	__cxa_exception * caughtExceptions;
	unsigned int uncaughtExceptions;
};

This struct uses TLS. We can add the Error ID in this same struct, without breaking existing programs:

struct __cxa_eh_globals
{
	__cxa_exception * caughtExceptions;
	unsigned int uncaughtExceptions;
	unsigned int errorID;
};

When we throw a new exception, we generate a unique value and store it in the errorID. When the exception is handled, we simply set the errorID to zero.

With this in place, strictly speaking we don’t even need to use the return value to communicate the failure flag; after we call a function, we can simply compare the errorID with zero to check for errors.

One possible way to communicate the failure flag in a new binary calling convention is to use one of the CPU flags, for example the Carry flag. However, the CPU instructions used to manipulate the frame pointer and the stack pointer usually modify the Carry flag, so it remains to be seen if this is a good idea or not.

This functionality — the ability to communicate the failure flag efficiently — is probably the most important problem that has to be solved to deliver maximum-performance on the "sad" path. Perhaps this functionality should be implemented in silicone in the future (e.g. a special failure flag bit).

Efficiency Considerations

P0709 claims that "today’s dynamic exception types violate the zero-overhead principle", but this is only valid when the speed overhead on the "sad" path matters. Let’s not forget that the current ABIs in fact have zero overhead on the "happy" path. From this point of view, the approach of using alternative return values to communicate the exception objects is the one that violates the zero-overhead principle.

It is a fact that there are existing users of C++ exception handling which are happy with the legacy tradeoff of zero overhead on the "happy" at the cost of very inefficient "sad" path. Therefore, the existing implementations should be extended rather than replaced; and we need a way for users to select which convention to use.

Presumably, the way to deal with this is the proposed throws exception specification. But a wise man once taught us that exception specifications are impractical, regardless of how they are implemented:

The short answer is that nobody knows how to fix exception specifications in any language, because the dynamic enforcement C++ chose has only different (not greater or fewer) problems than the static enforcement Java chose. …​ When you go down the Java path, people love exception specifications until they find themselves all too often encouraged, or even forced, to add throws Exception, which immediately renders the exception specification entirely meaningless. (Example: Imagine writing a Java generic that manipulates an arbitrary type T).[3]
— Herb Sutter

Exception specifications are indeed incompatible with generic programming — and therefore with C++. But even if we ignore this ancient wisdom, the introduction of a new exception specification will add yet another "incompatible dialect" and further fracture the C++ community.

Besides, it is not practical to specify statically whether or not a function which may fail should throw using the legacy (zero overhead on the "happy" path), or the new (minimal overhead on the "happy" and the "sad" path) mechanism. Consider that a library author is not in a position to make this determination: different users of the same library may have different preferences on this issue.

Indeed, the best case scenario is if the caller of a function which may fail can choose which exception-handling strategy should be used. The implementation strategy outlined in this paper in fact provides a natural way for the programmer to express this preference:

  • If exceptions are handled using catch-by-reference, this requests the legacy (zero overhead on the "happy" path) mechanism.

  • If exceptions are handled using catch-by-value, the new (minimal overhead on the "happy" and the "sad" path) is requested.

This places the decision firmly in the hands of the application programmer (where it belongs), while library authors don’t need to be concerned with it.

Implementation

The proposed catch-by-value semantics are implemented in C++11 library format by Boost LEAF[4] introduced with Boost 1.75.

The open_file example from the Catching More Values section can be written in terms of Boost LEAF using the following syntax:

struct file_error { };
struct open_error { };
struct file_name { std::string value; };

std::shared_ptr<FILE> open_file( char const * name )
{
	assert( name != 0 );

	if( FILE * f = std::fopen( name, "rb" ) )
		return std::shared_ptr<FILE>( f, &std::fclose );

	throw leaf::exception( file_error{}, open_error{}, file_name { name } );
}

The communication of the error objects passed to leaf::exception is implemented using the TLS technique described in Catching Values. Error handling uses the following syntax:

leaf::try_catch(
	[]
	{
		auto f = open_file( n );
		....
	},
	[]( file_error, open_error, file_name fn )
	{
		// Handle a "file error" that is also an "open error" and has an associated file name.
		std::cerr << "Failed to open file " << fn << std::endl;
	}
	[]( file_error, file_name fn )
	{
		// Handle any "file error" that has an associated file name.
		std::cerr << "Failed to to access file " << fn << std::endl;
	}
	[]( file_error )
	{
		// Handle any "file error".
		std::cerr << "Failed to access file" << std::endl;
	} );

The library supports the Dynamic Error Classification technique described in this proposal. The documentation elaborates on this idea, demonstrating that it is even possible to handle errors uniformly, regardless of whether the error flag is communicated implicitly by throwing an exception, or explicitly in terms of some result<T> type.


1. Sutter, "Zero-overhead deterministic exceptions: Throwing values", http://open-std.org/JTC1/SC22/WG21/docs/papers/2018/p0709r0.pdf
2. Dotchevski, "Error Handling Principles and Practices", https://github.com/boostorg/leaf/blob/master/doc/whitepaper.md
3. Sutter, "Sutter’s Mill", https://herbsutter.com/2007/01/24/questions-about-exception-specifications/
4. Dotchevski, "Lightweight Error Augmentation Framework", https://boostorg.github.io/leaf/