Aggregated exceptions: Proposal summary

Based on my previous post about errors in destructors and aggregated exceptions, I first made a Reddit thread, and then a proposal in the ISO C++ Future Proposals group. Based on feedback I've received, the proposal has evolved, and would benefit from a page where its current state is published.

I summarize the proposal's state. I will update this if I get further feedback prompting change.

Justification

There is a problem which I believe is limiting C++ in its potential for powerful expression. The problem is the single-exception policy, and its direct consequence: the marginalization of destructors, exemplified in recent years by how they're now marked by default noexcept.

I believe this problem is currently viewed incorrectly by many, and I wish to propose a solution. The solution is aggregated exceptions. I contend these are conceptually simple; resolve the Gordian Knot of destructor exceptions; are backward compatible, and straightforward to implement. :-)

There is a widespread belief, held passionately by many, which I believe is conceptually in error. This is that destructors are supposed to be nothing more than little cleanup fairies. That they should only:
  • quietly release resources, and
  • kindly shut up about any errors.
I find this a limiting and restrictive view, which does not permit full expression of destructors as a way to:
  1. schedule code for execution;
  2. determine the order of execution; but
  3. not dictate the exact trigger for execution to take place.
I propose that the limiting view of destructors is not inherently obvious, but is an accidental ideology. It arises not because we freely choose it, but because of a flaw that has plagued C++ since the introduction of exceptions. This flaw is the single-exception policy. This has prevented answers to questions such as:
  • What to do when a destructor throws, and an exception is already in flight?
  • What to do if we're destroying (part of) a container, and destructors throw for 2 or more of the contained objects?
I propose that we should not have to cope with not having answers for these questions in this day and age; and that support for unlimited aggregated exceptions answers them straightforwardly.

The support I propose:
  • Is conceptually simple.
  • Legitimizes exceptions in destructors.
  • Provides means for containers to handle, aggregate, and relay such exceptions.
  • Imposes no costs on applications that do not use this.
  • Provides a way for destructors to report errors. This is something for which there is currently no solid language support, outside of std::terminate.
  • Emancipates destructors as a way to schedule code for execution. This is to say any code; even code that may throw. This is a frequent usage pattern e.g. in database libraries, whose destructors must rollback; and rollback may involve exceptions.
The use of destructors for general execution scheduling, rather than only cleanup, is recognized as something the language reluctantly needs to support. C++ has always supported throwing from destructors. Even in the latest C++ versions, you can do so by declaring them noexcept(false). However, you better not throw if an exception is already in flight; and you better not store these objects in containers. My proposal addresses this in a more profound way that the noexcept approach does not.

Proposal

Core changes:
  1. In a running program, the internal representation of an exception in flight is changed from a single exception to a list of exceptions. Let's call this the exception-list.
  2. std::exception_ptr now points to the beginning of the exception-list, rather than a single exception. Methods are added to std::exception_ptr allowing a catch handler, or a container in the process of aggregating exceptions, to walk and manage the exception-list.
  3. When the stack is being unwound due to an exception in flight; and a destructor exits with another exception; instead of calling std::terminate, the new exception is simply added to the end of the exception-list. Execution continues as it would if the destructor exited normally.

Catch handlers

Traditional catch handlers:
  • To maintain the meaning of existing programs as much as possible, a traditional catch handler cannot receive an exception-list that contains more than one exception. If an aggregated exception meets a traditional catch handler, then to preserve current behavior, std::terminate must be called. This means we need a new catch handler to handle multi-exceptions.
  • Notwithstanding the above, catch (...) must still work. This is often used in finalizer-type patterns that catch and rethrow, and do not care what they're rethrowing. This type of catch handler should therefore be able to catch and rethrow exception-lists with multiple exceptions. It also provides a method to catch and handle an exception-list as a whole. This can be done via std::current_exception, and new methods added to std::exception_ptr.
