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 defaultnoexcept
.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.
- schedule code for execution;
- determine the order of execution; but
- not dictate the exact trigger for execution to take place.
- 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?
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.
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:- 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.
std::exception_ptr
now points to the beginning of the exception-list, rather than a single exception. Methods are added tostd::exception_ptr
allowing a catch handler, or a container in the process of aggregating exceptions, to walk and manage the exception-list.- 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 viastd::current_exception
, and new methods added tostd::exception_ptr
.
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.
The following code is currently unsafe if the
A::~A()
destructor is declared noexcept(false)
:struct DProblems with this code are as follows:
{
A *a1, *a2;
~D() { dispose(a1); dispose(a2); }
};
template <typename T> void dispose(T* ptr)
{
ptr->~T();
remove_from_siblings(ptr);
Allocator::dealloc(ptr);
}
dispose()
does not use SFINAE to require thatT
isstd::is_nothrow_destructible
. Therefore,dispose()
must take exceptions fromT::~T()
into account and it does not.- The
D::~D()
destructor makes two calls todispose()
, which is a function that may throw. If disposal of the first member throws, the second member will not be properly disposed.
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 {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.
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);
}
}
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 usingtry*
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 thatstd::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:
- You analyze the program to find the maximum number of exceptions it may need to handle concurrently.
- You add a call to the above function to reserve memory at start.
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?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.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?throw 42
is called, creating an exception-list with a single exception,int = 42
.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.
Showing 1 out of 1 comments, oldest first:
Comment on Jul 26, 2015 at 20:32 by Unknown
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));
}
}