We introduce the following new catch handler type:
catch* (<exception-type>) {
We call this a "catch-any" handler. It has the following characteristics:
  • It matches every occurrence of a matching exception in an exception-list. This means it can be called repeatedly, multiple times per scope, if there are multiple matches. We cannot do multiple calls to traditional handlers, because traditional handlers are not necessarily multi-exception aware, and do not expect to be called multiple times in a row.
  • All catch-any handlers must appear before any traditional catch handlers in same scope. This is because the catch-any handlers filter the list of exceptions, and can be executed multiple times and in any order, whereas the traditional catch handler will be the ultimate handler if it matches. Also, the traditional handler will std::terminate if it encounters an exception-list with more than one exception remaining.
  • If there are multiple catch-any handlers in the same scope, they will be called potentially repeatedly, and in an order that depends on the order of exceptions in the exception-list.
  • If a catch-any handler throws or re-throws, the new exception is placed back into the list of exceptions currently being processed, at the same position as the exception that triggered the handler. If there remain exceptions in the list, the search of catch-any handlers continues, and the same catch-any handler might again be executed for another exception in the list.
  • If a catch-any handler exits without exception, the exception that matched the handler is removed from exception-list. If this was the last exception, forward progress resumes outside of catch handlers. If more exceptions are in list, other catch-any handlers at current scope are tested; then any catch handlers at current scope are tested; and if there's no match, unwinding continues at the next scope.

Exception aggregation with try-aggregate and try-defer

For handling and aggregation of exceptions, we introduce two constructs: try-aggregate and try-defer.
  • Try-aggregate starts a block in which there can be one or more try-defer statements that aggregate exceptions.
  • At the end a try-aggregate block, any accumulated exceptions are thrown as a group.
  • If there are no aggregated exceptions, execution continues.
Example.

The following code is currently unsafe if the A::~A() destructor is declared noexcept(false):
struct D
{
    A *a1, *a2;
    ~D() { dispose(a1); dispose(a2); }
}; 

template <typename T> void dispose(T* ptr) 
{ 
    ptr->~T();
    remove_from_siblings(ptr); 
    Allocator::dealloc(ptr); 
}
Problems with this code are as follows:
  • dispose() does not use SFINAE to require that T is std::is_nothrow_destructible. Therefore, dispose() must take exceptions from T::~T() into account — and it does not.
  • The D::~D() destructor makes two calls to dispose(), which is a function that may throw. If disposal of the first member throws, the second member will not be properly disposed.
To allow this type of code to work, C++11 pushes to make destructors noexcept. But this leaves a hole where a destructor can still be declared noexcept(false), and then the above code will not work.

With exception aggregation, the above situation can be handled using try-aggregate and try-defer. To avoid introducing contextual keywords, I use try* for try-aggregate, and try+ for try-defer:
struct D {
    A *a1, *a2;
    ~D() {
        try* {
            try+ { dispose(a1); }
            try+ { dispose(a2); }
        }
    }
}; 

template <typename T> void dispose(T* ptr) 
{
    try* {
        try+ { ptr->~T(); }
        remove_from_siblings(ptr); 
        Allocator::dealloc(ptr);
    }
}
This performs all aspects of destruction properly, while catching and forwarding any errors in a controlled and orderly manner. The syntax is clear, and easy to use.

Containers

With this support, a container can now handle any number of destructor exceptions gracefully. If a container is destroying 1000 objects, and 10 of them throw, the container can aggregate those exceptions using try* and try+, relaying them seamlessly once the container's task has completed.

Since containers are written with templates, this does not need to impose any cost on users that use noexcept destructors. If the element uses a noexcept destructor, exception aggregation can be omitted. This can be done currently using SFINAE, or in the future with a static_if — assuming one is introduced.

Users who previously stored objects with throwing destructors in containers were doing so unsafely. With aggregated exceptions, and containers that support them, such types of use become safe.

What are the uses?

  • Simple resource-freeing destructors can now throw; as opposed to being coerced, via lack of support, to either abort the program or ignore errors.
  • Destructors are now suitable for complex error mitigation, such as database or installation rollback. Currently, it is unsafe to use a destructor to trigger rollback. It forces you to either ignore rollback errors, or abort if one happens — even if there are actions you would want to take instead of aborting.
  • You can now run any number of parallel tasks, and use exceptions as a mechanism to collect and relay errors from them. Under a single-exception policy, you have to rely on ad-hoc mechanisms to collect and relay such errors.

Limited memory environments

Implementation of an aggregated exception-list will most likely require dynamic memory. This poses the question of what to do if memory runs out. In this case, I support that std::terminate should be called when memory for exception aggregation cannot be secured.

For applications that need to guarantee that exception unwinding will succeed in all circumstances, we can expose a function to pre-reserve a sufficient amount of memory. For example:
bool std::reserve_exception_memory(size_t max_count, size_t max_bytes);
If this is a guarantee that your program must have:
  1. You analyze the program to find the maximum number of exceptions it may need to handle concurrently.
  2. You add a call to the above function to reserve memory at start.
I do not see this as much different than reserving a large enough stack — a similar problem that limited memory applications must already consider.

For applications that cannot make an estimate, or are not in a position to pre-allocate, we also introduce the following:
template <typename T> bool std::can_throw();
With aggregated exceptions, this provides similar functionality that std::uncaught_exception() provides currently. It provides destructors with a way to detect a circumstance where throwing an exception would result in std::terminate(); and in that case, allows the destructor to adapt.

When std::reserve_exception_memory() has been called with parameters appropriate for the program, std::can_throw<T>() would always return true. It would also always return true outside of destructors.

A program that doesn't wish to use any of this could also continue to use existing mechanics with no change in behavior. A program can still use noexcept destructors. If it uses destructors that are noexcept(false), it can still call std::uncaught_exception() and not throw if an exception is in progress. To avoid aggregated exceptions from containers, the program can still avoid using containers to store objects whose destructors are noexcept(false) — which is currently the only safe option.

If the program adheres to all the same limitations that we have in place today, it will experience no shortcomings. However, a function like std::reserve_exception_memory() would make it safe to use aggregated exceptions in limited memory environments.

Examples

Q. If you have some class Derived : Base, and the destructor of Derived throws an exception, what do you do with Base?

This is supported by C++ as-is, and remains unchanged in this proposal. If Derived throws an exception, the Base destructor is still called. If this is a heap-allocated object, being destroyed via delete, then operator delete is still called.


Q. Every destructor call is going to have to check for these deferred exceptions. Aren't you adding a bunch of conditional branch instructions to a lot of code?

When a destructor is called, this conditional branching is already there. Currently, it calls std::terminate. With multi-exceptions, it would call something like std::aggregate_exceptions.


Q. Suppose I have struct A, whose destructor always throws. Then I have struct B { A a1, a2 }. What happens when B is destroyed?
  1. a2.~A() is called, and throws. If B is being destroyed due to an exception in progress, the exception from ~A() is added to the existing exception-list. If there is no exception in progress, a new exception-list is created, and holds this first exception.
  2. a1.~A() is called. This throws, and its exception is appended to the existing exception-list.

Q. Suppose I have struct A, whose destructor always throws "whee!". Then I call a function void F() { A a; throw 42; }. What happens?
  1. throw 42 is called, creating an exception-list with a single exception, int = 42.
  2. a.~A() is called, which throws, and appends its exception to the existing exception-list. The exception-list now has two exceptions: (1) int = 42; and (2) char const[] = "whee!".


What's wrong with noexcept?

Forcing destructors to be noexcept is a kludge. It is an architectural misunderstanding — a patch to cover up a defect.

There is no reason the language can't handle multiple exceptions in stack unwinding. Just add them to a list, and require catch-any handlers to process them all. If any single exception remains, it can be handed to a traditional catch handler. All code can now safely throw. Containers can aggregate exceptions.

This is a focused change that fixes the problem at a fundamental level, emancipates destructors, and allows handling of parallel exceptions. Any number of errors can be handled seamlessly, from the same thread or multiple, and there's no longer code that can't throw.

Instead of fixing a hole in the road, forcing destructors to be noexcept is a sign that says: "Avoid this hole!" Instead of fixing the road, so it can be traveled on, noexcept creates a bottleneck in traffic, and blocks an entire lane from use.

Comments

Ludger Sprenker said…
I think, your proposal is a huge step into the right direction!

It is often reasonable to think of the first exception as the main problem and to treat all other exceptions as a resultant problem. In a transactional mind your operation succeeds or fails, you can't fail harder with n+1 exceptions ...
I think there should be an std::exception_list class which wraps the compiler internal exception list (like std::initializer_list wraps a language initializer list). This list type could be handled in a traditional/well known way:


void logCollateralDamage(const std::exception_list& el)
{
for(auto itr = ++el.begin(); itr != el.end(); ++itr)
{
try{
std::rethrow_exception(*itr);
}catch(std::exception& e){
log(e.what());
}catch(...){
logOtherException();
}
}
}

void test()
{
try{
:::
} catch(std::exception_list& el) {
// exception_list has no default constructor -> never empty guaranty
assert(std::begin(el) != std::end(el));

logCollateralDamage(el);

std::rethrow_exception(std::begin(el));
}
}

Popular posts from this blog

"Unreachable" beauty standards

When monospace fonts aren't: The Unicode character width nightmare

Is the internet ready for DMARC with p=reject?