P3166R0: Static Exception Specifications
Table of Contents
- 1. Abstract
- 2. Synopsis
- 3. Overview
- 4. Motivation
- 4.1. Alternative error-signalling mechansisms have problems too
- 4.2. Making exceptions fast by-design
- 4.3. Supporting exceptions in freestanding and real-time environments
- 4.4. Reducing bugs caused by failure to handle error-conditions
- 4.5. Describing the contract in code instead of in documentation
- 4.6. Reducing boiler-plate when writing functions that are transparent to exceptions
- 5. Proposal
- 5.1. Overview
- 5.2. (Re)Adding throw specifiers
- 5.3. Static and dynamic exception specifications
- 5.3.1. Types in a throw-specification form an unordered set
- 5.3.2. Handling of
std::any_exception
in the throw-specifier - 5.3.3. The types in the throw specification describe all concrete types that may be thrown
- 5.3.4. Exception types may not be references, cv-qualified, or void
- 5.3.5. Static exception specifications are part of the function type
- 5.3.6. Deducing the throw-specifier from a function signature
- 5.3.7.
throw(auto)
- Deducing exception-specifications from the body of a function - 5.3.8. Forward declarations of
throw(auto)
functions - 5.3.9. Deduced exception-specifications and recursive functions
- 5.3.10. Delayed computation of deduced throw specifications
- 5.3.11. Do we also need
noexcept(auto)
?
- 5.4. Querying the throw-specification
- 5.4.1.
declthrow
of a call to athrow(...)
function - 5.4.2. Mixed dynamic and static exception specifications
- 5.4.3. Order of the exception types
- 5.4.4. Exception specifications of defaulted special member functions
- 5.4.5. Introducing a pack outside of a template
- 5.4.6. Packs of
declthrow
packs - 5.4.7. Availability of the
declthrow
keyword - 5.4.8. Alternative Syntaxes Considered
- 5.4.9. Filtering the set of exceptions
- 5.4.1.
- 5.5. Checking the throw-specification of a function
- 5.6. Computing the set of potentially-thrown exception types
- 5.7. Template handlers
- 5.8. Virtual Functions
- 5.9. Concepts
- 5.10. Coroutines
- 5.11. Type traits
- 5.12. Freestanding
- 6. Prior Work
- 7. Design Discussion
- 7.1. Code Evolution
- 7.2. A new calling convention / function-type
- 7.3. Changing the implementation changes the calling convention
- 7.4. How accurate does the potentially-thrown exception type calculation need to be?
- 7.5. Permission to throw types derived from
std::bad_alloc
/std::bad_cast
- 7.6. Avoiding dependencies on thread-locals
- 7.7. Calling C functions
- 7.8. Interaction with Contracts
- 7.9. Standard Library Impacts
- 7.9.1. Whether implementations add
noexcept
to "Throws: Nothing" functions can now affect well-formedness of a program - 7.9.2. Should implementations be allowed to strengthen "Throws: something" exception specification to a static exception specification?
- 7.9.3. Usage of the standard library in functions with static throw-specifications will be painful without more noexcept
- 7.9.4. Function-wrapper types
- 7.9.5. Algorithms
- 7.9.1. Whether implementations add
- 8. Implementation Strategies
- 8.1. Multiple return-paths/return-addresses
- 8.2. Multiple return-value-slots
- 8.3. Passing an exception-value-slot parameter
- 8.4. Walking stack to find storage
- 8.5. Unwinding through scopes with destructors
- 8.6. Interop between static-exception functions and dynamic-exception functions
- 8.7. Virtual function calls
- 8.8. Interaction with
std::uncaught_exceptions()
- 8.9. Static exceptions and the Itanium C++ ABI
- 9. Acknowledgements
- 10. References
Document | P3166R0 |
Date | 2024-03-16 |
Reply To | Lewis Baker <lewissbaker@gmail.com> |
Audience | EWGI, LEWGI |
1. Abstract
Exceptions are the C++ language's primary tool for communicating errors to callers. However, the dynamic exceptions we have today come with runtime overheads that cause many people to avoid using, or even completely disable exceptions in domains that are performance-sensitive.
This paper proposes adding new language features for annotating functions with additional type information, in the form of static exception specifications, that should allow exception objects to be returned on the stack with performance similar to that of normal return-paths.
These features include:
- Adding the ability to add
throw
-specifiers to function declarations as a way of declaring the complete set of exception types that the function may exit with. - Allow declaring a function as
throw(auto)
to deduce the set of exception types that may be thrown based on the body of the function. - Add a new
declthrow()
syntax for querying the set of exceptions that an expression may exit with. - Add a
template catch
syntax to allow writing exception handlers that are templates which are instantiated based on the set of exception types that may be thrown from the associated try-block. - Enable static checking of exception specifications for function definitions with
a static
throw
-specifier to ensure that they can not exit with exceptions not listed in their throw specifier. Functions definitions with athrow(auto)
,throw(...)
ornoexcept
specifier are not checked, even if they call functions with static exception specifications.
This paper has the following main goals:
- Eliminate the performance-related reasons for not using exceptions in embedded and other performance critical code.
- Enable the use of exception-based error handling in freestanding environments by enabling exceptions to be thrown/caught without a need for heavy-weight runtime components or dynamic allocation.
- Eliminate classes of bugs relating to unhandled exceptions and error code-paths by making it possible to identify at compile-time code that is failing to handle some exception-types that might be thrown and make it ill-formed.
This proposal is still in the early stages and is being published to solicit feedback
on direction and also on potential interaction with other in-flight papers, such
as contracts and discussions around noexcept
policy in the standard library.
2. Synopsis
Example 1: Explicit throw specifications on declarations
void throws_nothing() throw(); // empty static exception specification - equivalent to noexcept(true) void throws_a_b() throw(A, B); // static exception specification void throws_any() throw(...); // dynamic exception specification - equivalent to noexcept(false) void throws_any2() throw(std::any_exception); // same as throw(...)
Example 2: Deduced throw specifications
template<std::ranges::range R, typename F> requires std::invocable<F&, std::ranges::range_reference_t<R>> void for_each(R rng, F func) throw(auto) { for (auto&& x : rng) { func(static_cast<decltype(x)>(x)); } }
Example 3: Querying the potentially-thrown exception types + use of template handlers
std::variant<std::monostate, declthrow(throws_a_b())...> err; // variant<monostate, A, B> try { throws_a_b(); } template catch (auto& e) { // instantiated for each static exception type in set { A , B } err.template emplace<decltype(auto(e))>(std::move(e)); }
Example 4: Checked exception specifications - Calling throw(A,B)
function from throw()
function
void nothrow_caller() throw() { throws_a_b(); // Ill-formed: A and B not handled locally and not listed in throw specification try { throws_a_b(); // Ill-formed: A not handled locally and not listed in throw specification } catch (B) {} try { throws_a_b(); // OK: all potentially-thrown exceptions are handled locally } catch (A) {} catch (B) {} }
Example 5: Checked exception specifications - Calling throw(...)
function from throw()
function
void nothrow_caller() throw() { throws_any(); // Ill-formed: dynamic exception not handled locally try { throws_any(); // OK } catch(...) {} }
Example 6: Checked exception specifications - Calling throw(A,B)
function from throw(A)
function
void caller() throw(A) { throws_a_b(); // Ill-formed: B not handled locally and not listed in throw specification try { throws_a_b(); /* OK */ } catch (B) {} try { throws_a_b(); /* OK */ } catch (A a) { if (!can_handle(a)) throw; // OK: rethrows A, which is allowed } catch (B) {} }
Example 7: Checked exception specifications - Calling throw(...)
function from throw(A)
function
void caller() throw(A) { throws_any(); // Ill-formed: dynamic exception not handled locally try { throws_any(); /* OK - all exceptions handled */ } catch (const A&) { throw; // Ill-formed: Might rethrow type derived from A (unless A is final) } catch(...) {} try { throws_any(); /* OK - exceptions handled */ } catch(const A& a) { throw a; // OK - only throws A - slices types derived from A } catch(...) {} }
Example 8: Simpler alternative to std::expected
/ std::variant
code
An API that can fail but avoids using exceptions might look like:
std::expected<X, E1> get_x() noexcept; std::expected<Y, E2> get_y() noexcept; std::expected<Z, std::variant<E1, E2>> make_z() noexcept { auto x = get_x(); if (!x) return {std::unexpect, std::move(x).error()}; auto y = get_y(); if (!x) return {std::unexpect, std::move(y).error()}; return {std::in_place, std::move(x).value(), std::move(y).value()}; } std::expected<Z, std::variant<E1, E2>> make_z_p2561() { return {std::in_place, get_x()??, get_y()??}; // ?? error-propagation operator from P2561 } void consumer() noexcept { auto result = make_z(); if (!result) { std::visit(overload( [&](const E1& e) noexcept { /* handle E1 */ }, [&](const E2& e) noexcept { /* handle E2 */ }), result.error()); return; } Z& z = result.value(); // use z }
which can instead be written using static exceptions, with equivalent (or better) performance:
X get_x() throw(E1); Y get_y() throw(E2); Z make_z() throw(E1, E2) { return Z{get_x(), get_y()}; // allows aggregate initialization } void consumer() throw() { try { Z z = make_z(); // use z } catch (const E1& e) { /* handle E1 */ } catch (const E2& e) { /* handle E2 */ } }
Example 9: Guaranteed deterministic local throw/catch, even in throw(...)
functions
void example(std::vector<std::vector<std::string>> vec) { struct InvalidString {}; try { for (auto& strings : vec) { for (auto& string : strings) { if (!is_valid(string)) throw InvalidString{}; // cost equivalent to local goto // process string... } } } catch (InvalidString) { // handle invalid input } }
3. Overview
This paper proposes introducing static exception specifications, repurposing the throw-specification syntax which was removed in C++17, to provide additional type-information about the closed-set of possible exception types that might be thrown from a function. The proposed design tries to avoid the shortcomings of the previous design of throw-specifications by requiring that checks are performed at compile-time instead of at runtime.
Function definitions with a throw specification are checked by the compiler at compile-time to ensure that there are no code-paths that might allow an exception to exit the function that would violate the declared throw-specification, rather than dynamically check this at runtime and terminate. Functions that fail this static exception specification check are ill-formed.
While we are yet to gain implementation experience for this proposal, the design for static exception specifications should permit implementation strategies that can achieve efficiency of throwing exceptions close to that of normal return values, with exception objects able to be returned on the stack or in registers using similar conventions to normal return-values. Some potential implementation strategies for this are described in the Implementation Strategies section.
The proposed design is also similar in many ways to the design proposed in P0709R4 - Zero-overhead deterministic exceptions: Throwing values. However, it differs in a number of key areas:
- It allows users to specify a list of multiple exception types they might throw
in the throw specifier rather than limiting them to throwing only
std::error
or, in the extension proposed at the end of P0709, a single error type. - It does not require the introduction of a special
std::error
type that must be used to wrap all exceptions-as-values. Such a type could potentially be used in conjunction with this proposal viathrow(std::error)
, but would require explicit conversion between exception types at function boundaries. - It does not require falling back to dynamic-allocation when propagating
exception types that do not fit in the small-object optimisation built into P0709's proposed
std::error
type. All static exception objects are passed in automatic storage duration storage so can be as large or as small as you like. Throwing an empty exception object does not need to consume a register, throwing a large object does not require dynamic allocation. - It provides the
template catch
facility to allow generically handling multiple static exception types - something P0709 does not provide as it expects you to just be propagating a single static exception type,std::error
.
This design does not change the semantics of existing C++23 code. It can be
incrementally adopted throughout a code-base by annotating functions with
static exception specifications using the throw(/type-list/)
syntax where appropriate
in much the same way that code-bases could incrementally adopt noexcept
specifiers in their code-base when they were introduced. Changing
a function from noexcept(false)
to throw(A, B, C)
will likely be an ABI
break, however, and so will require recompilation of any calling code.
This proposal aims to enable use of static exception specifications in freestanding
environments which were traditionally unable to use exceptions. Static exception-specifications
provide enough type-information to the compiler to allow exceptions to be thrown, propagated,
caught and rethrown with minimal runtime machinery - without need for dynamic-allocation,
runtime type information, or dynamic_cast
.
If a program can avoid using facilities that require dynamic exceptions, such as
std::current_exception()
and throw;
expressions that appear outside of the lexical
scope of handlers, then the only dependency on thread-local storage is std::uncaught_exceptions()
,
which is not required in a lot of projects and could potentially be omitted from freestanding environments.
See the section on Avoiding dependencies on thread-locals for more details.
The ability to opt-in to compile-time checking that all exceptions are handled helps to ensure that all error-conditions have been handled, potentially helping to improve the correctness, reliability and safety of code. Callers of a function cannot forget to handle errors like they can with other mechanisms for communicating errors.
It also allows a program to ensure that there are no hidden calls to std::terminate()
inserted by the compiler due to unhandled exceptions, which can potentially remove some
barriers to use of C++ exceptions in environments where termination should only be performed
in the presence of unrecoverable errors.
Authors of functions that have checked exception specifications can still explicitly catch
exceptions that represent fatal error conditions and insert an explicit call to std::terminate()
to get the same behaviour as the dynamically-checked noexcept
behaviour - only now these calls
to std::terminate()
appear in source code and so can be more easily audited than the implicit
ones the compiler was inserting.
Adopting this paper would have some implications for the P2900 contracts proposal and general noexcept
policy in the standard library. It forces us to confront the question of whether a function with
a deduced exception specification that evaluates a contract_assert
should be considered
potentially-throwing.
This paper discusses these question briefly and poses some potential directions, but does not
seek to answer them at this stage.
This paper is structured into the following sections:
- Motivation - motivation for addition of this feature
- Proposal - describes the design of the features this paper is proposing
- Prior Work - a comparison of this work to prior-art in this area. e.g. to Java, Midori, C++98
- Design Discussion - further discussion of important design points, alternatives, future work, etc.
- Implementation Strategies - discusses potential strategies implementations could use to implement this design efficiently
4. Motivation
The paper [P0709R4] "Deterministic exceptions: throwing values" by Herb Sutter contains detailed motivation for improving exceptions, and covers the background and history of exceptions in C++ better than I could do here.
I agree with much of the philosophy and motivations expressed in P0709 and have used them as inspiration for the design for this paper. However, this paper takes a different approach to solving the issues raised.
The paper [P1947R0] "C++ exceptions and alternatives" by Bjarne Stroustrup argues that adding yet-another-error-handling mechanism is likely to further fracture the community because existing mechanisms and styles won't disappear and efficiency improvements will be patchy and limited.
P1947 advocates instead for improving the existing C++ exceptions that we have and highlights some problems with some of the alternative error-code based handling mechanisms.
Improvements in this space should try to be consistent with, as much as possible, the
existing C++ exception design. For example, by ensuring that a thrown exception object
of type, T
, remains an exception object of type T
as it propagates up the stack,
regardless of whether callers have dynamic or static exception specifications.
4.1. Alternative error-signalling mechansisms have problems too
Code-bases that avoid using exceptions, for whatever reason, need to choose an alternative error-signalling mechanism to signal to the caller that an operation failed.
Popular choices include using std::error_code
(either as a return-value or out-parameter),
boost::outcome
, std::expected
, std::optional
, etc.
Many of these error-handling mechanisms force the user to make other trade-offs in return for avoiding exceptions. If we can reduce the reasons for people to avoid using exceptions, we can potentially reduce the need to make some of these trade-offs.
4.1.1. Composition of facilities that use different error-signaling mechanisms
As pointed out in P1947, these alternative error mechanisms aren't universally usable in all places. For example, calls to constructors are unable to change the return-type to return a value-or-error object, and overload operators are unable to take additional out-parameters that can receive error-codes.
Many of these error-signalling mechanisms don't force users to handle the errors, meaning users can mask problems by accidentally ignoring errors, making their use "error"-prone.
The growing number of error-handling mechanisms in use means it is harder to compose components that use different error-handling techniques - often requiring explicit conversion/adaptation between components that use different error-signalling mechanisms.
Many generic algorithms assume that if an operation doesn't throw an exception then it must have succeeded. Adapting these algorithms to other error-signalling mechanisms often requires reimplementing them.
4.1.2. Runtime overhead due to additional branching, packing/unpacking
The alternative error-handling mechanisms can also incur runtime overhead on the success-path compared to exception-based code.
They often require returning a value that can either represent a success or an error. The calling code then needs to inspect the value to see which kind of result it is and then branch on the answer rather than just continuing on the success path.
Alternatives can inhibit copy-elision of returned values. Results typically need to
be packed into some container, such as std::expected
, and then unpacked by the caller
before being passed by-value to another function, or used to initialize a data-member
with aggregate initialization.
When propagating error values through many layers of a call-stack each caller needs to inspect the result, unpack the error, repack into a new wrapper object of a different return-type.
Both cases require additional calls to move-constructors compared to returning the success value on its own, or compared to throwing an exception.
4.1.3. May require an additional "invalid" state, additional preconditions
Alternative error-handling mechanisms also typically require objects to have an invalid state.
A constructor that can fail but that does not throw an exception will generally require a subsequent query after calling the constructor to check whether construction succeeded before attempting to use that object.
This can potentially require that object to hold additional state to represent that invalid state.
It also requires adding additional pre-conditions on each operation performed on the object that check that the object is valid.
4.2. Making exceptions fast by-design
Exceptions are expensive on many platforms for error-handling that is not rare. In some cases, taking the exceptional path can be 100-1000x slower than the normal return-path.
This can be especially problematic for services that use exceptions for signalling errors which trigger in service-overload situations. If a system is overloaded and starts raising errors but this results in taking code-paths that are significantly more expensive then this can result in a snow-balling effect where a few small errors quickly leads to much larger system failures.
The dynamic nature of exceptions as they are today, which relies on dynamic allocation, run-time type information and dynamic type-matching of handlers during unwind, makes it very difficult to reason about the performance of code in exceptional situations.
Dynamic allocation can potentially make system-calls to acquire memory from the operating system, or can block acquring locks on shared data-structures, leading to unpredictable, non-deterministic execution times.
The cost of dynamic type-matching when searching for a handler can depend on what DLLs happen to be loaded into the process - something that is very difficult to reason about locally.
While there have been efforts to reduce the cost of the dynamic exceptions, such as
[FastCasting] and [Renwick], they do not solve the whole problem. For example,
even with the stack-allocation techniques described in [Renwick], dynamic allocation
is still required to transport exceptions across threads - something that will be
increasingly common with the upcoming std::excecution
facilities proposed in [P2300].
The cost of exceptions in production implementations have remained roughly the same over the past few decades as improvements, to the extent they would be possible, would result in extremely disruptive ABI breaks.
All of these performance issues with exceptions are well-known and are one of the primary reasons why people avoid using exceptions, or restrict their use to genuinely rare error-conditions.
However, exceptions as a model for error handling generally have a lot of benefits and are well integrated into the language.
If we can find a way to make exceptions fast by-design, of the order of normal return-values and as efficient or better than the alternative error-signalling mechanisms, then it would opens up a number of possibilities.
Exceptions then become possible to use in environments that were traditionally unable to use them (or even enable them) for performance reasons. e.g. freestanding/embedded systems, or real-time systems such as games.
It also opens up opportunities for APIs to use exceptions to report errors that are not rare. This can help make the ergonomics of using these APIs nicer by keeping the success-path of using the APIs clean and free-of error-handling logic.
4.3. Supporting exceptions in freestanding and real-time environments
The current lack of support for exceptions in freestanding environments significantly limits the how much of the standard library can be used in those environments, and also forces those environments to use other error-handling techniques which are potentially more error-prone. This is unfortunate as many safety-critical systems target freestanding environments.
To be able to be used in freestanding environments, the code-size and run-time overhead of using exceptions needs to be small enough that it can be used on small micro-controllers that may only have 10's of KB of memory available.
Dependencies on dynamic allocation, run-time type-information and thread-locals can easily eat up a significant portion of memory available for program text.
For real-time environments, such as games, where high-performance and binary size is a key reqirement, the current dynamic nature of exceptions and the common implementations of that design lead to overhead that is undesirable, even if exception paths are not evaluated.
4.4. Reducing bugs caused by failure to handle error-conditions
Use of exceptions in a program can introduce hidden control-flow that makes it hard to know if you have handled all possible error-cases in your function.
The existing proposals for exceptions have not yet sought to address the problem of programs unintentionally failing to handle exceptions as a potential source of bugs in a program. Although it is worth noting that [P0709R4]'s proposed `try` expression syntax at least sought to make the hidden control flow more visible.
If function authors are able to opt-in to having their function's exception specification checked at compile time to make sure they are not violating it by failing to handle some exception types then we can potentially reduce or eliminate two classes of bugs relating to unhandled exceptions.
The first case is where the programmer fails to handle a given exception due to an oversight when initially writing the function.
The second case is where a function was correct when initially written, but as code evolves, a function it calls is later modified to report new error-conditions for which the initial function was not aware it needed to handle.
Currently, the language offers little help to users wanting to catch this class of errors.
4.5. Describing the contract in code instead of in documentation
A function declaration defines the contract between callers of that function and the implementations of that function.
The status quo is that the function declaration currently only describes the types
that a caller must provide as arguments, the type that the function will return on
success, and whether or not the function might indicate failure by throwing an
exception (via the noexcept
specifier).
We have also added attributes such as [[noreturn]]
and [[nodiscard]]
that provide
the user more information about whether the function can return normally, or whether
the return-value is safe to discard.
With the contracts facility proposed in [P2900R6], we would gain the ability to describe runtime properties of the values that must be passed to a function and returned from it.
The set of exception-types that may be thrown forms an important part of a function's contract - it tells the caller "here are the ways in which I might fail", not just that it might fail.
However, currently, the ability to describe a set of exception-types that a function may throw in the function declaration is noticeably absent.
Prior to C++17, there was the ability to declare constraints on the set of exception types a function could throw, by declaring a function with a throw-specifier. The types listed, however, were not necessarily the complete set of types - the function was permitted to throw types derived from the types listed.
While this information was of some help to users who could know what handlers to
write to match all of the exceptions, it was not useful for compilers in determining
the potential size of exception ojbects in order to be able to return them on the stack.
Some form of type-erasure and run-time type-information was still required.
See (Re)Adding throw()
specifiers for more
details.
This facility was deprecated in C++11 with the introduction of noexcept
and removed
in C++17.
Both users and compilers would benefit from having more information about the set of exceptions that can be thrown - allowing local reasoning about the potential exception code-paths without requiring global knowledge of the program.
4.5.1. Uses in generic code
When writing generic code that ends up calling into APIs on types provided by the user,
we don't necessarily know what exception types they may throw. If that generic code
wants to be able to capture an exception, store it and rethrow it later, the best it
can do is store the exception in a std::exception_ptr
- meaning we need type-erasure
and dynamic allocation.
If a program had a way to query the set of concrete exception types that may be thrown
from an expression at compile time then the generic code could find more efficient ways
to store an exception object, such as in a std::variant
.
4.6. Reducing boiler-plate when writing functions that are transparent to exceptions
Authors of generic code often desire to write facilities that are transparent to exceptions. e.g. they call onto methods on types provided by the user and want to throw any exceptions that those methods can throw.
This is often expressed in standardese as "is expression equivalent to", or "Throws: exceptions
thrown by func(x)
".
Currently, if generic functions want their noexcept
specifier to correctly report whether
or not the function might throw an exception, the author must write an expression as a
parameter to the noexcept
specifier that computes this result.
To do this correctly, the author must identify and duplicate every expression in the
function body (including any implicit conversions) and then query whether or not that
expression can potentially throw by passing it as the argument to a noexcept
expression.
This is highly tedious code to write and often difficult to get right, especially in the
presence of constructs like if constexpr
within the function body.
If you get it wrong by being conservative, resulting in a noexcept(false)
when a
noexcept(true)
would have been appropriate then you might end up with slower code-generation,
or dispatching to a less efficient algorithm (e.g. via std::move_if_noexcept
).
However, it is much more likely that an author misses an expression and accidentally
declares a function as noexcept(true)
when it should have been noexcept(false)
.
If this is the case and the function throws then your program ends up terminating.
The compiler is much better placed to be able to reliably deduce whether or not a function can potentially throw an exception from the body than the programmer is.
Futher, the extra syntactic noise in the signature of the function often obscures the more important parts of the function definition, like the parameters, const-qualifiers, etc, reducing readability of the code.
We already have the ability to deduce the return-type of a function from the function body
by declaring a return-type of auto
or decltype(auto)
. It would simplify a lot of
generic code that wants to be transparent to exceptions if we could also let the compiler
deduce the exception specification.
5. Proposal
5.1. Overview
The key components of this proposal are as follows:
It proposes (re)adding throw specifiers which can be used to declare a function
as having either a static exception specification, throw(T1, T2...)
,
or dynamic exception specification, throw(...)
or throw(std::any_exception)
.
It proposes adding the throw(auto)
syntax for deducing the throw-specification of a
function from its definition, which must be visible before use of the function.
It proposes rules for statically checking at compile-time that the bodies of function definitions with throw specifiers do not violate their exception specification. Failure to either handle all such cases or declare that you forward on unhandled exceptions are ill-formed.
It proposes adding the declthrow(expr)
syntax for querying what set of exception
types might be thrown from a particular expression.
It proposes adding the template catch
syntax to allow catching static exceptions
thrown from the associated try-block, allowing a way to use the same handler template
to handle different types, without the need for type-erasing the exception.
5.2. (Re)Adding throw specifiers
The original design of exceptions in C++ included a throw-specification that allowed
the programmer to declare a list of exception types that a function might exit with, by
specifying the throw(E1, E2, E3)
specifier after the function parameter list.
For example:
void Example(const std::string_view& path) throw(std::bad_alloc, std::system_error);
The throw-specification, as originally designed, had a number of issues that limited its usability and utility, and in time most people came to avoid the feature as its pitfalls outweighed the benefits of using it.
The following is a summarized list of the issues:
- The runtime/code-size overhead cost due to need to dynamically-check for unhandled exception types.
- The
std::unexpected()
notification mechanism did not lend itself to recovery from unhandled exceptions. - MSVC (at the time) did not enforce the contract - a function with a
throw()
specification could still throw exceptions of types other than those mentioned in the throw-specification, but the compiler would optimise based assumptions that it did not. This made the feature dangerous to use as it would result in undefined behaviour if the programmer failed to adhere to the throw-specification.
In C++11, we introduced noexcept
, initially as a tool needed to restore the strong
exception-safety guarantee to types like std::vector
after the introduction of
move-constructors.
The original throw-specifications were deprecated along with the introduction of
noexcept
and, in C++17, were removed from the C++ language. This frees up the syntax
for being reused for a similar purpose, albeit with an improved design that tries to
avoid the pitfalls of the original design.
5.3. Static and dynamic exception specifications
A static exception specification is an exception specification that lists a finite set of possible exception types that a function may exit with.
A non-empty static exception specification is a static exception specification that contains one or more exception types listed in the throw specification.
A dynamic exception specification is an exception specification that allows the function to exit with any exception type.
A throw-specifier can be used to declare a function with either a static or dynamic exception specification.
For example:
void f() throw(); // static-exception-specification with empty exception type list // equivalent to noexcept(true) void g() throw(...); // dynamic-exception-specification // equivalent to noexcept(false) void g() throw(std::any_exception); // equivalent to throw(...) // see section on declthrow for rationale void h() throw(E1); // throws only E1 (static-exception-specification) void i() throw(E1, E2); // throws either E1 or E2 void j() throw(Es...); // throws one of the types in pack Es... void k() throw(auto); // set of exceptions it could throw is deduced from body of function, // much like using decltype(auto) to deduce the return-type.
A declaration signature of void foo() throw();
is equivalent to void foo() noexcept;
.
However, a function definition with a throw()
specifier differs from one with noexcept
specifier in that the definition is ill-formed if an exception can possibly escape the function,
whereas void foo() noexcept
detects such a failure to fulfil its contract at runtime and terminates.
i.e. throw-specifications are statically checked/enforced rather than dynamically checked/enforced.
Similarly, void bar() throw(E1, E2)
is ill-formed if any exception types other than E1
or E2
can possibly escape the body of the function. The aim is to avoid hidden calls to std::terminate
in response to unhandled exceptions.
It is permitted to declare a function with the specifier noexcept(true)
and define it with
the specifier throw()
, and vice versa. Doing so allows you to have the compiler statically
check that there are no unhandled exceptions exiting the function body that might implicitly
result in a call to std::terminate
.
Similarly, it is permitted to declare a function with the specifier noexcept(false)
and define
it with the specifier throw(...)
, and vice versa. However, there are no differences in semantics
of the definition between these two syntaxes - they are pure aliases for each other.
A forward declaration of a function with a non-empty static exception specification on its definition must have an equivalent static exception specification on the declaration.
5.3.1. Types in a throw-specification form an unordered set
The order of the types in the throw-specification is not significant. The throw-specification declares an unordered set of types that may be thrown, rather than an ordered list of types.
Two throw-specifications are equivalent if they contain the same set of types, regardless of the order in which those types are listed in the source code.
It is valid to list a type multiple times in a throw-specification. Any duplicates are ignored/eliminated by the compiler.
Eliminating duplicates is helpful when composing lists of exception types
from multiple declthrow
expressions that have overlap in the set of exceptions
they may throw - see the section "Querying the throw-specification".
For example, the following functions all have the same exception specification:
void f() throw(E1, E2); void g() throw(E2, E1); void h() throw(E1, E1, E2);
The rationale for making the set of exceptions an unordered set rather than an ordered list is to reduce the chance of annoying incompatibilities when casting a function to a function-pointer.
For example:
// declared in lib1 void f() throw(E1, E2); // declared in lib2 void g() throw(E2, E1); void (*func)() throw(E1, E2) = &f; if (cond) { func = &g; // It would be annoying if this was ill-formed because the throw-specification had a different order. }
5.3.2. Handling of std::any_exception
in the throw-specifier
The std::any_exception
type is a type that is handled specially by throw specifications.
See the section "declthrow
of a throw(...)
expression" below for a definition of this type.
If the list of types passed as arguments to the throw
specifier contains the type
std::any_exception
then the overall exception-specification is evaluated to be
throw(...)
. i.e. that it can throw any exception type.
For example:
void a() throw(std::any_exception); // -> throw(...) void b() throw(A, B, std::any_exception); // -> throw(...)
The use of a type std::any_exception
allows template metaprogramming libraries to be
able to conditionally compute a throw-specification that can evaluate as either a static exception specification
or a dynamic exception specification.
For example: Computing a throw-specification to either be throw(...)
or
a static exception specification, depending on a template parameter.
template<typename... Ts> using ...pack = Ts; // P1858R2 pack alias syntax // Generic case template<typename T> struct _compute_foo_throw_types { using ...types = pack<std::any_exception>; // P1858R2 pack alias syntax }; // When T satisfies the Foo concept, we know it will only // fail with two possible exceptions. template<typename T> requires Foo<T> struct _compute_foo_throw_types<T> { using ...types = pack<FooError, std::bad_alloc>; }; template<typename T> void foo(const T& x) throw(_compute_foo_throw_types<T>::...types...); // P1858R2 pack expansion syntax
Here, the function foo<T>
has an exception specification that is either throw(FooError, std::bad_alloc)
or throw(...)
,
depending on the type, T
.
5.3.3. The types in the throw specification describe all concrete types that may be thrown
One of the prime motivations behind re-adding throw-specifiers is to provide the compiler with enough static type information for it to be able to allocate storage for exceptions that may be thrown on the stack of the caller, rather than the runtime having to dynamically-allocate storage for them on the heap. It also allows the compiler to statically dispatch to the appropriate handler for each possible exception that might be thrown, without requiring dynamic type-matching or run-time type information.
For this to be possible, the compiler needs to know the size/alignment of all exception types so that it can reserve storage in the stack-frame for any exception-types which cannot be passed back to the caller in registers. Similarly, it needs to know which exception types may be passed back in registers.
This means that we cannot just list an exception base-class in the throw-specifier and then leave the set of possible exception types open to include any type derived from that base-class, as this would not allow callers to reserve space for any such exception on the stack-frame caller.
For example, a declaration with a throw-specifier of throw(std::exception)
does not declare
that the function may throw an exception derived from std::exception
, it instead states that
the function may throw an instance of std::exception
(e.g. as if via throw std::exception{};
)
and does not exit with any other type of exception.
The implication of this restriction, however, is that any changes to the set of exception-types that may be thrown by a function is a potential ABI break for that function, requiring, at a minimum, recompilation of all callers of that function.
This is no different to changing the return-type of a function.
e.g. when adding a new entry to a std::variant
-returning function.
This places some interesting constraints on the evolution of such functions, which are discussed in detail in a later section.
5.3.4. Exception types may not be references, cv-qualified, or void
Types listed in the throw-specifier may not be references, cv-qualified, or void
.
Static-exception types are returned by-value to callers, so it does not make sense to support throw-specifiers that include types that are references or cv-qualified.
5.3.5. Static exception specifications are part of the function type
A static exception specification is part of the function type, much like noexcept
specifier is
part of the function type.
In general, a function-pointer with a non-empty static-exception-specification cannot be cast to a function-pointer type with a different exception-specification. This is because the calling-convention between such functions may be different, as the list of exceptions that may be thrown forms part of the ABI of such a function.
Note that it is possible to cast a function directly to a function-pointer type with a wider exception specification than the function was declared with as the compiler is able to then generate a thunk that can implement the ABI for the wider specification in terms of the function's native ABI.
Once the identity of the function has been erased as a function-pointer, it is no longer possible for the compiler to know how to generate such a thunk.
For example:
void f() throw(); void g() throw(E1); void h() throw(E1, E2); void i() throw(...); void(*pf)() throw() = f; // OK pf = g; // ERROR - can't cast g() to a function-ptr with narrower throw-specification pf = h; // ERROR - can't cast h() to a function-ptr with narrower throw-specification pf = i; // ERROR - can't cast i() to a function-ptr with narrower throw-specification void(*pg)() throw(E1) = g; // OK pg = f; // OK - points either to f or to thunk that calls f pg = h; // ERROR - can't cast h() to a function-ptr with narrower throw-specification pg = i; // ERROR - can't cast i() to a function-ptr with narrower throw-specification void(*ph)() throw(E1, E2) = h; // OK ph = f; // OK - ph points to f or to a thunk that calls f ph = g; // OK - ph points to a thunk that calls g ph = i; // ERROR - can't cast i() to function-ptr with narrower throw-specification void(*pi)() throw(...) = i; // OK pi = f; // OK - ph points to f (same as casting noexcept(true) function-ptr to a noexcept(false) one) pi = g; // OK - ph points to a thunk that calls g and translates static-exceptions into dynamic-exceptions pi = h; // OK - ph points to a thunk that calls g and translates static-exceptions into dynamic-exceptions // The same casts are not all valid when casting function-pointers to other function-pointer // types instead of functions to function-pointer types. pf = pg; // ERROR: Can't cast function-ptr with static throw specification to another function-ptr type pf = ph; // ERROR: (same) pf = pi; // ERROR: Can't cast throw(...) function-ptr to throw() function-ptr pg = pf; // MAYBE?: In some ABIs the calling convention may be compatible. // Do we want to restrict the options here? pg = ph; // ERROR: Can't cast to function-ptr with narrower throw-specification pg = pi; // ERROR: Can't cast to function-ptr with narrower throw-specification ph = pf; // MAYBE?: In some ABIs the calling convention may be compatible. ph = pg; // ERROR: Can't cast function-ptr with static exception specification to function-ptr with a // different exception specification. Compiler is unable to generate the necessary thunk here. ph = pi; // ERROR: Can't cast to function-ptr with narrower throw-specification. pi = pf; // OK: this is same as casting function-ptr with noexcept(true) to function-ptr with noexcept(false) pi = pg; // ERROR: Can't cast function-ptr with static exception specification to function-ptr with // different exception specification. Compiler is unable to generate the necessary thunk here. pi = ph; // ERROR: Can't cast function-ptr with static exception specification to function-ptr with // different exception specification.
The existing type-conversions from pointers to a function with a noexcept(true)
exception specification
to a pointer to a function with a noexcept(false)
exception specification are unchanged.
5.3.6. Deducing the throw-specifier from a function signature
It is permitted to allow template arguments to be deduced from the throw-specification in a function-signature.
For example:
template<typename Ret, typename... Args, typename... Errors> void Call(Ret(*func_ptr)(Args...) throw(Errors...)); void a() throw(); void b() throw(int); void c() throw(std::bad_alloc, std::system_error); void d() throw(...); Call(&a); // deduces Errors to be the empty pack. Call(&b); // deduces Errors to be the pack: int Call(&c); // deduces Errors to be the pack: std::bad_alloc, std::system_error (in some unspecified order) Call(&d); // deduces Errors to be the pack: std::any_exception
This is similar to the ability to deduce whether a function signature is noexcept
or not.
5.3.7. throw(auto)
- Deducing exception-specifications from the body of a function
Often, when writing forwarding functions, or function templates, you just want the function to be transparent to exceptions. i.e. any unhandled exceptions should be propagated to the caller.
In these cases, ideally the function's exception-specification should mirror the set of exceptions that the body of the function may throw.
With the current facilities available with noexcept
, this typically means that you need to
repeat every expression in the body of the function in the noexcept
specifier for that function.
For simple functions this is manageable, although tedious. However, for more complicated function bodies,
or for function-bodies that include conditionally-executed logic guarded by an if constexpr
branch,
the expression needed to compute the noexcept
specifier argument quickly becomes unwieldy.
- Prior work on deducing exception specifications
This usability issue was identified as a problem back when
noexcept
was originally proposed for C++11:- N3227 - Please reconsider
noexcept
(Ottosen, 2010)
There have since been multiple papers exploring the idea of deducing the exception-specification:
- N3202 - To which extent can
noexcept
be deduced? (Stroustrup, 2010) - N3207 -
noexcept(auto)
(Merrill, 2010) - N4473 -
noexcept(auto)
, again (Voutilainen, 2015) - P0133R0 - Putting
noexcept(auto)
on hold, again (Voutilainen, 2015)
It is worth noting that the rationale given in P0133R0 for putting on hold the pursuit of
noexcept(auto)
was mainly because it did not solve the whole problem of having to duplicate the function-body in the declaration - the expressions of the body still needed to be duplicated in the return-type for SFINAE purposes - and therefore it was not good use of committee time to pursue a partial solution.Since this paper was written, we have gained support for concepts in C++20, which goes some way to simplifying the code needed to write function-templates that eliminates overloads with SFINAE. However, this only applies when there are existing concepts defined that can be used to constrain the function. For many cases you still need to duplicate the expressions of the function body in a
requires
clause.Despite this limitation, I feel there is still benefit to enabling deduced exception specifications as there are often case that are either covered by concepts or that do not require SFINAE, but that do need to compute accurate exception specifications.
- N3227 - Please reconsider
throw(auto)
With the (re)introduction of throw-specifiers, the task of computing a correct throw-specification from a set of sub-expressions becomes even more onerous than for
noexcept
, as you need to compute lists of types, not just a boolean expression.This paper therefore proposes the addition of the
throw(auto)
specifier on a function declaration, as a way of declaring that the compiler should compute the set of exception types that may exit the function from the definition of the body of the function and use that as the exception-specification for the function.For example, consider a hypothetical
for_each
function that invokes a function for each element of a range. If we wanted this function to have the same exception-specification as its body, it would need to be written withnoexcept
specifiers, something similar to the following:template< std::ranges::range Range, typename Func> requires std::invocable<Func&, std::ranges::range_reference_t<Range>> void for_each(Range&& range, Func&& func) noexcept(noexcept(std::ranges::begin(range)) && noexcept(std::ranges::end(range)) && noexcept(++std::declval<std::ranges::iterator_t<Range>&>()) && noexcept(std::declval<std::ranges::iterator_t<Range>&>() != std::declval<std::ranges::sentinel_t<Range>&>()) && noexcept(func(*std::declval<std::ranges::iterator_t<Range>&>()))) { auto iterEnd = std::ranges::end(range); auto iter = std::ranges::begin(range); while (iter != iterEnd) { func(*iter); ++iter; } }
And with the
throw()
specifier proposed by this paper, in conjunction with thedeclthrow()
expression (described in detail in the following section), we would need to write:template<std::ranges::range Range, typename Func> requires std::invocable<Func&, std::ranges::range_reference_t<Range>> void for_each(Range&& range, Func&& func) throw(declthrow(std::ranges::begin(range))..., declthrow(std::ranges::end(range))..., declthrow(++std::declval<std::ranges::iterator_t<Range>&>())..., declthrow(std::declval<std::ranges::iterator_t<Range>&>() != std::declval<std::ranges::sentinel_t<Range>&>())... declthrow(func(*std::declval<std::ranges::iterator_t<Range>&>()))...) { auto iterEnd = std::ranges::end(range); auto iter = std::ranges::begin(range); while (iter != iterEnd) { func(*iter); ++iter; } }
Having to repeat the body in a different way in the
noexcept
orthrow
specification like this is tedious and error-prone. It can be easy to miss an expression, or to later modify the body of the function and forget to update the throw-specification.Instead, if we use the proposed
throw(auto)
syntax, then the function definition simply becomes:template<std::ranges::range Range, typename Func> requires std::invocable<Func&, std::ranges::range_reference_t<Range>> void for_each(Range&& range, Func&& func) throw(auto) { auto iterEnd = std::ranges::end(range); auto iter = std::ranges::begin(range); while (iter != iterEnd) { func(*iter); ++iter; } }
This is much more concise, and is now impossible for the throw-specification to be inconsistent with the function body.
This facility will greatly simplify the definition of function-templates, in particular the function-templates that are defined as "expression-equivalent to" some expression or statement sequence.
- Further motivation for
throw(auto)
from P2300std::execution
One place where having accurate exception specifications (whether
noexcept
orthrow()
specifications) is when using thestd::execution
facility proposed in P2300.There are generic async algorithms that can potentially have more efficient implementations if they know that a given operation cannot fail with an error.
For example
when_all()
when passed a collection of senders that cannot complete with an error the implementation can avoid introducing expensive stop-token synchronization required for cancelling other child operations if one of them fails. It can also avoid having to reserve storage for astd::exception_ptr
(or other error type) in the operation-state in order to be able to stash the error while waiting for the other operations to stop.So throughout the design of P2300, the specification tries to ensure that, as much as possible, the noexcept-ness of expressions are passed-through. An unnecessarily conservative
noexcept(false)
can result in additional overhead that the compiler cannot inline away like it can for normal functions.The
noexcept
-ness of operations on arguments passed tostd::execution
algorithms can influence the return-type of functions, whether particular overloads of templateset_error()
functions are instantiated, etc. and so can influence the ABI and whether a program is well-formed.For users using the
std::execution
algorithms, using thethrow(auto)
syntax would be beneficial for cases where they are passing lambdas as parameters to these algorithms and they either:- Don't care whether or not the expressions could throw, but if they can then just do the right thing by having those expressions transparently propagate exceptions, and if they don't then do the fast thing.
- The do care, but they are writing generic code which may or may not be noexcept depending on the types it is instantiated with.
For example:
template<std::execution::sender S> auto sender_example(S source) throw(auto) { return std::move(source) | std::execution::then([](const auto& data) throw(auto) { // do something with data that might throw or might not throw depending on 'data' return some_computed_value; }) | std::execution::let_value([](auto& computed_value) throw(auto) { return std::execution::when_all( sub_operation_1(computed_value), sub_operation_2(computed_value)) | std::execution::then([&](auto op_1_result, auto op_2_result) throw(auto) { // ... combine results return some_expr; }); }); }
If we want this expression to produce a sender that is no-fail when the lambdas within it are guaranteed not to throw exceptions then currently you'd have to duplicate the body of each of the lambdas in the noexcept/throw-specifier. This greatly affects the readability of this sort of code. Most people are probably not going to bother and so the sender algorithm will have to pessimistically choose a less-efficient implementation to handle the possibility that some of those expressions might throw. If the author of the lambdas had access to
throw(auto)
then users would probably annotate their lambdas as a matter of course so that their sender/receiver code runs faster when appropriate.
5.3.8. Forward declarations of throw(auto)
functions
The use of throw(auto)
on a forward-declaration of the function requires that the definition
of the function is visible before the use of the function, in the same way that a function
declared with a deduced-return-type requires that the function definition is available before
it's ODR-used. This is consistent with the behaviour of functions with deduced return-types.
For example:
void example() throw(auto); void caller1() { example(); // ill-formed. cannot be ODR-used before the definition is seen } auto* example_ptr = &example; // ill-formed. Type of example() is not known until definition is seen. void caller2() throw(declthrow(example())...); // ill-formed. Cannot query the exception specification // of example() before it's definition is seen. void caller3() noexcept(noexcept(example())); // ill-formed. For same reason. void example() throw(auto) { if (foo()) { do_thing1(); } else { try { do_thing2(); } catch (Thing2Failure) { do_backup_thing2(); } } } // Now that the definition is visible and the exception-specification // can be deduced, the following usages are well-formed. void caller4() throw(declthrow(example())...) { // OK example(); // OK } auto* example_ptr2 = &example; // OK
The restriction that the function definition with a deduced exception specification needs to be visible before it can be used has implications for recursive functions, however.
5.3.9. Deduced exception-specifications and recursive functions
Supporting deduced exception-specifications for recursive functions is a challenge.
In theory we could define some language rules that would allow some kinds of recursive functions to be able to deduce their exception-specification.
For example:
struct Tree { Tree* left; Tree* right; int value; }; void process_value(int value) throw(InvalidValue); void process_tree(Tree& tree) throw(auto) { if (tree.left != nullptr) process_tree(*tree.left); process_value(tree.value); // recursive-call if (tree.right != nullptr) process_tree(*tree.right); }
In this case, the only call that is made that is not recursive is the call to process_value()
which can throw InvalidValue
. Therefore, we could in theory deduce that the overall throw
specification is throw(InvalidValue)
.
However, it is relatively easy to construct examples where such rules would not work.
Consider:
void contradiction(int arg) throw(auto) { if constexpr (noexcept(contradiction(arg)) { throw X{}; } else { if (arg > 0) return contradiction(arg - 1); } }
If the throw-specification is deduced to be throw()
then it throws an exception,
otherwise if it is potentially throwing, it calls itself but no longer contains
any statements that might throw an exception except the call to itself, leading
to a contradiction.
The key feature of this example that makes it problematic is that it is attempting to query the exception specification before the exception specification has been deduced.
There are also other cases that can directly or indirectly require the exception specification to be known. Including:
- Calling the function within a
try { ... } template catch (auto e) { ... }
block. The template catch block needs to know the types that might be thrown in order to instantiate the catch-block with the correct types. - Passing a pointer to the function to an algorithm. Constructing the function-pointer type to pass requires knowing the exception specification.
- Forming a call to the function as a sub-expression passed to
declthrow()
.
There are also further challenges with defining mutually-recursive functions that both have deduced exception specifications.
While we may be able to eventually define rules that may allow a subset of recursive function use-cases to have deduced exception specifications, this seems like a relatively niche case and so this paper proposes that it be left ill-formed for now.
5.3.10. Delayed computation of deduced throw specifications
The exception specification of a function or function-template with a throw(auto)
specifier
need only be computed when the function is selected by overload resolution, or is otherwise ODR-used.
This allows the compiler to avoid instantiating function-templates that are part of an overload set but that are never selected for overload resolution in order to compute the throw specification.
Taking the address of a function with a deduced throw-specification will also force the compiler to compute the throw-specification so that the function-pointer type is known.
This behaviour is consistent with functions with computed/deduced noexcept
specifiers today.
5.3.11. Do we also need noexcept(auto)
?
We could also consider adding support for the noexcept(auto)
syntax, in addition to throw(auto)
.
The primary semantic difference between these two would be that noexcept(auto)
would only deduce
to either noexcept(true)
or noexcept(false)
, (equivalent to throw()
or throw(...)
, respectively),
whereas throw(auto)
could also deduce to a non-empty static-exception-specification.
While, in most cases, it would be preferable to use throw(auto)
, as that allows the exception-specification
to deduce to the more-efficient static-exception-specification, where possible, there may be some scenarios
where deducing to either noexcept(true)
or noexcept(false)
could be preferable.
The one use-case I can think of is where you want to have the exception-specification deduce to a function
whose signature allows a pointer to that function to be assigned to a function-pointer variable that has a
noexcept(false)
exception-specification.
However, this use-case is somewhat tenuous as it would still be possible to directly cast any function
to a signature-compatible function-pointer with a noexcept(false)
exception-specification, it's just
not possible to cast first to a function-pointer with a non-empty static exception specification and
then cast that function-pointer to a function-pointer with a noexcept(false)
exception-specification.
For example:
void a() throw(A); void b() throw(B); void c() throw(auto) { // deduces to throw(A, B) a(); b(); } void d() noexcept(auto) { // deduces to noexcept(false) a(); b(); } void execute(void(*func)()); void example() { auto* c_ptr = &c; execute(c_ptr); // ill-formed: no conversion from 'void(*)() throw(A,B)' to 'void(*)()' auto* d_ptr = &d; execute(d_ptr); // OK: 'void(*)() noexcept' implicitly convertible to 'void(*)()'. } void workaround() { execute(static_cast<void(*)()>(c)); // OK: explicit cast to noexcept(false) function-pointer from function execute(&d); // OK: Explicit cast not needed }
It is an open question whether adding support for noexcept(auto)
in addition to throw(auto)
is
worth the extra complexity/specification effort.
However, in the author's opinion, it is probably not necessary to add in the initial version. It can be added later if usage experience shows that it would have sufficient value.
5.4. Querying the throw-specification
Once we have the ability to specify static-exception-specifications on functions, there will inevitably be cases where we want to be able to know what that set of exception types is in library code.
This paper proposes adding declthrow(expr)
syntax as a way of querying what the list of exceptions
that expr
may exit with.
As the declthrow(expr)
needs to be able to produce a list of types, it is proposed that this
form names a pack of types, which can be expanded as needed using declthrow(expr)...
.
Note that the pack of types produced by declthrow()
does not contain any duplicate types.
One of the common expected use-cases is in computing a derived throw-specification for a function composing other functions such that if their exception specifications change then so does the exception specification of the function composing them.
For example:
// Header file void PartA() throw(OutOfWidgets); void PartB() throw(ProtocolError, Timeout); void ComposedOperation() throw(declthrow(PartA())..., declthrow(PartB())...); // ... out-of-line definition in .cpp file void ComposedOperation() throw(declthrow(PartA())..., declthrow(PartB())...) { PartA(); PartB(); try { PartC(); } catch (...) { NothrowFallbackPart(); } }
5.4.1. declthrow
of a call to a throw(...)
function
If the expression may exit with a dynamic-exception (i.e. one of the sub-expressions has an
exception specification of noexcept(false)
or throw(...)
)
then the result of this is a compiler-generated type, much like decltype(nullptr)
.
An alias for this type is made available as std::any_exception
in the header <exception>
.
namespace std { // NOTE: using pack indexing syntax proposed in P2662R2 using any_exception = declthrow(static_cast<void(*)()throw(...)>(nullptr)())...[0]; }
The std::any_exception
type is not constructible or usable as a value.
It is only intended for use as a placeholder/marker for throw-specifications to indicate
a dynamic exception specification.
An alternative design worth considering is having the special type that indicates a
dynamic exception specification to instead be the type std::exception_ptr
.
This would be useful in cases where you want to store the exception results in a
std::variant
. However, it would mean that you could not have an exception specification
that allowed throwing a std::exception_ptr
object itself (instead of rethrowing the
exception object contained within the std::exception_ptr
).
This is explored in more detail in the design discussion section.
5.4.2. Mixed dynamic and static exception specifications
When the operand to declthrow()
contains multiple sub-expressions, some of which have
non-empty static exception specifications and some of which have dynamic exception specifications,
there is the question of what the result of the declthrow()
query should be.
For example:
// Given the following struct X; struct Y; int foo() throw(X, Y); void bar(int x) throw(...); // What types are in the following type-list? using types = type_list<declthrow(bar(foo()))...>;
There are two viable options to consider here:
- We say that the overall expression could emit any exception, so the deduced exception
specification of a function containing this expression would be
throw(...)
, and so the resulting type list should contain onlystd::any_exception
; or - We list the union of all of the types listed in static exception specifications and
also list
std::any_exception
in the result.
This paper proposes to have the result include both std::any_exception
and the types
from any static exception specifications, for the following reasons:
- It can be used to determine what types might be used to instantiate a
template catch
block (see section on this below) associated with a try-block that contains this expression. - It is not necessary to reduce the result to
std::any_exception
in thedeclthrow()
expression if it is being used as the argument to athrow
specifier - thethrow
specifier will do the reduction for you. Reducing the result early is just throwing away type information.
For example: With this behaviour we can write the following code
template<typename T, typename... Ts> concept one_of = (std::same_as<T, Ts> || ...); template<typename... Es> using err_variant = std::variant<std::monostate, std::conditional_t<std::same_as<std::any_exception, Es>, std::exception_ptr, Es>...>; err_variant<declthrow(do_foo())...> error; try { do_foo(); } template catch (auto e) { error.emplace<decltype(e)>(std::move(e)); } catch (...) { if constexpr (one_of<std::any_exception, declthrow(do_foo())...>) { error.emplace<std::exception_ptr>(std::current_exception()); } }
5.4.3. Order of the exception types
In the section on throw-specifications above it noted that the order of types listed in the throw specification was not significant, and that the types in the throw-specification formed an unordered set for the purposes of function-type-equivalence.
However, when querying the types in the throw-specification, we need to return the types in some order, and so we need to specify what the constraints of that order are.
At the very least, the order of the types returned needs to be deterministic and consistent across
different queries of the same expression, across all translation-units. This is because code may
compute types that have different layouts or ABIs based on the order of the types produced by the
declthrow
expression, and having the same computation produce the results in different orders
is a sure-fire way to introduce ODR-violations.
Any unique set of exception types queried via a declthrow
expression or by deducing the types
listed in a static exception specification of a function signature needs to consistently yield
a list of types in that set in a canonical order.
There are a few other questions around the ordering of the exception types:
- Should the order be a total ordering of all types?
i.e. if
E1
appears beforeE2
in somedeclthrow()
query, thenE1
appears beforeE2
in alldeclthrow()
queries that return those two types.- This would effectively provide a built-in facility for sorting types in type-lists.
- Note that [P2830R1] "Standardized Type Ordering" is also exploring the design space for sorting of types. Should the order that types are returned in be consistent with the order produced by that facility?
- Should the order be specified by the standard? or should it be unspecified/implementation-defined?
- It might be difficult to specify an ordering of all types in a portable way.
- Doing so may improve portability/compatibility of code across compilers, however.
- Standard library implementations do not necessarily define all types with portable canonical names.
e.g. some implementations place some
std::
library types inside inline ABI-version namespaces, which would give those types different names to the same types defined in other standard library implementations, which would negate some of the portability benefit.
Should the order of the exceptions from a
declthrow()
query be consistent with the order of exception types deduced from the throw-specification of a function type? For example:// Given the following. void foo() throw(A, B); template<typename T> struct throw_specifier; template<typename Ret, typename... Args, typename... Es> struct throw_specifier<Ret(Args...) throw(Es...)> { using ...types = Es; }; template<typename... Ts> struct type_list {}; // Should the following static_assert be guaranteed to hold on all conforming implementations? static_assert(std::same_as<type_list<declthrow(foo())...>, type_list<throw_specifier<decltype(foo)>::types...>>);
- Should the
std::any_exception
type appear in a specific location within the types returned bydeclthrow()
if it is present? e.g. as the first or last type in the pack.This might make it easier/more compile-time efficient to write metafunctions that want to detect whether there is a dynamic exception that may be thrown. e.g.
// If std::any_exception is always first type template<typename... Es> concept DynamicException = sizeof...(Es) > 0 && std::same_as<std::any_exception, Es...[0]>; // P2662R2 pack indexing // vs // If std::any_exception could appear anywhere template<typename... Es> concept DynamicException = (std::same_as<std::any_exception, Es> || ...);
- Doing so might be inconsistent with rules for sorting types, however, if we decide that
the type list produced by a
declthrow()
query must produce types in a sorted order consistent with the sorting order described in P2830.
- Do exception types need to be complete when used in throw specifications and
subsequently queried via
declthrow()
?- This may be somewhat limiting - preventing use of
- It would open the possibility of sorting types based on their ABI properties like size/trivial-copyability, etc. e.g. so that all error-types that might be returned by register appear earlier in the list
- The exception types need to be complete anyway when a function that might throw them is invoked, just like the return-type needs to be complete.
- The syntax proposed below for filtering exception types would need the exception types
to be complete so that it can determine whether they would match a given
catch
handler.
5.4.4. Exception specifications of defaulted special member functions
See [dcl.fct.def.default].
The following functions may have defaulted definitions
- special member functions
- default ctor
- move ctor
- copy ctor
- move assignment
- copy assignment
- destructor
- comparison operators
- equality
- three-way-comparison
For defaulted functions:
- implicitly defaulted functions have an implicit exception specification
- explicitly defaulted functions which are defaulted on first declaration have an implicit exception specification if they don't explicitly specify an exception specification.
- explicitly defaulted functions which are defaulted on first declaration that have an explicit exception specification use that explicit exception specification.
This paper proposes changing the implicit exception specifications of defaulted functions
to be equivalent to a throw-specification of throw(auto)
.
This should have no semantic effect on existing types / existing programs as
all existing types will have either a noexcept(true)
or noexcept(false)
member function and thus the deduced exception specification will either
deduce to noexcept(false)
or noexcept(true)
. The rules for deduction of
the exception specification via throw(auto)
are consistent with the pre-existing
rules of deduction for defaulted member functions.
However, it would ideally have an effect on types that compose new types that are defined with static exception specifications for these special member functions.
For example: Defining a struct that composes two types with static exception specifications on their special member functions.
struct A { A() throw(std::bad_alloc); A(const A&) throw(std::bad_alloc); A(A&&) throw(); ~A(); }; struct B { B() throw(std::system_error); B(const B&) throw(std::system_error); B(B&&) throw(); ~B(); }; struct C { A a; B b; // C has implicitly defaulted special member functions. }; template<typename... Ts> struct type_list; template<typename Func> struct throw_specification; template<typename Ret, typename... Args, typename... Es> struct throw_specification<Ret(Args...) throw(Es...)> { using types = type_list<Es...>; }; // Sorts the list of types in the canonical order for a throw-specification template<typename... Ts> using throw_specification_t = typename throw_specification<void() throw(Ts...)>::types; // The following static_asserts will always pass for conforming implementations. static_assert(std::same_as<throw_specification_t<declthrow(C{})...>, throw_specification_t<std::bad_alloc, std::system_error>>); static_assert(std::same_as<throw_specification_t<declthrow(C{std::declval<const C&>()})...>, throw_specification_t<std::bad_alloc, std::system_error>>); static_assert(std::is_nothrow_move_constructible_v<C>);
It would also be ideal if the same approach could be applied to special member functions of certain standard library types.
For example: Constructing a std::tuple
of types with default-constructors with
static exception specifications would ideally result in the std::tuple
type
having a static exception specification.
// Ideally the following would hold true for all implementations. // i.e. the throw-specification of the default constructor of std::tuple is the union // of the throw-specifications for all of the tuple member default constructors. static_assert(std::same_as<throw_specification_t<declthrow(std::tuple<A, B>{})...>, throw_specification_t<std::bad_alloc, std::system_error>>);
It's worth noting that, as currently specified, the default constructor of std::pair
or
std::tuple
is not required to be declared noexcept
if all of its member default constructors are
declared noexcept
, so making this work would require a change to the exception-specification of
the default constructors.
The copy/move constructors are, however, declared as either implicitly or explicitly defaulted, which therefore implies that the exception specification for these functions is deduced from the exception specifications of the members.
A more in-depth analysis of standard library types is required to determine where this kind of defaulted exception specifications can be applied.
5.4.5. Introducing a pack outside of a template
The introduction of a declthrow(expr)
syntax that can introduce a pack of types at an arbitrary
point within the program.
It may be problematic for some compilers to support arbitrary use of anonymous packs outside of templates.
If this is a restriction we want to maintain in the language, then it's possible we can
restrict, for now, the declthrow(expr)
syntax to having to be immediately expanded in-place
to the list of types. i.e. declthrow(expr)
must be immediately followed by a ...
to
expand the pack.
While this would be somewhat restrictive, it would still allow some basic common usage
within throw()
specifiers, and can be used to expand into the template arguments of
variadic class templates, or concepts.
For example:
template<typename... Ts> class type_list {}; // Can pass the result as template arguments to a class-template. using error_types = type_list<declthrow(foo(a,b,c))...>; template<typename T, typename... Ts> concept one_of = (std::same_as<T, Ts> || ...); // Can pass the result as template-arguments to a concept. constexpr bool throws_bad_alloc = one_of<std::bad_alloc, declthrow(foo(a,b,c))...>; // Can use it to compute the type of a variant that can hold all // possible exception types that might be thrown. std::variant<std::monostate, declthrow(foo(a,b,c))...> error; try { foo(a,b,c); } template catch (auto e) { error.template emplace<decltype(e)>(std::move(e)); } // Can use it in the throw-specification of a function that wants to transparently // throw whatever exceptions foo() throws, plus errors that it throws itself. void example(int a, int b, int c) throw(std::system_error, declthrow(foo(a,b,c))...);
However, it wouldn't be able to support things like the following:
void foo() throw(A, B); template<typename Nested> struct BarError { Nested nested; }; void bar(int count) throw(BarError<declthrow(foo())>...) { try { for (int i = 0; i < count; ++i) { foo(); } } template catch(auto e) { throw BarError<decltype(e)>{std::move(e)}; } }
As that requires using the pack in way that is not immediately expanding the pack.
Further, if we do not have the ability to generate a pack in a non-template then we
will not be able to take a type-list computed by some meta-programming and then expand
that type-list into elements of the throw()
specification.
template<typename... Ts> struct compute_new_exception_types { using type = type_list< /* template magic goes here */>; }; template<typename T> void algorithm(const T& obj) throw(typename compute_new_exception_types< declthrow((obj.foo(), obj.bar()))...>::type /* how to expand this to a pack here? */);
While additional workarounds could be added to the throw()
specification to make this
work, I think doing this would needlessly complicate the design. I am hopeful that we
can instead make progress on improving general pack-manipulation facilites to make
some of these cases possible. See P1858R2, P2632R0.
5.4.6. Packs of declthrow
packs
One common use-case of declthrow
is to compute throw-specifications for other functions.
For example, say we have a user pass an invocable that we will call with elements of a span,
the throw()
specification might be defined as follows:
template<typename T, typename Func> requires std::invocable<Func&, T&> void for_each(std::span<T> values, Func&& func) throw(declthrow(func(std::declval<T&>()))...);
However, if we were to, say, try to do something similar with a std::tuple
, where the function
may be evaluated with multiple different argument types, each argument type represented by a
pack element, then the throw-specification effectively needs to become a concatenation of the
declthrow
packs, one pack for each element of the tuple.
Ideally we'd be able to write something like the following:
template<typename... Ts, typename Func> requires (std::invocable<Func&, Ts&> && ...) void for_each(std::tuple<Ts...>& values, Func&& func) throw(declthrow(func(std::declval<Ts&>()))... ...);
However, there are known issues with expanding a pack of packs (see P2632R0 - section "Single level of packness").
As a workaround, we could instead write this with a single declthrow
expression that
contains a compound expression using operator,
.
For example:
template<typename... Ts, typename Func> requires (std::invocable<Func&, Ts&> && ...) void for_each(std::tuple<Ts...>& values, Func&& func) throw(declthrow((func(std::declval<Ts&>()), ...))...);
This way the Ts
pack is expanded inside the argument to declthrow
and it is no longer problematic
expanding the declthrow
expression.
The other alternative for function templates / inline functions that wish to be transparent in the set
of exceptions they may throw is to just use throw(auto)
to deduce the throw-specification
from the body, rather than having to duplicate the relevant parts of the body in the throw()
-specification.
5.4.7. Availability of the declthrow
keyword
A search of GitHub public repositories yielded no direct matches for the identifier declthrow
,
although it is worth noting that it did yield instances of a macro named DECLTHROW(X)
which
was used to conditionally define throw-specifications if available in the target C++ language/compiler.
A search of https://codesearch.isocpp.org/ yielded no matches for declthrow
.
5.4.8. Alternative Syntaxes Considered
Another alternative syntax considered was the reuse of the throw
keyword in a
throw...(expr)
that would expand to the pack of types that could potentially be
thrown by that expression.
However, this syntax would have a potential inconsistency with sizeof...(pack)
which takes an unexpanded pack and returns a single value. Whereas throw...(expr)
needs to take a single expression and produce a pack.
The throw...(expr)
syntax may also be more easily confused with throw (expr)
which
throws an exception instead of querying what exception types it might throw.
The declthrow
keyword also has the benefit of association/similarity with decltype
which is
used to query the value-type of an expression.
5.4.9. Filtering the set of exceptions
Sometimes we want to build a throw-specification that indicates that we throw any exception that some other expression throws, but that we handle some number of errors within the function and so we want to exclude those from the list. This way if the exception-specification of the other expression changes, then the expression-specification of our function changes to include the new set of exceptions.
While this could, in theory, be done with some template metaprogramming on packs, which would become possible with the introduction of more pack-manipulation facilites described in P2632R0, the resulting code is still onerous, and compile-time expensive compared to not filtering the exceptions.
For example: Using throw(auto)
and P3115R0 generalized pack facilities, we can define a helper filter_exceptions
template<typename ErrorType> [[noreturn]] _throws() throw(ErrorType); template<typename HandledType, typename ErrorType> void _handle() throw(auto) { if constexpr (not std::same_as<HandledType, std::any_exception>) { try { _throws<ErrorType>(); } catch(HandledType) {} } } // P3115R0 pack alias syntax template<typename HandledType, typename... Errors> using ...filter_exceptions = declthrow((_handle<HandledType, Errors>(), ...));
Which could then be used as follows:
void example() throw(filter_exceptions<CaughtException, declthrow(some_expression)...>...);
One alternative would be to add a syntax that allowed the programmer to describe the intent to filter the exception list directly in the language.
A strawman syntax for this could be to allow additional arguments to declthrow()
to list types
to exclude from the list of types. i.e. declthrow(expr, filter-clauses...)
For example: We could add additional catch(type)
arguments after the first argument to declthrow()
to list exception types from the expression that are caught and thus should be removed from the list.
// Given. struct A : std::exception {}; struct FooError : std::exception {}; struct B : FooError {}; struct C : FooError {}; void foo() throw(A, B, C); void example1() throw(declthrow(foo())...); // -> throw(A, B, C) void example2() throw(declthrow(foo(), catch(A))...); // -> throw(B, C) void example3() throw(declthrow(foo(), catch(A), catch(B))...); // -> throw(C) void example4() throw(declthrow(foo(), catch(FooError))...); // -> throw(A) void example5() throw(declthrow(foo(), catch(std::exception))...); // -> throw()
Note that listing the catch(FooError)
base class removes both derived types from the list.
Despite the potential syntactic and compile-time benefits that might arise from adding such a syntax, it's not clear whether the added complexity is worthwhile at this point. Usage experience is needed to better understand how often such a feature would be needed.
For a lot of these cases, it is expected that the throw(auto)
syntax will serve most of the
needs in this direction, and assuming that more generalised pack facilities become available,
users that really need to do such filtering would still be able to do this in library.
If we can specify the syntax of declthrow
such that it reserves the ability to be extended
in some way such that this capability could be added later, then we can take a wait-and-see
approach.
5.5. Checking the throw-specification of a function
A function declaration that includes a static-exception-specification must have a definition that ensures that only exceptions of those types may exit the function.
To assist with this, the compiler looks at the body of the function to compute the set of potentially-thrown exception types that may exit the body of the function.
If this set of possible exception types is not a subset of the set of exception types listed in the exception-specification then the program is ill-formed.
For example:
int other() throw(A); // OK: set of potentially-thrown exceptions is {A, B}, all of which are // listed in the function's throw-specification. void example1() throw(A, B) { int x = other(); if (x < 0) throw B{}; } // Ill-formed: call to other() can potentially throw exception A // which is not listed in example2()'s throw-specification. void example2() throw(B) { int x = other(); if (x < 0) throw B{}; }
Note that for functions with a throw-specifier of throw(auto)
the check
always passes as the compiler computes the exception-specification to be exactly the
set of potentially-thrown exception types and thus every exception type is, by-definition,
listed in the exception-specification.
For functions with a throw-specifier of throw(...)
or noexcept(false)
, the
function is permitted to throw an exception of any type and so this check is not
required to be performed.
5.6. Computing the set of potentially-thrown exception types
The ability to check the throw-specifier of a function, compute the results of a declthrow
query, instantiate a template-handler with the appropriate types, or deduce the set of exception
types that may be thrown from a function with a throw(auto)
throw-specification all depend on
the ability to compute the set of potentially-thrown exception types for expressions and statements.
When computing the set of exception types that might exit an expression, statement or function, we ideally want a set of rules that can be reliably evaluated in a consistent way across all conforming implementations, and that is not dependent on inlining, or compiler optimisations. This is because the computation can be important for correctness and well-formedness of a program, and can also affect the ABI of functions with deduced throw specifications.
Computing the set of potentially thrown exception types, therefore, needs to be computable locally for each function, from looking only at the function body and the signatures of any functions called from that function, since we cannot assume that the definitions of called functions will be available.
The following sections describe such a set of rules for computing the set of potentially-thrown exception types for each grammar term that may appear within a function-body.
The descriptions here are not as precise as they would need to be for wording, but are hopefully descriptive enough to understand the proposed semantics.
5.6.1. Statement Reachability
When computing the set of exceptions that may the thrown from some constructs, there are cases where we need to determine whether execution can potentially flow off the end of a compound-statement as these can affect the set of exceptions that can potentially be thrown.
For example:
- If execution flows off the end of a coroutine, it implicitly evaluates
co_return;
. This callspromise.return_void()
which may be potentially-throwing. - If execution flows off the end of a handler of a function-try-block for a constructor
or destructor then the exception is implicitly rethrown as if there was a
throw;
statement inserted at the end of the handler's compound-statement.
Therefore, we need to first define some rules around defining the reachability of certain statements. These rules will need to be somewhat conservative as computing an accurate sense of reachability is equivalent to solving the halting problem, and thus intractable.
The rules below use the terminology potentially reachable statement to indicate that the computation is conservative.
A compound-statement evaluates a sequence of statements. There are some statements/expressions for which it is never possible to execute the next statement, however, as they unconditionally divert control-flow elsewhere.
- Interrupted-flow statements
An interrupted-flow statement is a statement for which execution cannot flow to the next statement from this statement.
The following statements are interrupted-flow statements:
- A jump-statement - i.e.
break;
,continue;
,goto;
,return expr-or-braced-init-list[opt];
or coroutine-return-statement. - A compound-statement where execution cannot flow off the end of the block (see below)
- An if or if-else selection-statement where either;
- the init-statement, if any, is an interrupted-flow statement; or
- the condition is an interrupted-flow expression.
- An if-else selection-statement where the first and second sub-statements are both interrupted-flow statements.
Note: this includes
if consteval
selection-statements. - A constexpr if or if-else selection-statement where the condition evaluated to true and the first sub-statement is an interrupted-flow statement.
- A constexpr if-else selection-statement where the condition evaluated to false and the second sub-statement is an interupted-flow statement.
- A try-block where the compound-statement is an interrupted-flow statement and the compound-statement of every reachable handler (see section on try-block below) of the try-block's handler-seq is an interrupted-flow statement.
- A switch selection-statement where either;
- the init-statement, if any, is an interrupted-flow statement; or
- the condition is an interrupted-flow expression; or
- all of the following are true;
- the body statement is an interrupted-flow statement; and
- the body statement has a
default:
label associated with the switch; and - there is no potentially-reachable
break;
statement associated with the switch.
- A do-while iteration-statement where both the following are true;
- the loop body statement does not enclose any potentially-reachable
break;
statements associated with the loop; and - either;
- both of the following are true;
- the loop body statement does not enclose any potentially-reachable
continue;
statements associated with the loop; and - the loop body statement is an interrupted-flow statement; or
- the loop body statement does not enclose any potentially-reachable
- the loop expression is an interrupted-flow expression
- both of the following are true;
- the loop body statement does not enclose any potentially-reachable
- A for or while iteration-statement where either;
- the init-statement, if present, is an interrupted-flow-statement; or
- the condition expression is an interrupted-flow expression;
- A range-based for iteration-statement where either;
- the init-statement is an interrupted-flow statement; or
- the for-range-initializer expression is an interrupted-flow expression; or
- the begin-expr is an interrupted-flow expression; or
- the end-expr is an interrupted-flow expression.
- An expression-statement where the expression is an interrupted-flow expression.
- A declaration-statement that is an object declaration where the initializer expression is an interrupted-flow expression.
- A jump-statement - i.e.
- Interrupted-flow expressions
An interrupted-flow-expression is a potentially evaluated expression that is one of the following:
- A throw-expression
- A postfix-expression that evaluates a call to a function marked
[[noreturn]]
. - A conditional-expression (ternary
?:
operator) where either;- the first sub-expression is an interrupted-flow-expression; or
- the second and third sub-expressions are both interrupted-flow-expressions.
- A built-in logical AND or logical OR expression where the first sub-expression is an interrupted-flow expression.
- A prvalue expression of class type whose destructor is marked
[[noreturn]]
. - Any other compound expression that has an immediate sub-expression that is an interrupted-flow-expression.
- Potentially-reachable statements
A potentially-reachable statement is a statement of a function that the compiler determines can potentially be executed based on a local analysis of the control-flow of the function. It does not consider the values of any expressions which are semantically computed at runtime.
- Reachability of compound-statements
A sub-statement of a compound-statement is a potentially-reachable statement if:
- it is the first sub-statement of the compound-statement and the compound-statement is reachable; or
- the immediately preceding statement is a potentially-reachable statement and was not an interrupted-flow-statement; or
- the statement was immediately preceded by a label
(Note: this does not include the implicit labels mentioned in the definition of a
while
statement)
Otherwise a sub-statement of a compound-statement is considered an unreachable-statement.
A compound-statement that is the top-level compound-statement of a function body or lambda body is a potentially reachable statement.
- Reachability of components of an if-statement
In an if-statement of the form
if ( /condition/ ) /statement/
orif ( /init-statement/ /condition/ ) /statement/
with or without theelse /statement/
then;- The init-statement, if present, is a potentially-reachable statement if the if-statement is a potentially-reachable statement.
- The condition expression is a potentially reachable statement if;
- The if-statement is potentially reachable; and
- The init-statement is either not present, or if present, is not an interrupted-flow statement.
- The first and second (if present) statement is a potentially reachable statement if the condition expression is a potentially-reachable expression and the condition expression is not an interrupted-flow expression.
In a constexpr if statement;
- the first substatement is potentially reachable if and only if the if-statement is potentially reachable and the condition evaluates to
true
; - the second substatement, if present, is potentially reachable if and only if the if-statement is potentially reachable and the condition
evaluates to
false
.
- Reachability of components of a switch statement
In a switch-statement of the form
switch ( /condition/ ) /statement/
:- the condition expression is potentially reachable if the switch-statement is potentially reachable
And, in a switch-statement of the form
switch ( /init-statement/ /condition/ ) /statement/
:- the init-statement is potentially reachable if the switch-statement is potentially reachable
- the condition expression is potentially reachable if the switch-statement is potentially reachable; and the init-statement was not an interrupted-flow statement.
In both cases, the statement is not potentially-reachable. Execution can only enter statement via a jump to a label enclosed by statement.
Any
case
anddefault
labels associated with the switch statement are potentially reachable if and only if the condition expression is potentially reachable and is not an interrupted-flow expression.For example:
void f(int x) { switch (x) { a; // not-reachable case 0: b; // reachable - appears after a label break; c; // not reachable - appears after a jump-statement default: d; // reachable - appears after a label } }
- Reachability of components of an iteration-statement
In an iteration-statement of the form
while ( /condition/ ) /statement/
- The condition is a potentially reachable expression if the while-statement is a potentially reachable statement
- The statement is a potentially reachable statement if the condition expression is potentially reachable and the condition expression is not a flow-interrupted expression.
In an iteration-statement of the form
do /statement/ while ( /expression/ ) ;
- The statement is potentially reachable statement if the do-statement is potentially reachable
- The expression is a potentially reachable expression if do-statement is potentially reachable
and either;
- the statement is not a flow-interrupted statement; or
- the statement encloses a potentially reachable
continue;
statement associated with the do-statement
In an iteration-statement of the form
for ( /init-statement/ /condition/ ; /expression/ ) /statement/
- The init-statement is potentially-reachable if the for-statement is potentially-reachable
- The condition expression (if present) is potentially-reachable if the for-statement is potentially-reachable and the init-statement is not an interrupted-flow statement
- The statement is a potentially-reachable statement if the init-statement is a potentially-reachable statement and is not an interrupted-flow statement and either the condition expression is not present or the condition expression is not an interrupted-flow expression.
- The expression is a potentially-reachable expression if either;
- The statement is a potentially-reachable statement and is not an interrupted-flow statement; or
- There is a potentially-reachable
continue;
statement enclosed by statement that is associated with the for-loop.
- Reachability of identifier labels
These rules treat all identifier labels as potentially-reachable and does not do any analysis to determine whether there is any jump-statement that could potentially jump to that label.
For example, the rules could potentially require looking elsewhere in the function to determine whether there are any
goto
statements that target a particular label.However, requiring this prevents doing analysis of reachability in a single pass as you may need to look later in the function in order find a
goto
statement that targets a label earlier in the function.For example: When the compiler reaches the
retry:
label it has not yet seen thegoto retry;
statement and so does not yet know whetherretry:
label is reachable.int foo(int x) { { auto result = try_fast(x); if (!result) { goto slow; } return result.value(); } retry: reset_slow(); slow: auto result = try_slow(x); if (!result) { goto retry; // only know that 'retry:' label is reachable after processing this statement } return result.value(); }
An even more sophisticated approach could consider the potential reachability of the
goto
statement targeting a label itself.There may be cycles of reachability of
goto
statements which are not themselves reachable from the function entry-point.For example: In the following function there is a
goto
statement targeting each of the labels in this function, but none of thosegoto
statements are themselves reachable from the function entry-point.void foo(int x) { if constexpr (false) { goto foo; } return x; foo: if (x < 0) throw negative_error{}; goto baz; bar: --x; goto foo; baz: goto bar; }
It is not difficult to imagine such code occuring in practice in function templates where there are
goto
statements inif constexpr
branches that are either discarded or not discarded, depending on the types the function template was instantiated with.The rules could potentially be extended to consider a label as potentially reachable only if there is a potentially reachable
goto
statement that targets the label.Computing the reachability in this case would basically require the compiler to hold the control-flow graph of the entire function in memory and then walk that graph, marking statements as reachable or not. This may be incompatible with the architecture of some compiler implementations.
The proposed design chooses a more conservative algorithm that treats all labels as reachable in order to permit implementations that can compute a more conservative concept of reachability in a single pass.
It is not clear whether or not handling such cases in a more accurate way would be worth the additional complexity it would place on implementations.
- Reachability of compound-statements
- Flowing off the end of a compound-statement
Execution may flow off the end of a compound-statement if either;
- the compound-statement is a potentially-reachable statement and has an empty sequence of sub-statements; or
- both;
- the last sub-statement of the compound-statement is potentially-reachable and is not an interrupted-flow-statement (Note: This includes any null sub-statement implicitly inserted after a trailing label immediately before the closing brace); and
- There are no object declarations declared in the scope of the compound-statement that
have destructors that have the attribute
[[noreturn]]
.
- Flowing off the end of a switch statement
The rules for determining that a switch statement is an interrupted-flow statement require that the body of the switch statement has a
default:
label associated with the switch.This approach is somewhat conservative, as it may be possible that all of the potential cases are already covered by
case
labels and that, therefore, it is not possible for the switch statement to jump over the statement body and flow onto the next statement.For example: The rules above result in the following
void example(bool x) { // Not an interrupted-flow statement - no default: case switch (x) { case true: throw X{}; case false: throw Y{}; } // The following statement is considered potentially-reachable. // An interrupted-flow statement - has a default: case switch (x) { case true: throw X{}; default: throw Y{}; } // Not potentially-reachable. // Prior statement is an interrupted-flow statement. // Control cannot flow off the end of the function's compound-statement. }
The rationale here is that trying to determine whether every possible value for the switch expression is covered by a case label is non-trivial and/or probably doesn't do what you want.
For example: Consider switching on an enum where all enum members have case labels.
enum class state_t { stopped = 0, starting = 1, running = 2 }; int example(state_t state) { switch (state) { case state_t::stopped: return 0; case state_t::starting: return 1; case state_t::running: return 2; } foo(); // should this statement be considered "potentially-reachable"? } // Consider the following call. example(static_cast<state_t>(3));
If, instead, we just look for a
default:
label then we know that every possible case is handled.If we are willing to define rules for determining whether all possible cases are listed as
case
labels then we could potentially relax the rule requiring the use of adefault:
label here. - Use of
[[noreturn]]
for normative semantics
The rules above treat calls to functions marked as
[[noreturn]]
as being interrupted-flow expressions and the interpretation as such can potentially affect the computation of the set of potentially-thrown exceptions, which in turn can affect the semantics and well-formedness of a program.The use of an attribute in this way is novel and would no longer have optional semantics, which would go against the intent of the following note in [dcl.attr.grammar] p6
[Note : The attributes specified in \[dcl.attr] have optional semantics: given a well-formed program, removing all instances of any one of those attributes results in a program whose set of possible executions (\[intro.abstract]) for a given input is a subset of those of the original program for the same input, absent implementation-defined guarantees with respect to that attribute. — end note]
The statement reachability computation depends on the ability to determine whether a function can return normally and flow to the next statement or not. For example, programs may insert calls to
std::terminate()
orstd::unreachable()
before the end of a compound-statement to indicate that control should not flow off the end (e.g. after a loop that is never expected to exit except byreturn
).If we do not wish to give the
[[noreturn]]
attribute normative semantics, then perhaps we should explore defining an alternative normative mechanism for annotating functions as never returning normally.
5.6.2. function-body
The computation of the set of exception types of a function-body is used for two main purposes:
- checking that exception types that can potentially exit the function are listed in a function's throw-specifier.
- deducing the throw-specification for a function with a
throw(auto)
specifier.
The following steps are used to compute the set of potentially-thrown exception types for a function body.
Let A be the set of potentially-thrown exception types for the function body's compound-statement.
If the function is a coroutine and return_void
is found in the scope of
the coroutine's promise_type
then flowing off the end of the coroutine is
equivalent to evaluating co_return;
. If this implicit co_return;
statement
is potentially reachable (see above definition), then the computation of A
takes into account any potentially-thrown exceptions that may result from the
evaluation of the co_return;
statement.
If the function is a constructor, then
- Let B be the set of potentially-thrown exception types of the function call expressions of the constructors of the base-classes and non-static data-members.
Otherwise, if the function is a destructor, then
- Let B be the set of potentially-thrown exception types of the function call expressions of the destructors of the base-classes and non-static data-members.
Otherwise,
- Let B be the empty set.
Let C be the union of the sets A and B.
If the function-body has a function-try-block, then;
- let D be the subset of types in C that would be caught by the handlers of the function-try-block. (see the try-block description for more details about this); and
- for each potentially reachable handler, Hi, of the try-block, let Ei be the set of potentially-thrown
exception types corresponding to the compound-statement of that handler.
For the purposes of computing the set of potentially-thrown exception types, if the function-body is
of a constructor or destructor then the compound-statement of Hi should be considered to have an
implicit
throw;
statement inserted immediately prior to the closing brace. Note: This implicitthrow;
statement may or may not be potentially-reachable and therefore may or may not contribute to the set of potentially-thrown exception types computed for Ei. - Let E be the union of the sets Ei.
Otherwise, let D and E both be the empty set.
Then the set of potentially-thrown exception types of the function-body is the set of types described by (C - D) ∪ E.
5.6.3. statement
A statement is one of the following cases:
- expression-statement
- compound-statement
- selection-statement
- iteration-statement
- jump-statement
- declaration-statement
- try-block
See the relevant section for a description of each.
5.6.4. expression-statement
An expression-statement has a set of potentially-thrown exception types equal to the set of potentially-thrown exception types of the expression.
See section on expression handling below.
5.6.5. compound-statement
The set of potentially thrown exception types for a compound-statement is the union of the set of potentially-thrown exception types for each of the potentially-reachable statements in the statement-seq of the compound-statement.
Note that this takes into account some basic control-flow analysis to eliminate potentially-thrown
exceptions from statements in the statement-seq that are determined to be unreachable.
e.g. ignoring a statement because a preceding statement branched unconditionally to some other
code-path via return
, break
, continue
, goto
, throw
or calling a [[noreturn]]
function.
For example: Assuming the following declarations:
void foo() throw(A); void bar() throw(B, C); void baz() throw(D);
The set of potentially-thrown-exceptions from the following compound-statement is { A
, D
}
{ foo(); // might throw A goto label; bar(); // might throw B or C (note this is an unreachable-statement) label: baz(); // might throw D }
5.6.6. selection-statement
Selection statements include if
, if constexpr
, if consteval
and switch
statements.
if
statements
The set of potentially-thrown exception types of an
if
statement is the union of the potentially-thrown exception types of the:- init-statement - if present
- condition expression
- statement - the first substatement
- statement - the second substatement (if the
else
part is present)
Note that the computation of potentially-thrown exception types does not consider whether or not the condition is a constant expression or not - both branches of sub-statements are always considered when computing the set of potentially-thrown exception-types.
For example: The following if-statement has a set of potentially-thrown exception types equal to {
X
}, despite the condition being a constantif (false) { throw X{}; }
If you want to force the branching decision to be performed at compile-time then use the
if constexpr
form of selection-statement (see below).- Reachability of if-statement components
With if-statements there is the question of whether we should consider the set of potentially-thrown exceptions of the first or second sub-statements if either the init-statement is a interrupted-flow statement, or if the condition is an interrupted-flow expression.
For example: Should the following function,
f()
deduce to a throw-specification ofthrow(X)
or tothrow(X, Y, Z)
?[[noreturn]] bool throws_something() throw(X); void f() throw(auto) { if (throws_something()) { throw Y{}; } else { throw Z{}; } }
The rules above do not try to compute the individual reachability of the substatements and applying the rules as written would result in a deduced exception specification for
f()
ofthrow(X, Y, Z)
.While it would be relatively straight-forward to extend the rules to, instead, compute a deduced exception specification of
throw(X)
, it is not clear that this would bring significant value, as this kind of code is expected to be relatively rare, and could be straight-forwardly rewritten in a form that separates the interrupted-flow expression into a separate statement.For example: The following code is equivalent but deduces the throw-specification to
throw(X)
according to the above rules since the if-statement is not a reachable statement.void f() throw(auto) { bool cond = throws_something(); // The if-statement is unreachable as prior statement was an interrupted-flow statement. if (cond) { throw Y{}; } else { throw Z{}; } }
if constexpr
statements
As the condition of a constexpr if statement is evaluated as part of constant evaluation and a constant evaluation is not permitted to result in a thrown exception, the condition part does not contribute to the set of potentially-thrown exception types.
If the selection-statement contains an init-statement part, then let I be the set of potentially-thrown exception types of the init-statement, otherwise let I be the empty set.
If the value of the converted condition expression is
true
then then set of potentially-thrown exception types of the selection-statement is the union of I and the set of potentially-thrown exception types of the the first substatement. i.e. the body of theif
statement.Otherwise, if the
else
part of the selection statement is present, then the set of potentially-thrown exception types of the selection-statement is the union of I and the set of potentially-thrown exception types of the second substatement. i.e. the body of theelse
statement.Otherwise, the set of potentially-thrown exception types of the selection-statement is I.
For example: The following statement has a set of potentially-thrown exceptions equal to {
X
}.if constexpr (true) { throw X{}; } else { throw Y{}; }
if consteval
An if-statement of the form
if consteval /compound-statement/
has a set of potentially-thrown exception types that is the set of potentially-thrown exception types of the compound-statement.An if-statement of the form
if consteval /compound-statement/ else /statement/
has a set of potentially-thrown exception types that is equal to the union of the sets of potentially thrown exception types of compound-statement and statement, respectively.Note that the compound-statement is manifestly constant-evaluated and so is not currently permitted to throw exceptions and so could potentially be considered as not contributing to the set of potentially-thrown exception types.
However, it is possible that we may want to support the ability to throw exceptions during constant-evaluation in future. See P3068R0 "Allowing exception throwing in constant-evaluation" for such a proposal.
If we were to initially treat code within the manifestly-constant-evaluated branch as non-throwing then later changing it to be potentially-throwing would be a breaking change.
switch
A switch statement of the form
switch ( /init-statement/ /condition/ ) /statement/
orswitch ( /condition/ ) /statement/
has a set of potentially thrown exception-types equal to union of the sets of potentially thrown exception types of the following parts:- init-statement (if present)
- condition expression
- statement
Note that similarly to the if-statement we could modify this to consider the condition only if it is a potentially-reachable expression (i.e. the init-statement is absent or is not a flow-interrupted statement).
Further, if the condition is either not reachable or is a flow-interrupted excpression then we could also consider any
case
ordefault
labels associated with the switch-statement as unreachabel labels.The statement of a
switch
is not itself a potentially-reachable statement, although it may contain potentially-reachable sub-statements if there are labelled sub-statements. The set of potentially-thrown exception types of the statement only considers the potentially-reachable sub-statements.
5.6.7. iteration-statement
The following kinds of iteration-statement are possible:
while ( /condition/ ) /statement/
do /statement/ while ( /expression/ ) ;
for ( /init-statement/ /condition/ ; /expression/ ) /statement/
for ( /init-statement/ /for-range-declaration/ : /for-range-initializer/ ) /statement/
For all of these forms of iteration statement, the set of potentially-thrown exception types of the iteration statement is the union of the sets of potentially-thrown exception types from each of the relevant subexpressions or substatements:
- condition
- statement
- expression
- init-statement
- for-range-declaration
- for-range-initiailizer
Note that this includes exceptions that may be thrown from the body of the iteration statement, even if the statement's condition is such that the body will never be executed.
Note that if [P2809R2] "Trivial infinite loops are not Undefined Behavior" is adopted, then
trivial infinite loops could potentially be required to be implemented as a call to the
proposed function std::this_thread::yield_forever()
, which is marked [[noreturn]]
.
This means that such a trivial infinite loop would be considered a flow-interrupted statement.
If P2809 is adopted after this paper then this could potentially be a breaking change as
it could change the deduced set of potentially-thrown exception types of a function with
a throw(auto)
specifier.
For example: Without P2809 the following function would deduce its exception specification to throw(X)
but with P2809 it would deduce to throw()
.\
void example() throw(auto) { while (true) ; throw X{}; }
5.6.8. jump-statement
Jump statements include:
break;
continue;
return
expr-or-braced-init-list;
- coroutine-return-statement
goto
identifier;
Only the return
and co_return
statements can potentially affect the set of potentially-thrown
exception types here. The others are pure control flow, and while they can potentially trigger
exceptions to be thrown when exiting scopes via that control-flow (if destructors are potentially-throwing),
those exceptions should be covered by the declaration statement for that variable.
return
statements
A
return
statement has a set of potentially-thrown exception types equal to the union of the set of potentially-thrown exception types of the operand expression, and the set of potentially-thrown exception types from any implicit conversion or constructor call required to initialize the return-value.Note that there is an edge-case here that needs to be considered, where the operand to the return statement is a prvalue which is returned with guaranteed copy-elision and where the object has a potentially-throwing destructor. Normally, an expression that creates a pr-value includes the potentially-throwing types of both the call to the constructor, and the call to the destructor, as a statement containing that expression will also call the destructor at the end of the full-expression. However, for a return-value that is initialized with guaranteed copy-elision, the destructor will be invoked by the caller of the function, rather than the local function, and so the exception-specification of the return-value type should not be included in the calculation of the set of potentially-thrown exception types for the return statement.
For example:
struct Foo { Foo() throw(A); Foo(Foo&&) throw(B); ~Foo() throw(C); }; Foo f() throw(auto) { // deduces to throw(A) return Foo{}; // constructor called here but not destructor } Foo g() throw(auto) { // deduces to throw(A, B, C) Foo f; // declaration statement potentially throws A (from constructor) and C (from destructor) return f; // move-constructor potentially called here, even if copy is elided due to NRVO }
We also need to consider the case where the returned object is initialized using aggregate initialization, where there may be a whole tree of sub-expressions that potentially initialize sub-objects of the returned object.
Any expression in such a return-statement that directly initializes an object or sub-object of the return-value that will be destroyed by the caller should not consider the exception-specification of that sub-object's type's destructor when computing the set of potentially-thrown exceptions of the
return
statement.Note that, while it may be possible that the return-statement may execute the destructor of some of these sub-objects in the case that initialization of a subsequent sub-object exits with an exception, we do not need to consider this case for the purposes of computing the potentially-thrown exceptions as these destructors will only be called in case there is an unwind due to another exception - if these destructors then throw their own exceptions during unwind then this results in an immediate call to
std::terminate
.For example:
struct X { X() throw(A); ~X() throw(B); }; struct Y { Y() throw(C); ~Y() throw(D); }; struct Z { X x; Y y; }; Z h() throw(auto) { // deduces to throw(A, C) return Z{X{}, Y{}}; }
In this example, if
Z::x
is initialized and then the call toZ::y
's constructor throws then theZ::x
destructor will be called, which could theoretically exit with an exception of type,B
. However, since this can only happen while unwinding with an exception of typeC
, ifZ::x.~X()
exits with an exception during unwind thenstd::terminate()
is called. So it is not possible for the functionh()
to exit with an exception of typeB
.As an aside, it would be useful to be able to make ill-formed any cases that could result in potential calls to
std::terminate()
due to an exception being thrown during unwind - at least in pursuit of the goal of embedded systems having no hidden calls tostd::terminate()
. How, and whether, to do this is an open-question.co_return
statements
A
co_return
statement of the formco_return;
has a set of potentially-thrown exception types equal to the set of potentially-thown exception types of the statementpromise.return_void();
, where promise is the current coroutine's promise object.A
co_return
statement of the formco_return /expr/ ;
, where expr has typevoid
has a set of potentially-thrown exception types equal to the union of the set of potentially-thrown exception types of expr and the set of potentially-thrown exception types of the statementpromise.return_void()
, where promise is the current coroutine's promise object.A
co_return
statement of the formco_return /expr-or-braced-init-list/ ;
, where the operand is either an expression of non-void
type or is a braced-init-list, has a set of potentially-thrown exception types equal to the set of potentially thrown exception types of the statementpromise.return_value( /expr-or-braced-init-list/ );
.
5.6.9. declaration-statement
A declaration statement consists of a block-declaration, which in turn consists of one of the following:
- simple-declaration
- asm-declaration
- namespace-alias-definition
- using-declaration
- using-enum-declaration
- using-directive
- staticassert-declaration
- alias-declaration
- opaque-enum-declaration
Other than the first two cases, the rest of the declarations do not introduce any executable code that might throw exceptions.
Note that, while the staticassert-declaration has a child expression, this expression is evaluated as a manifestly constant expression, and therefore the program is ill-formed if that expression exits with an exception.
- simple-declaration
A declaration statement that declares one or more block variables with automatic storage duration has a set of potentially-thrown exception types equal to the union of the sets of potentially thrown exception types of any initializer expressions and any calls to constructors or conversion operators required to initialize the local variables, and any potentially thrown exception types of calls to the destructors of the declared variables.
A declaration statement that declares one or more block variables with either static storage duration or thread storage duration has a set of potentially-thrown exception types equal to the union of the sets of potentially-thrown exception types of any initializer expressions and any calls to constructors of conversion operators required to initialize the local variables, but does not include exception types of calls to the destructors of the declared variables.
We do not include the exceptions thrown by destructors of static/threadlocal variables because these destructors are not called from within the scope of a function that initializes these variables.
Q. Initialization of variables with static storage duration needs to perform synchronization in multi-threaded environments in order to guard against data-races initializing the variable. Is it possible that operations on the synchronization primitives might fail with an implementation-defined or unspecified exception (in which case we would need to include this in the set of exception types) or can we assume that the synchronization will always succeed?
A declaration statement that declares a constant expression (i.e. is declared with the
constexpr
specifier) has an empty set of potentially-thrown exception types, assuming that the program is ill-formed if a constant-evaluation exits with an exception. - asm-declaration
The asm-declaration has implementation defined behaviour and, while in theory, on some implementations, an assembly declaration might be able to throw an exception, we cannot, in general, deduce anything about the set of exception types that might be thrown in a portable way.
There are three possible options we take here:
- the asm-declaration has implementation-defined behaviour, so the set of potentially-thrown exceptions from such a declaration should be implementation-defined.
- the asm-declaration could potentially do anything (invoke a potentially-throwing function, implement some exception-throwing mechanics, etc.) so we should treat this as potentially throwing any type of exception.
- the vast majority of asm-declaration usage is for implementing optimized, inline code, which won't throw any exceptions, so we could define it to be non-throwing.
- we could make it ill-formed to use an asm-declaration in any context in which the exception-specification needs to be deduced.
This paper suggests treating an asm-declaration statement as having an implementation-defined set of potentially-thrown exceptions as this at least allows the possibility of the implementation being able to analyse a declaration and deduce what exceptions might be thrown in an implementation-specific way.
However, it would be worth a more detailed discussion within the Evolution sub-group about what the desired semantics are here.
5.6.10. try-block
A try-block statement has the form try /compound-statement/
followed by one or more
handlers of the form catch ( /exception-declaration/ ) /compound-statement/
.
In addition, a function-try-block statement for a constructor can also have the form
try /ctor-initializer/ /compound-statement/
followed by one or more handlers of the
form catch ( /exception-declaration/ ) /compound-statement/
.
Let A be the set of potentially-thrown exception types of the try block's compound-statement.
If this is a function try block for a constructor then let B be the union of A and the sets of potentially-thrown exceptions of initializer expressions and calls to constructors of any base classes and non-static data-members.
Otherwise, let B be the set A.
Let E initially be the empty set.
For each type, x, in the set B
- if x is
std::any_exception
then- add the set of potentially-thrown exception types of the compound-statement of every reachable
handler to E.
- NOTE: a handler is not reachable if the exception-declaration for that handler names a type unambiguously derived from a base class type that is listed in an earlier handler's exception-declaration.
- NOTE: this does not include template handlers as those are only invoked from exceptions thrown from expressions that have a static exception specification.
- if the list of handlers associated with this try-block does not include a handler with an exception-declaration of
...
then addstd::any_exception
to E.
- add the set of potentially-thrown exception types of the compound-statement of every reachable
handler to E.
- otherwise, if any handler is a match for an exception object of type x then
- The selected handler for this exception type is the first handler, h, that matches an exception object of type x. h is a potentially-reachable handler.
- Add the set of potentially-thrown exception-types of h's compound-statement to E.
- Add the set of potentially-thrown exception-types of a function call expression
that invokes the destructor of an object of type, x.
- NOTE: This is because exiting the handler's compound-statement will potentiallly call the destructor of the exception object of type x.
- NOTE: There is a potential edge-case here, where the compound-statement unconditionally
rethrows the exception (e.g. with a
throw;
statement). In this case, the destructor of the exception object is never called upon exiting the compound-statement and so we could avoid adding the destructor's set of potentially-thrown exception types to the set of potentially-thrown exception types for this handler. This is unlikely to make much difference in practice, as most exception types used in practice have a non-throwing destructor, but still needs to be defined.
- otherwise,
- add x to E
If the try-block is part of a function-try-block of a constructor or destructor
then a handler associated with that try-block should be treated as if the statement
throw;
was inserted immediately prior to the closing brace of its compound-statement
for the purposes of computing its set of potentially-thrown exception types.
The set of potentially-thrown exception types of the try-block is the resulting set, E.
5.6.11. init-statement
An init-statement is either a:
- simple-declaration
- expression-statement - Expression statements are already described above.
- alias-declaration - These do not contain any executable code and thus do not contribute to the set of potentially-thrown exception types.
If the statement is a simple-declaration that is an object declaration, the set of potentially-thrown exception types of that statement is the union of the sets of potentially-thrown exception types of the initializer expression, the function call expression of the objects selected constructor and the function call expression of the object's destructor.
5.6.12. expression
There are many different types of expressions that need to be considered.
This paper does not attempt to list all of them here, but instead gives some general rules that apply to most expressions and then describe separately the rules for any expressions that do not follow the general rules.
- General rules for expressions
Expressions that have sub-expressions in general have a set of potentially-thrown exception types that includes the union of the sets of potentially-thrown exception types of the immediate-sub-expressions of that expression.
Operator expressions or conversions that resolve to calls to user-defined operator functions have a set of potentially-thrown exception types of a function call expression to that user-defined operator function. Built-in implicit conversions and operators generally have an empty set of potentially-thrown exceptions.
- Function call expressions
Function call expressions have a set of potentially-thrown exception types equal to the union of the sets of potentially-thrown exception types of the following expressions:
- the expressions provided as arguments to the function, including any default argument expressions.
- any implicit conversion expression required to convert the argument to the corresponding parameter type, and
- the postfix-expression immediately preceding the parenthesised argument-list
unioned with the set of exception types listed in the postfix-expression's function type's or function-pointer type's exception-specification.
If the postfix-expression has function type then the set of potentially-thrown exception types is taken from the exception-specification of the function declaration.
If the postfix-expression has function-pointer or member-function-pointer type then the set of potentially-thrown exception types is taken from the exception-specification of the pointed-to function type. Note that this may be a superset of the set of exception types listed in the pointed-to function's exception specification.
If the function or function-pointer has an dynamic exception-specification then the set of potentially-thrown exception types is the set {
std::any_exception
}, otherwise, if it has a static exception-specification then the set of potentially thrown exception types is the set of types listed in the exception specification.Note that if any parameter types of the function or function-pointer being called have a value category of prvalue then the set of potentially-thrown exception types will also include the exception types listed in the type's destructor's exception-specification, as per the next section.
- prvalue expressions
If an expression has a prvalue value category then the set of potentially thrown exception types of that expression includes the set of potentially thrown exception types of a function call expression to that object's destructor, unless that expression is the operand of a
return
statement of a function returning a prvalue of the same type as the operand and the operand is used to initialize the result object via copy-initialization.See the section on
return
statements for more details (in particular regarding aggregate initialization of return values). - Standard conversions
The set of potentially-thrown exceptions from all standard conversions listed under [conv.general] is the empty set.
- Constant expressions
In C++23 it is not permitted for an exception to be thrown within the evaluation of a constant expression. If this is to remain the case, then we could assume that any manifestly constant expression has an empty set of potentially-thrown exceptions.
However, it's possible that in the future we may decide to allow exceptions to be thrown during the evaluation of a constant expression. Although this may be limited to the cases where any thrown exception is caught within the same constant evaluation - similar to how dynamic memory allocation is allowed, as long as the memory is freed within the same constant evaluation. The paper P3068 "Allowing exception throwing in constant-evaluation" proposes such a change to the language.
In order to allow for this possibility, and to avoid having adding this capability later be a breaking change, manifestly constant expressions should be treated the same as runtime-evaluated expressions for the purposes of computing the set of potentially-thrown exception types of that expression.
However, the top-level expression that begins a new constant evaluation, such as initialization of a
constexpr
variable or the condition of anif constexpr
statement, should be assumed to be non-throwing. An exception propagating out of such an expression would make the program ill-formed.For example:
constexpr int parse_integer(const char* s) throw(parse_error) { int result = 0; do { if (!std::isdigit(*s)) throw parse_error{}; result = 10 * result + (*s - '0'); ++s; } while (*s != '\0'); return result; } int example_1() throw(auto) { // deduces to throw() constexpr int i = parse_integer("1234"); return i; } int example_2() throw(auto) { // deduces to throw(parse_error) const int i = parse_integer("1234"); return i; } int example_3() throw(auto) { // deduces to throw() if constexpr (parse_integer("1234") >= 10) { return 10; } return 0; } constexpr int example_4() throw(auto) { // deduces to throw(parse_error) if consteval { return parse_integer("1234"); } else { return 0; } }
In
example_2()
, even though a compiler could potentially evaluate the call toparse_integer()
as a constant-expression, it is not required to do so and so could potentially have a runtime call to a function that is potentially-throwing.Also, if the function does happen to throw during a speculative constant execution of the function then the compiler falls back to inserting a runtime call to the function instead, rather than making the program ill-formed, like it would be if the invocation in
example_1()
orexample_3()
were to throw.For example, consider what the semantics should be if
example_2()
replaced the argument toparse_integer
with"not-a-number"
. In this case, we would expect that callingexample_2
would throwparse_error
at runtime. Simply changing the value of the string literal passed toparse_integer
should not change the deduced exception-specification of the function.In
example_4()
, despite the call to theparse_integer()
function being evaluated as a manifestly constant expression (inside the first-substatement ofif consteval
) this context is not being evaluated as a top-level constant evaluation and so might (one day) propagate the exception up to some caller that then handles the exception. - Throw expressions
We need to consider both:
throw <expr>
expressions that throw new exception objects, andthrow
expressions that rethrow an existing exception.
throw <expr>
A throw-expression constructs and throws a new exception object of type equal to the decayed type of the operand expression.
Some throw-expressions may require dynamic allocation of storage for the exception object, which might fail due to resource exhaustion. In this case, the throw expression may instead result in throwing an exception of type
std::bad_alloc
.Note that currently, the behaviour of a program that fails to allocate storage for a newly thrown exception is unspecified. However, the behaviour for failure to allocate/rethrow an
exception_ptr
is well-defined and so I would expect most implementations to do something similar - i.e. throwstd::bad_alloc
.This was discussed in the mailing list thread: https://lists.isocpp.org/core/2023/10/15012.php
This paper proposes defining some forms of throw expressions as not requiring dynamic memory allocation, but instead requiring that implementations allocate the exception objects as automatic storage duration objects. This is necessary to be able to provide the guarantee to programs that particular throw expressions actually throw an exception of that type and do not throw an expression of some other type (like
std::bad_alloc
) and therefore that the set of potentially-thrown exception types is limited to the types of the actual thrown exceptions.- Dynamic and Static throw expressions
The function that contained the throw expression that created the exception object is called the "originating function".
If the exception thrown by a throw-expression is either handled within the originating function or if the exception escapes the originating function (possibly after a number of catch/rethrow steps) and the function definition has a throw-specifier with a static exception specification (note this implies the exception type is listed in the throw-specification as otherwise the program would be ill-formed), then the throw expression is considered a static throw expression. All other throw-expressions are dynamic throw expressions.
In general, this means that all throw expressions in functions with a throw-specifier on the definition that have a static exception specifications will be static throw expressions and throw-expressions in a function with a dynamic exception specification will be dynamic throw expressions unless they are caught locally within the originating function.
For example: Both of the throw-expressions and the rethrow-expressions are all static throw expressions.
void example1(int i) throw(Y, Z) { try { switch (i) { case 0: throw X{}; // static throw-expression: doesn't escape the function - locally handled case 1: throw Y{}; // static throw-expression: can escape the function (with rethrow) case 2: throw Z{}; // static throw-expression: escapes the function (not handled locally) } } catch (X) { // handled, not rethrown } catch (Y) { // handled, rethrown throw; } }
Another example: this time with some dynamic exception specifications
void example2(int i) throw(...) { try { switch (i) { case 0: throw X{}; // static throw-expression: doesn't escape the function - locally handled case 1: throw Y{}; // dynamic throw-expression: can escape the function (with rethrow) case 2: throw Z{}; // dynamic throw-expression: escapes the function (not handled locally) } } catch (X) { // handled, not rethrown } catch (Y) { // handled, rethrown throw; } }
A dynamic throw expression allocates the storage in an unspecified way controlled by the implementation in the same way that storage for thrown exception objects are currently allocated. The exception object created by a dynamic throw expression is called a dynamic exception object.
A static throw expression allocates the storage for the exception object with automatic storage duration. The exception object created by a static throw expression is called a static exception object. The scope of the storage ends after the end of the lifetime of the static exception object.
- Lifetime of exception objects created by static throw expressions
According to [except.throw] p4
The points of potential destruction for the exception object are:
- when an active handler for the exception exits by any means other than rethrowing, immediately after the destruction of the object (if any) declared in the exception-declaration in the handler.
- when an object of type
std::exception_ptr
that refers to the exception object is destroyed, before the destructor ofstd::exception_ptr
returns.
Among all points of potential destruction for the exception object, there is an unspecified last one where the exception object is destroyed. All other points happen before the last one.
For exception objects created by static throw expressions, we need to be able to deterministically compute their latest-possible point of destruction so that we know where to allocate the automatic storage-duration storage for that exception object.
To allow the compiler to compute this point, we need to eliminate the possibility of the exception object's lifetime being dynamically extended by calling
std::current_exception()
and holding on to thestd::exception_ptr
. However, we still want to permit users to callstd::current_exception()
if desired.If the exception object being handled by the most-recently entered handler is a static exception object then a call to
std::current_exception()
returns anexception_ptr
that references a dynamic exception object that is copied from the static exception object.Note that the current semantics of capturing the exception in a
std::exception_ptr
by callingstd::current_exception()
and throwing the captured exception bystd::rethrow_exception()
does not guarantee to throw the same exception object. An implementation is allowed to copy the current exception object into storage referenced by the returnedstd::exception_ptr
, so this behaviour is consistent with existing semantics.Similarly, if a static exception object is rethrown by a
throw;
expression that is not a lexically associated rethrow of the active handler then the compiler may not be able to deduce that the current exception object escapes the current handler and should not be destroyed when the current handler exits.A lexically associated rethrow is a
throw;
expression that is a sub-statement of a handler's compound-statement and that is;- not inside the handler of a try-block nested within this handler; and
- not inside the body of a lambda expression nested within this handler; and
- not inside the body of a member function of a local class
A
throw;
expression that is not a lexically associated rethrow throws a dynamic exception object. If the current exception is a static exception object then this will allocate a new dynamic exception object copied from the current static exception object.With those cases handled, we can now define that the lifetime of a static exception object ends deterministically upon exiting a handler for that exception through any means other that a lexically associated rethrow.
Storage for a static exception object has automatic storage-duration.
Generally, the storage for a static exception object will be allocated in the scope of the function that has the outer-most dynamic scope for a handler that can match the exception object's type, taking into account possible chains of catch/rethrow of the exception object.
However, there are some cases where rethrowing an exception object may result in copying a static exception object from automatic storage duration storage allocated in an inner dynamic-scope to automatic storage duration allocated by an outer dynamic-scope.
A caller of a function with a static exception specification will generally provide storage to the called function so that the called function can allocate the exception object in storage owned by the caller before it exits. If the caller is not the final handler of an exception (i.e. the lifetime of the exception object may escape the calling function), the caller may choose to reuse the storage provided to it by its caller as the storage it provides to the function it calls.
- Examples of static exception object lifetime and storage duration
The following examples walk through some simple cases and describe how the exception object lifetime of static exception objects is intended to work.
Given the following exception type,
X
, and functionf()
, which throws an instance ofX
:struct X { explicit X(int) noexcept; X(const X&) noexcept; ~X(); int value; }; extern int some_int; void f() throw(X) { throw X{some_int}; }
Example 0: Consider the following case:
void g0() throw(X) { f(); } void h0() throw() { try { g0(); } catch (const X&) {} }
In this case:
h0()
allocates automatic-storage duration storage for an exception object of typeX
and provides the address of this storage tog0()
.- then
g0()
callsf()
and provides the address provided byh0()
tof()
- if
f()
throws an exception, then it constructs the object directly into the storage provided byh0()
. - the exception unwinds through
g0()
and the handler inh0()
is activated - the exception object is destroyed and the lifetime of the storage for the
exception ends when the handler in
h0()
exits.
Example 1: Consider the following case:
void g1() throw(X) { try { f(); } catch (const X&) { throw; } } void h1() throw() { try { g1(); } catch (const X&) {} }
In this case:
h1()
allocates automatic-storage duration storage for an exception object of typeX
and provides the address of this storage tog1()
.- when
g1()
callsf()
it forwards the address provided byh1()
tof()
andf()
constructs the exception object directly into the storage provided byh1()
. - execution returns from
f()
and the handler ing1()
is activated. - this handler then rethrows the exception - as the exception object is already constructed in the final location there is no need to copy the object.
- execution then returns to
h1()
and its handler is activated - the exception object is destroyed when the handler in
h1()
exits
Note that
g1()
in this case was able to pass the address of the storage for the exception object provided byh1()
tof()
because there was no other exception object being thrown byg1()
whose lifetime overlaps with the lifetime of the exception object thrown byf()
.Example 2: Consider the following case:
void g2() throw(X) { try { f(); } catch (const X& x) { throw x; } } void h2() throw() { try { g2(); } catch (const X&) {} }
In this case:
- The handler in
g2()
throws a new exception object insead of rethrowing the existing one - This means that the lifetime of the new exception object thrown by
g2()
overlaps the lifetime of the exception object thrown byf()
, which is alive until the handler is exited. - This means that
g2()
cannot reuse the storage for the exception object provided byh2()
to pass tof()
and it needs to allocate its own automatic storage duration storage to provide tof()
for any exception object - When
f()
throws an exception it constructs into the storage ing2()
and then unwinds and activates the handler ing2()
. - The
throw x;
statement ing2()
's handler then constructs a new exception object in the storage provided byh2()
. - When the handler in
g2()
exits, the exception object thrown byf()
is destroyed and its storage lifetime ends. - Control then unwinds to
h2()
and activates its handler. - When the handler in
h2()
exits, the exception object thrown byg2()
is destroyed and its storage lifetime ends.
Example 3: Consider the following case:
void g3() throw(X) { try { f(); } catch (const X& x) { if (x.value < 0) { throw X{0}; // throw a new object } throw; // rethrow } void h3() throw() { try { g3(); } catch (const X&) {} }
In this case:
- The handler in
g3()
conditionally either rethrows the current exception object or throws a new exception object. - In the case that the handler throws a new object, we have a similar case to
g2()
where there is overlap in lifetime between the exception object thrown byf()
(whose lifetime ends at the end of the current handler) and the new exception object created by thethrow X{0};
statement. - This means that we cannot reuse the storage that
h3()
provided tog3()
for returning the exception as the storage thatg3()
provides tof()
for returning its exception, even though there is another code-path ing3()
's handler that rethrows the current exception toh3()
. - Instead,
g3()
will need to allocate its own local storage to provide tof()
, and then in both of the throw and rethrow-expressions, construct a new exception object in the storage provided byh3()
tog3()
. - Note that in the rethrow-expression case, since the original exception object is about to
be destroyed, we may be able to move-construct the new exception object from the original
object instead of copy it as the
throw;
statement will exit the handler. Note: This may not be the case for all rethrow expressions.
Example 4: Consider the following case:
void g4() throw(X) { try { f(); } catch (const X& x) { if (x.value >= 0) throw; } throw X{0}; } void h4() { try { g4(); } catch (const X&) {} }
In this case:
- It is similar to the above
g3()
example, except that this time, the new exception object is thrown from outside of the handler. - This means that the new exception object's lifetime no longer overlaps the lifetime of the
exception object thrown by
f()
. If the exception object is rethrown byg4()
's handler then the exception object is already constructed in the right location. Otherwise, the handler exits and the exception object thrown byf()
is destroyed before the new exception object is created bythrow X{0}
. - It is therefore safe for
g4()
to pass through the storage provided to it for the exception object to the call tof()
.
- Rules for throw expressions
The set of potentially-thrown exception types of a throw expression is the union of
- the set of potentially-thrown exception types of the operand expression
- the type of exception thrown. i.e.
std::decay_t<decltype(<expr>)>
- the set of potentially-thrown exception type of copy-initialization of the exception-object
Additionally, if the throw-expression is a dynamic-throw-expression then the set of potentially-thrown exception types also includes
std::bad_alloc
, which may be thrown if the implementation fails to allocate storage for the exception object.
- Dynamic and Static throw expressions
throw
(rethrowing the currently handled exception)
If the rethrow expression occurs lexically within the body of a handler and is not nested within a locally defined function body or a lambda expression body defined in the scope of that handler, then we say that the rethrow expression is associated with the inner-most enclosing handler.
Otherwise, we say that the rethrow expression is unassociated with a handler.
For example:
void example() { throw; // unassociated - not inside handler try { throw; // unassociated - not inside handler } catch (A a) { // #1 if (!can_handle(a)) { throw; // associated with #1 } auto rethrow = [] { throw; // unassociated - inside lambda }; struct LocalClass { static void Rethrow() { throw; // unassociated - inside nested function } }; } catch (...) { // #2 try { throw; // associated with #2 } catch (B b) { // #3 throw; // associated with #3 } } }
If a rethrow expression is associated with a handler then we potentially have more static information about the set of possible exception types that might be thrown by that expression than a rethrow expression that is unassociated with a handler.
We can compute the set of exception types that might be thrown by such a rethrow exception based on the set of exception-types that may propagate out of the try-block and based on the exception types that may be handled by handlers earlier in the sequence of handlers for that try-block.
- Unreachable handlers
One of the interesting cases to handle is when we can statically determine whether the handler is unreachable. This can happen in one of two cases:
- When an earlier handler matches an unambiguous base-class of the later handler's exception-declaration type and will therefore preferentially match any exception types that would be handled by the later later handler.
- When the set of potentially thrown exception types of the try-block's compound-statement is a finite set
(i.e. does not contain contain
std::any_exception
) and there are no types in that finite set that match this handler and that do not match any earlier handler.
In this case, there is the question of what the set of potentially-thrown exception types should be for a rethrow expression that is associated with such an unreachable handler.
As the handler itself is statically determined to be unreachable, the body of the handler's compound-statement does not contribute to the overall set of potentially-thrown exception types of the try-block, so we might say "it doesn't matter".
However, if we consider the ability to query the set of potentially-thrown exception types using
declthrow()
then a program might want to query within the context of the handler, what types might be thrown by a rethrow expression. i.e.declthrow(throw)...
.The ability to query whether or not a given handler is reachable can be useful in eliminating code that would otherwise be ill-formed, or that we want to avoid instantiating to reduce compile-times.
For example: Code for implementing the P2300
std::execution::then()
algorithm needs to determine whether invoking the transformation function might throw and only if it does then invoke the receiver withset_error()
, otherwise it should not form a call toset_error()
.For example:
template<typename... Args> void then_receiver::set_value(Args&&... args) noexcept { try { if constexpr (std::is_void_v<decltype(this->func(std::forward<Args>(args)...)>) { this->func(std::forward<Args>(args)...); std::execution::set_value(this->receiver); } else { std::execution::set_value(this->receiver, this->func(std::forward<Args>(args)...)); } } catch (...) { // Only want to instantiate the call to set_error() if there are actually // any errors possible. if constexpr (noexcept(this->func(std::forward<Args>(args)...)) { std::execution::set_error(this->receiver, std::current_exception()); } } }
Note that here we need to repeat the essential parts of the body of the try-block in a constexpr if condition noexcept expression to determine whether or not the
catch(...)
block was reachable. And while, for this example, the body only has one such expression, it is relatively easy to conceive of try-block logic that could be much more involved.If, instead, we were able to query whether or not the handler was reachable by querying the set of exception types that might be thrown by
throw;
within that handler, then we could avoid having to duplicate the body expressions in the constexpr if condition.For example:
template<typename... Args> void then_receiver::set_value(Args&&... args) noexcept { try { if constexpr (std::is_void_v<decltype(this->func(std::forward<Args>(args)...)>) { this->func(std::forward<Args>(args)...); std::execution::set_value(this->get_receiver()); } else { std::execution::set_value(this->get_receiver(), this->func(std::forward<Args>(args)...)); } } catch (...) { // Only want to instantiate the call to set_error() if there are actually // any errors possible. // // declthrow(throw)... will produce the empty pack if the try-block body // is not potentially-throwing. if constexpr (sizeof...(declthrow(throw)) != 0) { std::execution::set_error(this->get_receiver(), std::current_exception()); } } }
Another design direction that could be taken here is to allow writing the catch-all handler as a template catch that is instantiated with an exception declaration of
std::exception_ptr
. This could build upon the alternative suggested earlier for usingstd::exception_ptr
in place ofstd::any_exception
for indicating the set of potenially-thrown exception types for a call to a function with a dynamic exception specification.If the body of the try-block had a set of potentially-thrown exception types that was empty then the
template catch
handler would not be instantiated.For example:
template<typename... Args> void then_receiver::set_value(Args&&... args) noexcept { try { if constexpr (std::is_void_v<decltype(this->func(std::forward<Args>(args)...)>) { this->func(std::forward<Args>(args)...); std::execution::set_value(this->get_receiver()); } else { std::execution::set_value(this->get_receiver(), this->func(std::forward<Args>(args)...)); } } template catch (auto& error) { // Catch-block is only instantiated if there are any exceptions that might be thrown from code in the try-block. // If an exception is thrown by a 'throw(...)' function then 'error' will have type 'std::exception_ptr&'. std::execution::set_error(this->get_receiver(), std::move(error)); } }
- Rethrow expression rules
If the rethrow expression is unassociated with a handler then the set of potentially-thrown exception types for that rethrow expression is equal to the set {
std::any_exception
}. This is because the expression could potentially be executed within the dynamic scope of any handler and thus could rethrow any caught exception type.Otherwise, if the rethrow expression is associated with a handler, H, then;
Let E be the set of potentially-thrown exception types of the compound-statement of the try-block associated with H.
Let H-pre be the set of handlers associated with the same try-block as H that precede H in the try-block's handler-seq.
Let X be initially the empty set.
For each type,
e
, in the set E- if
e
isstd::any_exception
then- if the exception-declaration of H is
...
then addstd::any_exception
to the set X - otherwise, let
h
be the type named in H's exception-declaration- if any handler in H-pre matches exceptions of type
h
then, do nothing (this handler is unreachable) - otherwise, if
h
is of non-class type or is a final class then addh
to X - otherwise add
std::any_exception
to X (it might handle an unbounded set of potential exceptions derived fromh
)
- if any handler in H-pre matches exceptions of type
- if the exception-declaration of H is
- otherwise,
- if any handler in H-pre matches exceptions of type
e
, then do-nothing - otherwise, if H matches exceptions of type
e
, then adde
to X - otherwise, do nothing
- if any handler in H-pre matches exceptions of type
The set of potentially-thrown exception types for a rethrow expression associated with H is X.
Note that this algorithm can produce a set of potentially-thrown exception types that includes a list of concrete exception types as well as
std::any_exception
.Note that these rules make the result of a
declthrow(throw)
expression context dependent.Note that each instantiation of a template handler has a separate set, X, of potentially-thrown exception types that it handles. Thus a lexically-associated rethrow expression within a template handler will have different sets of potentially-rethrown exception types for different instantiations. The sets of potentially-rethrown exception types for each instantiation of a template handler will be disjoint.
- if
- Examples
Given the following declarations:
struct A {}; struct B : A {}; struct C : A {}; struct D : B, C {}; struct E final {}; template<typename... Ts> void throws() throw(Ts...);
Example 1: static exception list, catch(…)
try { throws<A>(); } catch (...) { throw; // declthrow -> { A } }
Example 2: static exception list, typed handler, unreachable catch (…)
try { throws<A>(); } catch (A) { throw; // declthrow -> { A } } catch (...) { throw; // declthrow -> { } }
Example 3: multiple exceptions caught by single handler
try { throws<A, B>(); } catch (A) { throw; // declthrow -> { A, B } } catch (...) { throw; // declthrow -> { } }
Example 4: multiple exceptions caught by multiple handlers
try { throws<A, B>(); } catch (B) { throw; // declthrow -> { B } } catch (A) { throw; // declthrow -> { A } } catch (...) { throw; // declthrow - > { } }
Example 5: ambiguous bases
try { throws<D>(); } catch (A) { throw; // declthrow -> { } - empty because A is ambiguous base of D and so doesn't match } catch (B) { throw; // declthrow -> { D } - catch (B) unambiguously handles exceptions of type D } catch (C) { throw; // declthrow -> { } - already handled by catch (B) } catch (D) { throw; // declthrow -> { } - already handled by catch (B) }
Example 6: mixed static/dynamic
try { throws<A>(); throws<std::any_exception>(); } catch (A) { throw; // declthrow -> { std::any_exception, A } } catch (...) { throw; // declthrow -> { std::any_exception } }
Example 7: dynamic throw with final class
try { throws<std::any_exception>(); } catch (E) { throw; // declthrow -> { E } } catch (...) { throw; // declthrow -> { std::any_exception } }
Example 8: mixed static/dynamic with final class
try { throws<E>(); throws<std::any_exception>(); } catch (E) { throw; // declthrow -> { E } } catch (...) { throw; // declthrow -> { std::any_exception } }
Example 9: template handler, single type
try { throws<A>(); } template catch (auto e) { // Instantiated for types { A } throw; // declthrow -> { decltype(e) } }
Example 10: template handler, multiple types
try { throws<A, B, C, D>(); } template catch (auto e) { // Instantiated for following types { D, B, C, A } throw; // declthrow -> { decltype(e) } }
Example 11: template handler + non-template handler
try { throws<A, B, C, D>(); } catch (B) { throw; // declthrow -> { B, D } } template catch (auto e) { // Instantiated for { C, A } throw; // declthrow -> { decltype(e) } - either { A } or { C } }
Example 12: template handler + non-template handler + dynamic throw
try { throws<A, B, C, D>(); throws<std::any_exception>(); } catch (B) { throw; // declthrow -> { B, D, std::any_exception } } template catch (auto e) { // Instantiated for { C, A } throw; // declthrow -> { decltype(e) } } catch (...) { throw; // declthrow -> { std::any_exception } }
Note that for example 12 the template handler is instantiated for types A and C and rethrowing within the template handler only rethrows those concrete types, despite the possibility of the
throws<std::any_exception>()
potentially being able to throw exceptions of type derived from eitherC
orA
.Example 13: constrained template handler + non-template handler + dynamic throw
try { throws<A, B, C, D>(); throws<std::any_exception>(); } template catch (std::one_of<B, C> auto e) { // instantiated for { B, C } throw; // declthrow -> { decltype(e) } } catch (A) { throw; // declthrow -> { A, D, std::any_exception } } catch (...) { throw; // declthrow -> { std::any_exception } }
Note that with this example, the compiler tries to instantiate the template handler for each of the static exception types, but substitution fails for types A and D, succeeding only for types B and C.
The handler for A matches A and D and potentially also an unbounded set of other types derived from A.
The final handler matches an unbounded set of types not derived from A.
- Unreachable handlers
- await-expression
An expression of the form
co_await /expression/
has a set of potentially-thrown exception types equal to union of the sets of potentially-thrown exception types of each of the sub-expressions that the await-expression de-composes into.Including:
- The operand expression
- The call to
p.await_transform(expr)
, if applicable. - The call to member or non-member
operator co_await
, if applicable. - The calls to
await_ready()
,await_suspend()
andawait_resume()
. - A call to the destructors of any temporary objects created as a result of one of the above sub-expressions
Note that if one of the sub-expressions is an interrupted-flow expression then we still consider the set of potentially-thrown exception types of subsequently evaluated sub-expressions even though they may be unreachable. If we want to consider ignoring unreachable sub-expressions then this should be applied more generally across all expression evaluation.
- yield-expression
A
co_yield /assignment-expression/
orco_yield /braced-init-list/
expression has a set of potentially-thrown exception types equal to the union of the sets of the potentially thrown exception types of the expressionco_await promise.yield_value(expr)
, wherepromise
is an lvalue reference that refers to the current coroutine's promise object.Note that the call to
promise.await_transform()
is not applied as part of aco_yield
expression, so does not contribute to the set of exception types. dynamic_cast
Basically, any
dynamic_cast
expression to reference-type that could fail the runtime check has a set of potentially-thrown exception types equal to the set {std::bad_cast
}.A
dynamic_cast
expression that is a cast to pointer-type or that was a cast to a reference type that would not fail (e.g. because it was a cast to an unambiguous base-class of the operand's class type) has an empty set of potentially-throw exception types.Note that the wording of
dynamic_cast
currently permits implementations to throw some type that would match a handler of typestd::bad_cast
, which currently permits throwing types derived fromstd::bad_cast
. This change would require that the exception thrown was exactly the typestd::bad_cast
.typeid
A
typeid(/type-id/)
expression has an empty set of potentially-thrown exception types.A
typeid(/expression/)
expression has a set of potentially-thrown exception types equal to {std::bad_typeid
}. This exception may be thrown if the operand of thetypeid
expression is the result of dereferencing a null pointer of non-cv type.While this behaviour matches the current specification of
typeid
expressions, supporting the ability to ask for the type of a dereferenced null pointer seems to be of questionable value. The wording for the builtin unary*
operator in [expr.unary.op] says that the behaviour of performing an indirection on a pointer that does not point to a function or object is undefined except as specified in [expr.typeid].So, literally the only thing you can do with an lvalue produced by dereferencing a null pointer is pass it to a
typeid
expression.It may be worth exploring whether we can either deprecate/remove this behaviour from
typeid
(which would be a breaking change) or whether we can add a new form oftypeid(/expression/)
that has a precondition that the expression does result in an glvalue that refers to an object or function. i.e. such that passing a dereferenced null pointer is undefined-behaviour.This would allow the set of potentially-thrown exception types for a
typeid
expression to be empty.- Name expressions
Expressions that simply name an object
These expressions include:
- id-expression
this
These expressions have an empty set of potentially thrown exception types.
- Lambda expressions
The set of potentially-thrown exception types of a lambda expression are the union of the set of potentially thrown exception types of the initializers of the lambda captures.
5.7. Template handlers
Template handlers are the static-exception equivalent of a catch(...)
.
They allow a generic handler to be written that can be instantiated to handle any
number of exception types statically. The compiler instantiates the handler based
on the set of potentially-thrown exception types computed for the body of the
corresponding try
block which were not caught by any preceding handlers.
The short-hand syntax allows the user to prefix the catch
keyword with the
template
keyword and then use a placeholder-type-specifier as the exception-declaration.
For example:
void do_something() throw(A, B, C); try { do_something(); } template catch (auto e) { // catch-block instantiated for each unique type in the static-exception specification // In this case where 'e' has type A, B or C. }
The template
keyword is technically redundant in this case as we could deduce that the handler
is a template from the use of a placeholder-type-specifier. However, the use of the keyword
template
here can also serve as an indication in code that something a bit different is going
on.
The requirement for the template
keyword could potentially be dropped in this case if desired
to match consistency with the syntax for function declarations.
The short-hand syntax also allows a trailing requires-clause to be added after the exception-declaration to allow introducing type-constraints on the instantiation of the template handler.
For example:
void do_something() throw(A, B, C); try { do_something(); } template catch (auto e) requires std::derived_from<decltype(e), std::exception> { // catch block only instantiated for types that inherit from ~std::exception~. LOG("error: %s", ex.what()); } template catch (auto e) { // handle types not inherited from std::exception }
The more general syntax for a template catch-block allows the use of a template-head before the
catch
keyword and then refer to to a dependent type that allows any template parameters declared
in the template-head to be deduced from the type of the exception.
For example: Alternative to template catch (auto e)
using the template-head syntax
try { do_something(); } template<typename T> catch (T e) { //... do something with 'e' }
For example: Using constraints and deduced template arguments for a class template
template<typename Inner> struct MyException : std::exception { const char* what() noexcept override { return "MyException"; } Inner inner; }; try { do_something(); } template<std::derived_from<std::exception> Inner> requires (!std::derived_from<Inner, std::runtime_exception>) catch (const MyException<Inner>& err) { LOG("Failed with MyException because: {}", err.inner.what()); }
Template handlers can also then be used with if constexpr
to handle
a given exception based on static properties of the exception type.
For example:
template<typename T, typename Inner> struct NestedException : public T { Inner inner; }; template<typename T> constexpr bool is_nested_v = false; template<typename T, typename Inner> constexpr bool is_nested_v<NestedException<T, Inner>> = true; void foo() throw(FooError, NestedException<FooError, std::bad_alloc>, NestedException<FooError, std::system_error>); try { foo(); } template catch (std::derived_from<FooError> auto err) { // generic FooError handling if constexpr (is_nested_v<decltype(err)>) { std::print("error: Failed with FooError because {}", err.inner); } else { std::print("error: Failed with FooError"); } }
5.7.1. Template argument deduction
When the compiler is trying to determine which handler, if any, will match a static exception object thrown from the body of a try-block, it proceeds to try each handler in order. If a handler matches the exception type then the compiler stops searching and does not look at any subsequent handlers.
If, during the search for a matching handler, the compiler encounters a template handler, then the compiler performs the following to determine whether the handler is a match:
- Attempts to deduce template arguments of the template handler by treating the handler as if it were a function template with a single argument and then forming a call to that function-template, passing a single argument which is a non-const lvalue reference to the static exception object. However, this is done with the restriction that the only conversions allowed are implicit conversion to an unambiguous base-class.
- If template argument deduction fails then the handler is not a match.
- Otherwise, if template argument deduction succeeds, then any constraints are evaluated using those deduced template arguments.
- If those constraints are not satisfied then the handler is not a match.
- Otherwise, the handler is a match and the body of the handler is instantiated with the deduced template arguments, substituting those template arguments into the body of the handler. The resulting handler instantiation becomes the handler for static exception objects of that type.
If a handler template is never instantiated (i.e. because it did not match any thrown static exception object types) then the compound-statement of that handler does not contribute any statements to the body of the enclosing function.
This can potentially affect the return-type of a function with a deduced return-type.
For example:
auto example(auto f) throw(X) { try { f(); } template catch (auto err) { return err; } throw X{}; } example([] throw() {}); // deduces return type to be 'void' as return statement never instantiated example([] throw(int) {}); // deduces return type to be 'int' - single return statement example([] throw(int, long) {}); // ill-formed: multiple return statements with different deduced return types
5.7.2. Matching dynamic exception objects
The proposed design is such that a template handler does not match a thrown
dynamic exception object, so if the body contains any throw(...)
expressions
then the handler sequence will still require a catch(...)
handler as the
last handler to handle those.
However, one design direction being explored is to allow a dynamic-exception-object
to match template and non-template handlers as if the exception object had type
std::exception_ptr
. In this case, a template catch (auto e)
would be
instantiated with decltype(e)
deduced to std::exception_ptr
.
5.7.3. Ordering of instantiated handlers
For the purposes of determining which handler is a match, instantiations of a given handler template are unordered. If multiple instantiations would potentially match a given handler when written out then which one is chosen is determined by deducing template arguments and choosing that instantiation rather than looking at which one appears earlier in a list of instantiations.
5.8. Virtual Functions
Virtual functions declared on a base-class can potentially have a different (wider) exception specification than an override of that virtual function declared in a derived class.
For example: A base class might declare a virtual function as noexcept(false)
whereas a
derived class might declare its override as noexcept(true)
.
However, a derived class cannot have an exception-specification that is wider than the base
class method. Callers of a base-class virtual method marked noexcept
can rightly expect that
derived classes cannot override this method to start throwing exceptions from the call.
If we want to extend this idea to throw-specifications, then we need to require that an override of a virtual function does not widen the set of potentially-thrown exception types.
If a base class has a virtual function with an exception specification that is
noexcept(false)
or throw(...)
then an override of this function in a derived
class can have any exception-specifier.
Example 1
struct base { virtual void f() throw(...); }; struct derived1 : base { void f() throw() override; // OK }; struct derived2 : base { void f() throw(A, B, C) override; // OK }; struct derived3 : base { void f() throw(...) override; // OK };
If a base-class has a virtual function with an exception specification that is
noexcept(true)
or throw()
then an override of this function must also have
an exception specification that is either noexcept(true)
or throw()
.
Example 2
struct base { virtual void f() throw(); }; struct derived1 : base { void f() throw() override; // OK }; struct derived2 : base { void f() throw(A, B, C) override; // ERROR: f() has wider exception specification than base::f() }; struct derived3 : base { void f() throw(...) override; // ERROR: f() has wider exception specification than base::f() };
If a base-class has a virtual function with a non-empty static exception specification then an override of this function must have a static exception specification that is a subset of the set of exception types listed in the base class exception specification.
Example 3
struct base { virtual void f() throw(A, B); }; struct derived1 : base { void f() throw(A, B) override; // OK }; struct derived2 : base { void f() throw(A) override; // OK }; struct derived3 : base { void f() throw(B) override; // OK }; struct derived4 : base { void f() throw() override; // OK }; struct derived5 : base { void f() throw(A, C) override; // ERROR: Throws C which is not allowed by base::f() throw specification }; struct derived6 : base { void f() throw(...) override; // ERROR: f() has wider exception specification than base::f(). };
It is worth noting here that virtual function overrides are only permitted to throw a subset of the set of types listed in the throw-specification of the base class declaration. They are not permitted to throw types not listed in the throw-specification that are derived from the types listed in the base class declaration.
Virtual functions that want to allow overrides to throw types not listed in the
base class function's throw-specification should declare the base-class virtual
function with the noexcept(false)
or throw(...)
specifier to indicate that
a dynamic exception type may be thrown.
Alternatively, the base class function declaration can list an exception type
that can allow derived types to extend with different values it can hold.
e.g. std::error_code
5.8.1. ABI of virtual functions with differing exception specifications
While the language semantics described above are a natural extension of the current rules around noexcept, they do have some impacts on what implementations need to do to provide these semantics.
A virtual function with a non-empty static exception-specification can have a different calling-convention to a function with a different exception-specification.
This means that in cases where an override chooses to narrow the exception specification that we may not be able to directly use a pointer to the overridden function in the vtable slot used for a call that dispatches statically via the base class interface as the calling conventions may not match.
This is a similar case to how the compiler needs to handle overrides that can have covariant return-types that return a pointer/reference to some type that is derived from the return-type of the base class function.
In many cases the ABI of the override that returns a derived type is the same as the ABI of the base class function.
For example:
struct base { virtual base* clone(); }; struct derived : base { derived* clone() override; };
In this case, the derived
class has the base
class as its first sub-object
and so the address of the derived
object returned by derived::clone()
can be
reused as the address of the base
object and so the vtable entries for calling
base::clone()
and derived::clone()
can be the same.
However, if you consider:
struct base { virtual base* clone(); }; struct other_base { virtual std::string to_string(); }; struct derived : other_base, base { derived* clone() override; };
Now, the base
object is not necessarily the first sub-object of a derived
and
so the pointer returned by derived::clone()
may need to be adjusted by an offset
when called via the base::clone()
interface so that the returned pointer refers
to the base
sub-object.
This means that the vtable entry for base::clone()
cannot just contain the address
of the derived::clone()
function as it would return an address that was not a
pointer to the base
sub-object.
Instead, the table entry for base::clone()
in the derived
type's vtable contains
the address of a "thunk" - a function stub that forwards the call onto the real
implementation and then adjusts the returned address by applying the necessary offset.
When a call is made via the derived::clone()
interface, it instead dispatches to
a separate vtable entry that directly calls the derived::clone()
function.
The same approach would be required whenever a base-class virtual function is overridden by a derived class that has an exception-specification that differs such that it has a different calling convention and is thus ABI incompatible with the ABI of the base class function's ABI.
These thunks would be the kind of thunk that is generated when you cast a function to a function-pointer with a different exception-specification, described in Static exception specifications are part of the function type.
5.9. Concepts
A requires
expression allows checking that certain expressions are noexcept
.
For example:
template<typename T, typename U> concept nothrow_assignable_from = requires(T t, U u) { { static_cast<T&&>(t) = static_cast<U&&>(u) } noexcept; };
This concept checking that the assignment expression is valid and that the set of potentially thrown exceptions for the expression is the empty set.
It is equivalent to the following, only without the repetition of expressions:
template<typename T, typename U> concept nothrow_assignable_from = requires(T t, U u) { static_cast<T&&>(t) = static_cast<U&&>(u); } && noexcept(std::declval<T&&>() = static_cast<U&&>(u));
If we are to introduce static exception specifications then it seems reasonable to also want to extend the requires-expression syntax with the ability to check that the set of potentially-thrown exceptions from an expression satisfies some requirement.
For example, say we wanted to check that a given member-function-call could only throw std::bad_alloc
,
we we could allow the user to write something like:
template<typename T> concept foo_factory = requires(T& obj) { { obj.make_foo() } throw(std::bad_alloc) -> foo_concept; };
However, from this syntax it's not clear exactly what the semantics of such a check should be. It could be:
- Check that the set of potentially-thrown exceptions is a subset of the listed types.
e.g. because it needs to be callable from a function with a
throw(std::bad_alloc)
specifier. - Check that the set of potentially-thrown exceptions would be caught by a handler whose
exception-declaration is
std::bad_alloc
. e.g. because the expression needs to be callable from within anoexcept
function but is surrounded by a try/catch that catches and handlesstd::bad_alloc
.
This confusion/ambiguity is similar to the one that was raised during the design of concepts
for C++20 - is a { expr } -> T;
clause in a requires-expression checking that the expression
results in exactly type T
, or is it checking that the expression is convertible to type T
?
The approach taken to resolve this was to require that you specify a concept that the result of
the expresion needs to match. This forces the user to be explicit about intent.
i.e. either write std::same_as<T>
or std::convertible_to<T>
.
Thus this paper proposes taking a similar approach. i.e. to extend the compound-requirement as follows:
/compound-requirement/: { /expression/ } /exception-requirement[opt]/ /return-type-requirement[opt]/ ; /exception-requirement/: noexcept /throw-requirement/ /throw-requirement/: throw ( /type-constraint/ )
The propsed semantics of providing a throw-requirement as part of a compound-requirement are as follows.
The set of potentially-thrown exceptions is computed by applying declthrow()
query to the expression.
The requirement is satisfied if, for each type produced in the expanded pack produced by the declthrow()
query, the type satisfies the specified type-constraint.
i.e. If the throw-requirement was throw(C)
, then the requirement is satisfied if (C<declthrow(expr)> && ...)
is true
.
If we wanted the above example to mean "Any exceptions caught by a handler for type std::bad_alloc
"
then we could write the concept as follows:
template<typename T> concept foo_factory = requires(T& obj) { { obj.make_foo() } throw(std::derived_from<std::bad_alloc>) -> foo_concept;
Or, if you want to check that the only exception thrown (if any) is exactly std::bad_alloc
then
replace the use of std::derived_from
with std::same_as
.
To handle the case where you want the expression to be one of a set of types,
e.g. because the expression is called from a function that is only allowed to
emit those exception types, you can use the one_of
concept helper.
template<typename T, typename... Ts> concept one_of = (std::same_as<T, Ts> || ...); template<typename T> concept foo_factory = requires(T& obj) { { obj.make_foo() } throw(std::one_of<A, B, C>) -> foo_concept; }; void usage(foo_factory auto& factory) throw(A, B, C) { auto foo = factory.make_foo(); // ... }
It is expected this should cover most scenarios that users will want to write.
For other scenarios, expressions involving direct use of declthrow()
queries can be used
to for more complicated type-constraints.
5.10. Coroutines
Coroutines have several areas that require updates to support static exception specifications:
- Extensions to
unhandled_exception()
function of a coroutine's promise-type to allow handling static exception objects. - Rules for computing the exception specification of a coroutine with a deduced throw specificiation
The rules for computing the set of potentially thrown exceptions for co_return
statements and
co_await
and co_yield
expressions are already described above.
5.10.1. Coroutine promise object handling for static exception objects
The current behaviour of a coroutine with a given function-body is specified to be as if the function-body were replaced by:
{ /promise-type/ promise /promise-constructor-arguments/ ; try { co_await promise.initial_suspend(); /function-body/ } catch (...) { if (!initial-await-resume-called) throw; promise.unhandled_exception(); } final_suspend: co_await promise.final_suspend(); }
The only way that the promise.unhandled_exception()
object can observe the current exception object
is by either performing a dynamic rethrow expression, throw;
, or by calling std::current_exception()
.
This would force any static exception object to be copied to a dynamic exception object, which negates
the benefit of static exceptions.
Also, we may want to define a coroutine promise type such that the coroutine body itself cannot throw types other than a specified set of potentially-thrown exception types.
To support these use-cases, we can modify the above translation as follows:
{ /promise-type/ promise /promise-constructor-arguments/ ; try { co_await promise.initial_suspend(); /function-body/ } template catch (auto& ex) { // Static exception objects caught here if (!initial-await-resume-called) throw; if constexpr (can_handle_exception</promise-type/, decltype(ex)>) { promise.unhandled_exception(ex); } else if (can_handle_exception</promise-type/, std::exception_ptr>) { promise.unhandled_exception(std::make_exception_ptr(std::move(ex))); } else { promise.unhandled_exception(); } } catch (...) { // Dynamic exception objects caught here if (!initial-await-resume-called) throw; constexpr bool is_handler_reachable = sizeof...(declthrow(throw)) > 0; if constexpr (is_handler_reachable) { if constexpr (can_handle_exception</promise-type/, std::exception_ptr>) { promise.unhandled_exception(std::current_exception()); } else { promise.unhandled_exception(); } } } final_suspend: co_await promise.final_suspend(); }
Where the following exposition-only concept is defined:
template<typename P, typename E> concept can_handle_exception = requires(P& promise, E& ex) { p.unhandled_exception(ex); };
If a thrown static exception object, ex
, escapes the body of the coroutine then the exception is caught
and one of the following expressions is evaluated, tried in-order;
- Call
promise.unhandled_exception(ex)
, if that expression is valid; otherwise - Call
promise.unhandled_exception(std::current_exception())
, if that expression is valid; otherwise - Call
promise.unhandled_exception()
if that expression is valid; otherwise - The program is ill-formed
If a thrown dynamic exception object escapes the body of the coroutine then the exception is caught and one of the following expressions is evaluated, tried in-order;
- Call
promise.unhandled_exception(std::current_exception())
, if that expression is valid; otherwise - Call
promise.unhandled_exception()
, if that expression is valid; otherwise - The program is ill-formed
Some key points to note here:
- The
unhandled_exception()
method can now optionally take a parameter that accepts an lvalue-reference to the static exception object- It is a non-const reference to allow the implementation to move/mutate the exception object if desired.
- The
unhandled_exception()
method can also optionally take a parameter of typestd::exception_ptr
to refer to the current exception.- This can be potentially more efficient on some platforms than having the
unhandled_exception()
method callstd::current_exception()
since, in the case of a static exception object being thrown, it has a reference to the exception object already and so doesn't necessarily have to access the thread-local values insidestd::current_exception()
. - It also allows some freestanding implementations that do not support thread-locals and thus do not
support
std::current_exception()
to still allow type-erasing exception objects in astd::exception_ptr
.
- This can be potentially more efficient on some platforms than having the
- We only try to form a call to
promise.unhandled_exception()
if the handler is potentially-reachable. i.e. if either theawait_resume()
method on the initial-suspend awaiter object is potentially-throwing or if the function-body of the coroutine is potentially-throwing.- This differs from C++20 which always requires that a promise type has an
unhandled_exception()
object. - This also means that a coroutine promise-type that does not define any
unhandled_exception()
member function can still be used as long as the set of potentially-thrown exceptions from the function-body is the empty set.
- This differs from C++20 which always requires that a promise type has an
Together, these changes will allow a coroutine promise type to store and/or inspect an exception object without
having to first dynamically rethrow the exception, or if P2927 is adopted,
have to allocate a dynamic exception object and call eptr.try_cast<T>()
to check if it has a particular
type.
For example, say we wanted to write a coroutine type that only permitted exceptions of type A
and B
to exit the coroutine body, we could define it to have a promise-type that had the following
unhandled_exception()
member-function.
struct my_promise_type { // ... rest of promise type omitted for brevity void unhandled_exception(one_of<A, B> auto& ex) throw() { constexpr std::size_t index = std::same_as<decltype(ex), A&> ? 2 : 3; result.emplace<index>(std::move(ex)); } std::variant<std::monostate, value_type, A, B> result; };
If any other exception than A
or B
escapes the function body then the compiler will fail to
find a suitable overload for a call to promise.unhandled_exception()
and will be considered ill-formed.
This allows the author of a coroutine type to mimic the equivalent throw-specification checking that
normal functions with a throw(A, B)
specfication does.
This will allow async coroutines to efficiently propagate static-exception objects through chains of coroutines without needing to invoke any of the dynamic exception machinery of the C++ runtime.
5.10.2. Computing the exception specification of a coroutine with a deduced throw specification
When a coroutine is invoked, the call to the coroutine does not necessarily invoke the entire body of the coroutine before returning. The coroutine may suspend at the initial-suspend point and then return immediately.
Also, once the execution of the coroutine reaches a certain point during the execution of the initial-suspend
expression, any exceptions that exit the coroutine body from that point are caught and directed to the
promise object's unhandled_exception()
member-function, as described in the previous section.
This means that the set of potentially-thrown exceptions of the initial call to the coroutine is different from the set of potentially-thrown exceptions of the coroutine function-body that the user writes.
This section defines the steps for computing the deduced throw specification of a coroutine that accurately matches the set of potentially-thrown exception types of the initial invocation of a coroutine.
The steps involved in the initial call to a coroutine are:
- allocating the coroutine frame, calling
promise_type::operator new()
if present and viable, otherwise calls globaloperator new
. - copying/moving parameters of the function to the coroutine-frame
- constructing the promise object, optionally passing lvalues of the parameter copies to the promise constructor
- calling
promise.get_return_object()
- calling
promise.initial_suspend()
- calling
operator co_await()
on the returned object, if one is defined - calling
await_ready()
on the awaiter - calling
await_suspend()
on the awaiter - any implicit conversions required to convert the result of
promise.get_return_object()
to the return-type of the coroutine.
If any of these expressions throw an exception then this exception will propagate out of the initial call to the coroutine and so the deduced exception specification should include the union of the sets of potentially-thrown exception types of these expressions.
Any subsequent expressions evaluated as part of the coroutine body, from the initial-suspend-point's
await_resume()
call onwards, will be evaluated inside the try/catch around the coroutine body and
thus will be filtered through the promise.unhandled_exception()
logic described above.
As it's possible that the coroutine may not suspend at the initial suspend-point and may
execute the body of the coroutine, and thus can potentially execute any of the reachable
handlers that call promise.unhandled_exception()
. If any of these calls to promise.unhandled_exception()
exit with an exception then this exception would also propagate from the initial call to the coroutine.
Futher, if the coroutine first suspends at a suspend-point that symmetrically transfers execution to another
coroutine by returning a coroutine_handle
from await_suspend()
then if that other
coroutine has a promise-type with a potentially-throwing unhandled_exception()
method then
this could still exit with any exception thrown by that other coroutine's unhandled_exception()
.
Or that other coroutine could suspend and symmetrically transfer execution to some other coroutine
which has a throwing promise.unhandled_exception()
. This could continue transitively for an arbitrary
chain of coroutines.
This possibility for the initial call to a coroutine potentially throwing any exception that could be thrown by a coroutine that is symmetrically transferred to transitively from the initial call makes it difficult in general to deduce an accurate exception specification that is anything other than a dynamic exception specification.
It is currently impossible to tell whether a call to coroutine_handle::resume()
might throw or not, which makes it difficult to compute an accurate deduced exception specification for
any coroutine that suspends and symmetrically transfers to another coroutine.
This in turn means that we need to conservatively assume that every coroutine with a symmetric-transfer has a dynamic exception specification, which also means that we cannot permit such a coroutine to have a static exception specification.
It's possible that we can later relax this by allowing defining coroutines that have a non-throwing resume which in turn only allow symmetric transfer to other coroutines that have a non-throwing resume and which require the coroutine promise-type's unhandledexception() functions to also be non-throwing.
This direction requires more investigation before a design proposal can be made to address this issue, however.
5.11. Type traits
The standard library has a number of traits queries that let you query properties about certain operations and whether or not they are "nothrow".
5.11.1. Nothrow queries
The existing std::is_nothrow_*
traits are specified in terms of noexcept
expressions.
As the noexcept
operator will still "do the right thing" for expressions that have static
exception specifications (i.e. will return false
if there is a non-empty static exception
specification) these existing type-traits should not require any changes in specification.
It is not expected that implementations will require any changes to these traits.
5.11.2. std::is_function
and std::is_member_function_pointer
While the specification of these traits need not change, as they are just defined in terms of whether or not a given type is a function type, there may be some changes to standard library implementations which use partial specialization of all of the different forms of functions.
It is noted that libc++, libstdc++ and MSSTL all either are defined in terms of compiler
intrinsics, which would need to be updated as part of implementing this feature, or are
defined in terms of other properties of the type. For example, by computing is_function_v<T>
by checking whether the result of evaluating !is_const_v<const T> && !is_reference_v<T>
is true
.
5.11.3. Additional traits
It's possible we may want to add some additional traits for querying information about static exception specifications of certain expressions, however this has not yet been explored.
It is expected that any such traits could be added in future as and when a need for them has been established.
5.12. Freestanding
This paper proposes that the following facilites be available in conforming freestanding implementations:
- static throw expressions
- static rethrow expressions
- try/catch
template catch
handlers- functions with throw specifications
The following facilities would not be required in a freestanding implementation:
- dynamic throw expression
- dynamic rethrow expression
std::exception_ptr
std::uncaught_exceptions()
std::current_exception()
6. Prior Work
There have been a number of prior proposals and prior work that have looked at this area of exception specifications and various improvements to exceptions.
This section tries to summarise some of the history of the prior art.
6.1. Throw specifications and noexcept
- N2855 - Rvalue References and Exception Safety (2009)
- Discusses the problem with move-constructors and providing strong exception-safe guarantee, which motivates some way for the library to check whether an expression/move-ctor can throw.
- Proposes introducing the
noexcept
specifier - Originally proposed to have
noexcept
functions be ill-formed if any exception could potentially escape the function. - Proposed to use syntax
throw(...)
to mean "this function can throw any exception." This eventually becamenoexcept(false)
. - Proposes making destructors
noexcept
by default. Proposes adding a
noexcept
block that allows telling the compiler to assume that no exceptions will propagate out of this block. e.g. where exceptions are a dynamic property that is guarded against by other means.double sqrt(double); noexcept void f(double& x) { if (x > 0) { noexcept { x = sqrt(x); } // okay: if sqrt(x) throws, invokes undefined behaviour } }
- Proposes that exception specifications are deprecated
- Lack of static checking has limited usability and confused users
- Provide few benefits for compilers
- Not useful in generic code, where functions need to know whether an exception can be thrown or not, but don't know (or care) what kind of exceptions can be thrown.
- In fact, the
noexcept
specifier, along with the ability to detect whether an operation isnoexcept
via concepts, provides precisely the statically-checked exception specifications that are required in C++ code.
- N2983 Allowing Move Constructors to Throw (2009)
- Proposes use of
std::move_if_noexcept()
in move-constructors that require strong exception-safety guarantee. - Proposes new
noexcept(<expr>)
expression. - Proposes a parameterised
noexcept(<bool-expr>)
function specifier. - Suggests making destructors
noexcept
by default.
- Proposes use of
- N3051 - Deprecating exception specifications
- Talks about shortcoming of original throw-specifications
- Run-time checking
- Offers programmer no guarantees all exceptions have been handled.
std::unexpected()
does not lend itself to recovery.
- Run-time overhead
- Run-time checking requires compiler to generate extra code, which hampers optimisations.
- Unusable in generic code
- Not generally possible to know what types of exceptions may be thrown from operations on template arguments, so precise exception specification cannot be written.
- Run-time checking
- Claims that there are only two useful exception-specifications: throws-something and throws-nothing.
- Proposes deprecation of
throw()
specifications asnoexcept
covers the two useful cases. - Also proposed that
noexcept
was equivalent tothrow()
on a declaration, but differed in semantics when it was placed on the definition.
- Talks about shortcoming of original throw-specifications
- N3103 - Security impact of noexcept
- Says that a program that continues after noexcept function exits with an exception can lead to undefined/unexpected-behaviour that can be exploited by a malicious user to bypass security restrictions and/or cause denial-of-service attacks.
- Proposes mandating that the program should terminate if there is ever an unhandled exception
that is about to exit a
noexcept
function.
- N3248 - noexcept prevents library validation (2011)
- The "Lakos Rule" paper
- don't put noexcept on functions with narrow contracts
- so we can test assertions/preconditions
- The risk from overly aggressive use of noexcept specifications is that programs with hidden terminate calls are produced
- The risk of under-specifying noexcept specifications is that they become difficult to add in a later revision of the standard, as the noexcept operator becomes an observable part of the ABI.
- Long list of library changes to roll back use of noexcept in standard library.
- The "Lakos Rule" paper
- N3279 - Conservative use of noexcept in the Library (2011)
- Summary of N3248 that just describes the guidelines for use of noexcept
- N4133 - Cleanup for exception-specification and throw-expression
- Revised by N4285
- Editorial wording cleanup
- Introduces "exception specification" semantic concept separate from the grammar term exception-specification.
- Wording changes from "throws an exception" to "exits via an exception"
- N4320 - Make exception specifications be part of the type system (2014)
- Revised by N4518, N4533, P0012R0, P0012R1
- Proposes to make
noexcept
specifier part of the type of a function and function-pointer
- P0003R0 - "Removing Deprecated Dynamic Exception Specifications (2015)
- Revised by P0003R2, P0003R5 (2016)
- Proposes to remove the
throw
specifiers deprecated in C++11. - This was adopted and throw specifiers were removed in C++17.
6.2. Java Checked Exceptions
6.2.1. Background On How Exceptions work in Java
In the Java exception model all exception types inherit from the Throwable
base class.
A throw
expression can only throw objects that inhert frim Throwable
.
There are a number of important base-classes for exceptions defined in
Throwable
- Base-class for all types that can be thrownError
- Base-class for bugs (assertion failures) or more serious problems with the runtime (e.g.StackOverflowException
,OutOfMemory
). Applications are not expected to handle these errors.Exception
- Base-class for conditions that an application may need to handle.RuntimeException
- Base-class of conditions that might be raised at any time by the runtime. e.g.NullPointerException
,IndexOutOfBoundsException
, etc.
Types inheriting from Throwable
, directly or indirectly, are classified as
either "checked" exceptions, or "unchecked" exceptions.
Types inheriting from either Error
or RuntimeException
are "unchecked" exceptions.
These could potentially be raised by the runtime at any time and may not require an
explicit throw
expression in user-code.
Types inheriting from Throwable
that are not "unchecked" exceptions are classified
as "checked" exceptions.
This property of being "checked" or "unchecked" is a property of the type. A checked exception type is always a checked exception type, regardless of context.
Functions in Java have an optional throws
clause that is placed after the argument list.
For example:
public void foo() throws IOException { // ... }
If a function can potentially throw a checked exception then it must list that checked exception type, or one of its base-classes, in the throws clause, otherwise the program is ill-formed.
A function is allowed to list unchecked exception types in its throws clause, but is not required to.
6.2.2. Criticisms of Java Checked exceptions
In the interview "The trouble with checked exceptions" [Trouble] between Bruce Eckel and Anders Hejlsberg they talks about checked exceptions in Java and some of the problems they had found in practice with this design.
The interview discussed two main problems:
- versioning and evolvability of interfaces
- scalability of the design at high levels
- Versioning and evolvability of interfaces
The problem with versioning and evolvability of interfaces with Java checked exceptions arises when an API that is initially released and is in use by users later wants to evolve to add additional error-conditions for a given operation.
Consider a function that initially specifies that it throws
A
,B
orC
but then later wants to add some features that means it can fail in new ways and wants to now addD
to the list. AddingD
to the list of potentially thrown exceptions is now a breaking change, potentially requiring updating each caller to either handle that exception, or if it does not handle that error, then adding that exception to its throw specification.This in turn can require recursively updating many calling functions with new error-conditions.
To avoid having to go and update callers to add additional exceptions to their throw specifiers programmers often just listed
throws Exception
which allows it to transparently throw any type of exception - which basically defeats the purpose of checked exceptions.This is arguably already the status quo in C++, where a dynamic exception specification already just says "I can throw anything".
This allows C++ functions to later be modified to throw new exceptions without that introducing a compilation error. However, in many cases this can still be a breaking change if existing callers were not expecting this new exception to be thrown - potentially leading to bugs relating to unhandled exceptions.
One of the arguments against checked exceptions in Java was that for a lot of use-cases people don't want to handle the exceptions. If something fails then they just want to let these errors propagate up to some top-level handler and just let it log/report the error. Forcing the programmer to write a lot of boiler-plate to accurately list exception types in cases where callers do not handle specific errors does not add a lot of benefit and so writing
throws Exception
may make sense in that situation.This paper holds the position that adding a new exception to the list of potentially thrown exceptions is indeed a potentially breaking change to a function, regardless of whether this is represented in the signature of the function or not.
The set of ways in which a function can potentially fail is a key aspect of its interface and should be considered as part of its design.
The design proposed in this paper provides a few additional tools compared to Java to help manage evolution of a function's exception specifications.
It provides the ability to introspect the set of potentially-thrown exceptions for some expression via
declthrow
, allowing computation of derived exception specifications in terms of the exception specifications of other functions. This can help with allowing the signatures of calling functions to automatically adapt when callees are modified to throw additional exception types.It also provides the ability to deduce the exception specification from the body by using
throw(auto)
. This is largely just a convenience to manually writing out complexdeclthrow
expressions to compute the exception specification, but requires that the body of the function is visible to the caller.Functions that want to have an extensible set of error-conditions that can be emitted can also take the approach of using types that have extensible values, such as
std::error_code
, which can allow new error-categories and error-conditions to be represented in a single value in an extensible way.This requires calling code to manually check for each error-condition, however, and have a fallback for error-conditions it doesn't yet support. If a new error-condition should require callers to handle it then it may be better to represent this as a new exception type which callers need to handle.
- Scalability of checked exceptions
The second issue discussed in the [Trouble] interview was the use of checked exceptions in large systems.
The point was made that checked exceptions look good in the small when you are looking at leaf functions with a few exception-types and their callers, but when it comes to composing multiple high-level systems it is easy for those high-level calls to end up with 50+ exception-types being listed in each function, affecting readability of the code.
To avoid such large lists of exceptions, Java code ends up either just listing base-class exceptions like
throws Exception
, or ends up catching and ignoring the exception via acatch
with an empty handler.Note that the status quo in C++ is that any function not marked
noexcept
has an exception specification that is the equivalent of Java'sthrows Exception
, so if dynamic exceptions are suitable for your use-cases then you can continue to use them.With this paper, if you want to use throw-specifiers and static exceptions all the way up into the high-level systems then it's possible there would indeed be a need to list a large number of exceptions in such high-level functions.
There are additional tools in C++ that could help make this more manageable compared to Java, however.
- Pack aliases, proposed by [P3115R0], could be used to define shorter names for reusable
sets of exceptions thrown by each subsystem. Then high-level functions could list the
pack-alias names,
throw(foo_errors..., bar_errors..., baz_errors...)
instead of listing every exception that might be thrown by each subsystem. - Use of
throw(auto)
could be used on functions that compose multiple systems to allow the compiler to compute the exception-specification - with the tradeoff that these functions must have their definitions visible to users. Alternatively, use ofthrow
specfications usingdeclthrow()
queries could be used in the declaration if separate compilation is required. - Multiple subsystems could throw types like
std::error_code
to represent error conditions so that there is overlap between sets of potentially-thrown exceptions from different subsystems and the errors.
- Pack aliases, proposed by [P3115R0], could be used to define shorter names for reusable
sets of exceptions thrown by each subsystem. Then high-level functions could list the
pack-alias names,
6.3. Midori
Midori was an internal research/incubation project at Microsoft that explored ways of innovating the software stack, including programming languages, compilers, operating-systems, services, applications and overall programming models.
Joe Duffy has written an excellent blog series about the Midori project, which is generally a good read which I highly recommend. However, there is one article in particular on the Midori Error Model that is worth discussing here as it contains a lot of good ideas and also critique of error models of other languages, including Java's checked exceptions and C++ exceptions.
The critique of what he calls the "unchecked exceptions" model:
In this model, any function call – and sometimes any statement – can throw an exception, transferring control non-locally somewhere else. Where? Who knows. There are no annotations or type system artifacts to guide your analysis. As a result, it’s difficult for anyone to reason about a program’s state at the time of the throw, the state changes that occur while that exception is propagated up the call stack – and possibly across threads in a concurrent program – and the resulting state by the time it gets caught or goes unhandled.
TODO: Finish writing this.
6.4. Deducing exception specifications
There have been several papers that have explored the idea of deducing exception specifications of a function. This section tries to summarise them.
- N3202 - To which extent can noexcept be deduced (Stroustrop) (2010)
- Summarises N3227 "Please reconsider
noexcept
" (2010)- "almost every statement in function templates leak into the noexcept declaration"
- "a user-maintained noexcept increases the likelihood that the specification is not correct. In turn this implies (a) an increased chance that client code terminates unexpectedly, or (b) that optimization opportunities are lost. (Note tha providing correct warnings is also undecidable.)
- client code can still change (fail to compile, different runtime behaviour) if noexcept is added or removed from a library.
Questions of consistency of deducing noexcept declarations:
The idea of noexcept is to allow code to be written to take advantage of knowing that code will not throw. The key observation is that if we fail to deem a function noexcept even though it doesn’t throw the worst that can happen is that sub-optimal, but still correct, code will be executed. In other words, as long as we don’t mistakenly deem a throwing function noexcept, not serious harm is done.
- Walks through some cases where there might be inconsistencies in deducing the noexceptness
in different contexts.
- This seems to be based on the assumption that it needs to be an implicit deduction rather than explicit opt-in for each function.
- Summarises N3227 "Please reconsider
- N3207 - noexcept(auto) (2010)
- Highlights issues with implicit deduction in N3202
- Easy to accidentally introduce ODR violations
- Issues with eager function template instantiation to determine function signature/noexceptness (not SFINAE friendly).
- Proposes
noexcept(auto)
as in-between compromise between having to duplicate body in noexcept declaration, and fully implicit, which has issues. Doesn't let mutually recursive functions all have their noexcept-specification deduced
struct A { void f(int i) noexcept(auto) { if (i > 1) g(i-1); } // call to g() is ill-formed as it's noexcept specifier is incomplete. void g(int i) noexcept(auto) { if (i > 1) f(i-1); } };
Also mentions this example as being ill-formed:
template<bool> struct M; template<> struct M<true> { int large[100]; }; template<> struct M<false> { char small; }; struct B { template<bool> void maybe_throw(); template<> void maybe_throw<true>() noexcept(auto) { throw 0; } // deduced noexcept(false) template<> void maybe_throw<false>() noexcept(auto) { } // deduced noexcept void f() noexcept(auto) { maybe_throw<(sizeof(B) > 10)>(); }; M<noexcept(f())> data; // ill-formed because the noexcept-specification for f() is not yet deduced // because definition of f() is not available until end of class definition. }; // Definition of f() isn't available until the end of the class-definition, here.
Recursion is also interesting:
int f(int i) noexcept(auto) { if (i == 0) return i; else return f(i-1) + i; }
Should in theory be deducible, but ill-formed similar to mutually recursive functions
- Minutes:
- dislike of
noexcept(auto)
with SFINAE on exception-specifications; having only the latter without the former is ok - you can't overload on exception-specifications, why do you want to SFINAE on it?
- can delay determining exception-specification until the function was selected in overload resolution
Issue with non-template deduced member inside class template
template<class T1, class T2> struct C { C(C&& other) noexcept(auto) : first(std::move(other)), second(std::move(other)) { } T1 first; T2 second; }; C<int, int> x; // will instantiate the body of C::C(&&) right here
The implicit instantiation of the body of the move constructor should only be performed if ODR-used. This would make
noexcept(auto)
more like explicitly enumerating the expressions. Ideally, the noexcept specification shoud only be deduced if overload selected. May need to be deduced even if used in unevaluated operand - e.g.noexcept(f())
- Issue with debug builds w/ assertions that throw in move-ctor with deduced noexcept meaning that
std::vector
copies instead of throwing and never calls move ctor.
- dislike of
- Paper was struck from core motions in 2010-11 Batvia meeting
- I was unable to locate minutes indicating why this was.
- Presentation Notes
https://wiki.edg.com/pub/Wg21batavia/Documents/noexcept_auto.pdf
Lists two perils
- Adding print statements changes the deduced exception-specification (e.g. using cout)
- Can workaround by adding try/catch.
- Adding assertions can change deduced exception-specification.
- An issue if assertion macro throws.
- Not an issue if it terminates.
- Adding print statements changes the deduced exception-specification (e.g. using cout)
- Minutes:
- Highlights issues with implicit deduction in N3202
- N3227 - Please reconsider noexcept (2010)
- Draft: https://wiki.edg.com/pub/Wg21batavia/EvolutionWorkingGroup/reconsider_noexcept.html
- https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2010/n3227.html
- Suggests that we should support deducing
noexcept
-ness of a function - Talked about avoiding use of flow-analysis to determine noexceptness of a function (undecidable/hard problem - Rice's theorem).
- Concerns about inconsistencies across compiler with flow-analysis leading to some programs being well-formed on some compilers but ill-formed on others based on whether they deduced the noexcept the same way.
- Not a problem if you ignore flow-analysis and just look at whether there are any potentially-throwing expressions that do not catch exceptions.
- Has some expected objections and some counter-arguments
- N3204 - Deducing "noexcept" for destructors (2010)
- Short paper
- Wording for default exception-specification user-defined destructors to be the same as for an implicit destructor. i.e. deduced from the exception-specifications of the data-members/base-classes.
- N3386 - return type deduction for normal functions (2012)
- N3638 - Return-type deduction for normal functions (2013)
- maybe relevant to
noexcept(auto)
deduction? - talks about deduced return types for recursive functions
- works if there is a prior recursion-breaking return-statement by the time we get to the use of the recursive call.
- Talks about instantiation of function templates even if they are not odr-used
e.g. if you use it in a non-evaluated context such as
decltype(f(1))
. - Proposes adding
decltype(auto)
as well.
- N4473 -
noexcept(auto)
, again (2015) (Voutilainen)- Tries to open up the discussion about adding
noexcept(auto)
again. - Not a lot of detail here, other than this is something that is oft-requested, and is a big pain point for some people.
- Minutes
- Jason M had some proposed wording (not found/attached)
- Had consensus in EWG in Lenexa
- Tries to open up the discussion about adding
- P0133R0 - Putting
noexcept(auto)
on hold, again (2015) (Voutilainen)- Abandoned
noexcept(auto)
upon realising that you still need to duplicate the expressions for SFINAE cases.- Concern should be reduced now that we have concepts as a nicer syntax for SFINAE
- Abandoned
6.5. Faster Exceptions
A number of papers have explored the concept of improving the performance of exceptions.
- N4049 - 0-overhead-principle violations in exception handling (2014)
- N4234 - 0-overhead-principle violations in exception handling - part 2 (2014)
- low-latency SG
- N4456 - Towards improved support for games, graphics, real-time, low latency, embedded systems
- Mentions wanting guaranteed support for -fno-exceptions, -fno-rtti No detail on why/motivation/issues.
- N4456 - Towards improved support for games, graphics, real-time, low latency, embedded systems
- P0709 Zero-overhead deterministic exceptions (Herb Sutter)
- Intro sections have a lot of good motivation for fixing exceptions.
- Makes the claim that the overheads of dynamic exceptions cannot be avoided by a better implementation strategy.
- Section 4.1 contains EWG polls indicating that exception-handling is something that they want to improve
- Marking function with
throws
turns return type into a union of R + E with a bool flag to indicate whether it's a result/error.- Basically baking std::expected into the language
- Requires a new function-call ABI.
throws(cond)
can be a bool value, or anexcept_t
value (noexcept, staticexcept, dynamicexcept)- Proposes a new
std::error
type- Tries to map error values to exception type when propagating out of a function. e.g. std::errc::ENOMEM <-> std::badalloc
- For types where there is no obvious standard mapping, it would just wrap an
exception_ptr
- This mapping seems like it would be complicated, and difficult to specify/extend.
- At call sites (that propagate or handle an error), a potential downside of the if-error-goto-handler
implementation model is that it injects branches that can interfere with optimizations.
- Claims that you can still use table-based handling. But not sure what this would look like.
- Catching
std::error
then requires you to do further conditional branches to determine which of the many possible error-conditions it might be.- The callee knew which error they returned with, yet this information has
been type-erased in the
std::error
object, and the type information now needs to be extracted again.
- The callee knew which error they returned with, yet this information has
been type-erased in the
- try expression / statement
- Require every potentially throwing expressions/statement inside a
throws
function to be covered by atry
expression. - Also proposes a
catch(E) { ... }
without an openingtry { ... }
block. Instead, could havetry
expressions scattered throughout code between enclosing open-brace andcatch
clause.- This would have issues with the programmer determining what variables are in-scope inside
the
catch
block. Every variable whose scope beings after the firsttry
expression would potentially not exist and not be available in thecatch
block.
- This would have issues with the programmer determining what variables are in-scope inside
the
- Require every potentially throwing expressions/statement inside a
- Suggests adding a
throws{E}
syntax for specifying a single error type that would be thrown instead ofstd::error
.
7. Design Discussion
7.1. Code Evolution
TODO:
- How do we evolve a function to add new exception types?
- How do we evolve a function to remove exception types from throw-specification?
- What about changing from dynamic to static?
- Or from static to dynamic?
7.2. A new calling convention / function-type
TODO: Describe impacts of adding another category of function-types.
- specialization of function-traits
- function-object wrappers
- type-erasing facilities
TODO: Discuss trade-offs of allowing cast from throw(T1, T2)
function-pointers to
throw(...)
/ noexcept(false)
function-pointers
- ABI / calling-convention impacts
- thunks
7.3. Changing the implementation changes the calling convention
TODO: Expand on these points in more detail
- this was a problem with the paper [P1235R0] "implicit
constexpr
"
counter points
- this is already a problem for things with deduced return-type
- mangled names don't include the return type
std::execution
/ other expression template libraries can change the return-type of a function if they change the implementation (i.e. the type of the expression-object being returned)
- lots of template code that wants to be
noexcept
transparent already has to maintain this by hand - duplicating the body in the noexcept clause- if the implementation changes then so does the noexcept clause
- this is not eliminating something
- lots of code is currently specified as "expression equivalent to", which
implies having the equivalent noexceptness.
- using
throw(auto)
is a more direct way of expressing that the exception specification is "expression equivalent to" the function body
- using
7.4. How accurate does the potentially-thrown exception type calculation need to be?
This paper currently proposes rules for computing the set of potentially-thrown exception types in a somewhat conservative way and might include exceptions that a deeper analysis would deduce are unreachable.
It considers only control-flow structure of the function-body and function-signatures of called functions.
It does not involve or require any reasoning about whether or not conditions are always true or false except for
those conditions required to be constant evaluated. i.e. if constexpr
.
There are a couple of decisions that were made in the design described in this paper which should be considered further:
- whether to consider all identifier labels as reachable or whether to only consider an identifier
label reachable if there is a corresponding
goto
statement that targets the label that is also reachable. whether to consider sub-expressions that are sequenced after other interrupted-flow expressions within the same statement as unreachable and thus not contributing to the set of potentially thrown exceptions. For example: What should the deduced exception specification of
f()
andg()
be?void f() throw(auto) { // deduces to throw(A) or throw(A, B) (throw A{}, throw B{}); // throw B{} is unreachable } [[noreturn]] int always_throws() throw(A); void takes_an_int(int x) throw(B); void g() throw(auto) { // deduces to throw(A) or throw(A, B) takes_an_int(never_returns()); // takes_an_int() is never invoked }
7.5. Permission to throw types derived from std::bad_alloc
/ std::bad_cast
The definition of global operator new
and dynamic_cast
facilities permit implementations to
throw exceptions that will be caught by a handler for std::bad_alloc
or std::bad_cast
.
This allows implementations to throw types that are derived from these types.
If we want to allow these expressions to have static exception specifications then we would
need to constrain these implementations to throw types that were exactly std::bad_alloc
or std::bad_cast
. However, doing so is a potential breaking change for any implementations
that are throwing some type derived from these types.
And whilst portable C++ code should not be relying on catching a particular exception-type
derived from these classes, implementations could potentially define the semantics for their
implementations of these facilities to throw vendor::bad_alloc
or vendor::bad_cast
.
Having these expressions actually have a static throw-specification would also introduce
a dependency of those expressions on the corresponding library-defined types, which
could be problematic for some implementations. There is presumably already this dependency,
however, since the compiler needs to generate code to throw std::bad_cast
for dynamic_cast
expressions.
7.6. Avoiding dependencies on thread-locals
The design proposed by this paper allows eliminating some of the overhead of dynamic exceptions; in particular the dynamic allocation, reference-counting and run-time type-information needed to perform dynamic matching of handlers.
However, there is still some overhead that remains and that is the dependency on thread-local
variables needed to support std::uncaught_exceptions()
, std::current_exception()
and
dynamic-rethrow expressions.
As mentioned in the "Error Handling" section of Ben Craig's paper, P2268R0 "Freestanding Roadmap", there are some freestanding environments that do not support thread-locals. P2268 suggests some potential directions we could take to try to eliminate the dependency on thread-locals for freestanding environments and this paper discusses them further here.
7.6.1. Avoiding overhead implied by std::current_exception()
and throw;
The std::current_exception()
function and throw;
expression can be called from any
function dynamically within the scope of a handler - they don't need to be called from
code lexically within the handler.
However, this means that, because they could be called from an arbitrary number of different contexts, they don't know anything about the type of the exception objects that might be returned or rethrown - so they necessarily need to deal with dynamic exception objects.
These facilities also rely on accessing thread-local state to be able to find the current exception object.
When a thrown static exception object is caught by a handler, the exception object will be living
on the stack. However, if we want a call to std::current_exception()
to be able to return
an exception_ptr
that references a dynamic exception object that is a copy of the stack-allocated
object on the stack, then the handler needs to register the address of the static exception object
along with some basic type-information and information about how to copy that object to a new location.
This then allows a subsequent call to std::current_exception()
within the scope of the handler
to find the object, allocate new storage for a dynamic-exception-object of the right size, copy
the static-exception-object to the dynamic-exception-object and then return an exception_ptr
referencing that dynamic-exception-object.
Then on exit from the handler, the handler needs to deregister this information from the thread-local.
All of this generated code for registering/unregistering the static-exception-object adds to the cost of handling an exception, both in terms of execution and in terms of binary size.
If the compiler can prove that there will be no calls to std::current_exception()
or
dynamic rethrow it could theoretically optimize out the registration/deregistration logic.
But this is difficult to prove as soon as the handler contains any calls to functions
that weren't inlined.
However, if we were to annotate the handler with some kind of marker that told the
compiler that within the dynamic scope of this handler there will be no calls to
std::current_exception()
or any dynamic-rethrow expressions, then the compiler
could avoid needing to register the information about the exception object.
- Using
catch static
to reduce overhead of dynamic exception facilities
We could consider adding some kind of annotation/specifier to a handler that allows the author to indicate that they will not be making use of the dynamic exception facilities within the handler and that the compiler can optimise on this basis.
For example:
try { something(); } catch static (const X& ex) { // user promises not to call std::current_exception() or dynamic-rethrow in here }
Annotating a handler thusly signals to the compiler that, while this handler is the active handler, that:
- evaluating a call to
std::current_exception()
will return an unspecifiedstd::exception_ptr
. It is unspecified since if the handler has caught a dynamic exception object then there may not be any overhead in registering the exception object if the act of throwing the exception already registered it. - evaluating a dynamic-rethrow expression either calls
std::terminate()
or is undefined-behaviour (which one is TBD).
Note that the handler can still rethrow the exception by evaluating a
throw;
expression that is lexically associated with the handler - the compiler knows where to go to get the exception object in this case. - evaluating a call to
- Catching
std::exception_ptr
as an alternative to callingstd::current_exception()
Another avenue to explore that might reduce the need for thread-local access is to allow the user to write:
try { something(); } catch /* static */ (std::exception_ptr e) { // stash 'e' somewhere }
as an alternative to writing:
try { something(); } catch (...) { auto e = std::current_exception(); // stash 'e' somewhere }
The alternative formulation potentially allows the compiler to directly construct the
exception_ptr
and pass it to the handler without needing to access the thread-local state.If the thrown exception object was a dynamic-exception-object then it might not be any more efficient and so might lower to calling
std::current_exception()
and just passing the result.If the thrown exception object was a static-exception-object, however, then the compiler could directly construct a new dynamic-exception-object from the static-exception-object and pass the pointer withouth having to access the thread-locals. e.g. as if by
std::make_exception_ptr(obj)
.On the throwing side, we could consider making a
throw expr
expression where argument is a possibly-cv-qualifiedstd::exception_ptr
as equivalent to callingstd::rethrow_exception()
.This would have great benefits for generic programming if we could treat
std::exception_ptr
as if it were just another exception object that could be thrown/caught.The major challenge to a syntax like this, however, is that it would be a breaking change. There may be code-bases out there that are intentionally throwing an exception of type
std::exception_ptr
and catchingstd::exception_ptr
rather than usingstd::rethrow_exception()
to rethrow the contained dynamic-exception-object.Adoping the semantics described above would mean their
catch(std::exception_ptr)
handler would now start matching all exceptions, not just a thrownstd::exception_ptr
.Also, if we decided that, like
catch(...)
, that acatch(std::exception_ptr)
must appear as the last handler in the sequence of handlers for a try-block, then it may cause existing code to become ill-formed ifcatch(std::exception_ptr)
does not appear as the last handler.A quick search in https://codesearch.isocpp.org/ showed 2 matches for handlers that were catching a
std::exception_ptr
.One was a unit-test1 in cpprestsdk that was making sure that when an
exception_ptr
was being rethrown by an async task library that it was usingstd::rethrow_exception(eptr)
instead ofthrow eptr
and was checking that thecatch(std::exception_ptr)
handler was not being entered when the exception was rethrown.The other was similarly for an async framework in the Ice project which was intentionally catching an
exception_ptr
and then immediately callingstd::rethrow_exception()
to propagate the exception. Upon further investigation, I found an issue2 that indicated a desire to move away from this and a search of the latest version of the codebase yielded no usage.A search of github yielded one other hit:
- heisenberg-go hnswwrapper.cc
This looks like it might be a bug and may have intended to write
catch(const std::exception&)
as I could not find anythrow
expressions that were throwing anexception_ptr
in the project.
Usage of this pattern in open-source projects does not seem to be wide-spread. While not representative of code-bases at large, it may be worth considering a syntax like this to both allow freestanding platforms that don't have access to thread-locals an alternative API for obtaining the current exception an an
exception_ptr
and also to simplify generic code that needs to deal both dynamic and generic exceptions by giving them similar forms. - heisenberg-go hnswwrapper.cc
This looks like it might be a bug and may have intended to write
7.6.2. Avoiding overhead implied by std::unhandled_exceptions()
The std::unhandled_exceptions()
function reports a count of the number of in-flight
exceptions on the current thread - ones for which a throw
expression has been evaluated
but for which the corresponding handler has not yet been activated.
Multiple exceptions can be in-flight at once in the case that a destructor calls some function that throws an exception which is then caught within the scope of the destructor.
This is used by facilities such as scope_success
or scope_failure
scope-guard objects
that conditionally execute some logic if the destructor is called during the normal scope
exit path or during exception unwind. These classes capture the number of uncaught exceptions
in their constructor and then compare that number to the number of uncaught exceptions in
their destructor decide whether to execute the logic based on the result of that comparison.
For example: A scopefailure can be used to roll back a transaction if something fails
{ scope_failure cancel_on_fail{[&] { transaction.RollBack(); }}; for (auto& x : items) { transaction.execute(generate_sql(x)); // might throw } }
To make this work, however, the C++ runtime needs to store a thread-local count of the
number of in-flight exceptions and then increment it whenever executing a throw
expression
and decrement it whenever a handler is activated.
For example, on the Itanium ABI, there is the following interface relevant to std::uncaught_exceptions()
struct __cxa_eh_globals { __cxa_exception* caughtExceptions; unsigned int uncaughtExceptions; }; // Gets a pointer to the current thread's exception state __cxa_eh_globals* __cxa_get_globals(void);
When an exception is thrown, the compiler needs to ensure that something equivalent to the following:
++__cxa_get_globals()->uncaughtExceptions;
and then in the handler (immediately after initializing any exception-object parameter)
--__cxa_get_globals()->uncaughtExceptions;
Usually the increment/decrement is wrapped up inside the __cxa_throw()
and __cxa_begin_catch()
functions.
The use of a thread-local here is a somewhat indirect mechanism to detect which context the compiler is calling the destructor and has known deficiencies when used in conjunction with coroutines or fibers which can suspend and then resume in a different context, potentially with a different number of in-flight exceptions.
One direction to explore, which has been previously hinted at in both P0709 and P2268 (and possibly others) is to allow either having two destructors, one to execute during exception unwind and another to execute during normal scope exits, or to pass a flag that indicates the disposition in which the destructor is being called.
For example, the strawman syntax of a destructor that takes a parameter of type std::unepxect_t
:
template<std::invocable F> struct scope_failure { F func; // Normal destruction ~scope_failure() {} // Unwind destruction ~scope_failure(std::unexpect_t) { func(); } }; template<std::invocable F> scope_failure(F) -> scope_failure<F>;
The compiler already knows in what contexts it is calling the destructors - the calls to destructors during unwind is typically separate from the code that calls destructors during normal scope exit, and so it would be straight-forward for compilers to call a different overload in exceptional contexts.
While this would allow classes like scope_success
and scope_failure
to be implmented
without a dependency on thread-locals, it would be a fairly large design change and
potentially require a lot of viral changes to standard library types to support.
More exploration is needed to determine the viability of this option.
In the meantime, it's possible that freestanding platforms that do not support thread-locals
could provide basic exception support but without providing std::uncaught_exceptions()
.
7.7. Calling C functions
When importing APIs from C libraries which were not written with consumption by C++ in-mind,
C function declarations will generally not have any exception-specification listed on them,
thus defaulting the exception-specification to noexcept(false)
.
When a C++ program calls such functions, the compiler must assume that they could potentially throw an exception and so may end up generating additional code to handle the case that these functions throw an exception, even if the vast majority of these functions will never emit an exception.
When you call these C functions from a noexcept
function then the worst you get is some
suboptimal code-gen that bloats your binary and maybe misses out on some optimizations.
However, within a function that has a static-exception-specification, calling such functions
would require surrounding the call in a try { } catch (...) { }
to ensure that all exceptions
are handled.
For example:
extern "C" int some_c_api(void*); void some_caller() throw(std::error_code) { // ... void* handle = /* ... */; int result = some_c_api(handle); // ERROR: some_c_api() may throw a dynamic exception which is not handled locally if (result < 0) { throw std::error_code{errno, std::system_category()} } // ... }
This is helpful that the compiler can identify places where control flow may cause an unhandled exception to be emitted, however in this case may be an example of a false-positive.
The code can be amended either by adding a try/catch(...)
around the call,
with either a std::unreachable()
or std::terminate()
call, although this
still ends up generating sub-optimal code.
Alternatively, if the developer knows that the function will not throw, the function-pointer
can be reinterpret_cast
to an equivalent function-pointer annotated with noexcept
and then
call through that function-pointer. While non-portable, this will work on most platforms and
gives better codegen (without code for handlers).
For example: Given the following helpers
template<typename T> struct noexcept_cast_helper; template<typename R, typename... As> struct noexcept_cast_helper<R(*)(As...)> { using type = R(*)(As...) noexcept; }; template<typename F> [[clang::always_inline]] auto noexcept_cast(F func) noexcept { return reinterpret_cast<typename noexcept_cast_helper<F>::type>(func); }
you can write the following (which is now well-formed):
void some_caller() throw(std::error_code) { // ... void* handle = /* ... */; int result = noexcept_cast(some_c_api)(handle); // OK: This is now a call to a noexcept function. if (result < 0) { throw std::error_code{errno, std::system_category()} } // ... }
Another direction suggested by Ben Craig was to add something similar to the extern "C"
block to allow
declaring that all functions in a given block are noexcept
.
For example: The following would
extern "C" { noexcept { #include "the_c_api.h" } }
One concern with this approach would be that it could transitively apply
the noexcept
qualifier to all headers included by "thecapi.h", not necessarily
just the APIs directly declared by that header.
Another potential direction we could explore is the use of try
expressions.
For example: A try/else
expression that evaluates a first sub-expression and if that exits with an exception then evaluates a second sub-expression as a fallback value.
int result = try some_c_api(handle) else std::terminate(); int result = try some_c_api(handle) else nothrow_unreachable(); // compiler optimizes this to be as if the function was noexcept. int result = try some_c_api(handle) else -1;
This could help simplify calling C APIs that have not been marked non-throwing but for which the programmer knows it cannot throw.
Note that this paper does not propose either of these directions at this time, only list them as potential directions for further investigation.
7.8. Interaction with Contracts
The contracts proposal [P2900R6] adds the ability to add pre-conditions and post-conditions to function declarations
and also allows adding contract_assert()
expressions.
When a contract that is checked is violated, a violation handler is invoked.
As currently specified, implementations can permit the volation handler to throw an exception.
This means that contract_assert()
statements can potentially throw exceptions.
Whether the violation handler is a throwing handler or not is not necessarily known
by the compiler at the time that a contract_assert()
statement is compiled, and the
current contracts facility provides no way of detecting whether or not a contract-assertion
is potentially throwing.
Further, throwing violation handlers are not restricted to throwing an exception of a particular type,
so if treating a contract_assert()
statement as potentially-throwing then we would need to consider
it as potentially-throwing any exception type - i.e. as if it had a dynamic exception specification.
However, one stated design principle of the contracts facility, listed in [P2900R6]:
Concepts do not see Contracts - if the mere presence of a contract assertion, independent of the predicate within that assertion, on a function or in a block of code would change when the satisfiability of a concept then a contained program could be substantially changed by simply using contracts in such a way. Therefore, we remove the ability to do this. As a corollary, the addition or removal of a contract assertion must not change the result of SFINAE, the result of overload resolution, the result of
noexcept
operator, or which branch is selected by anif constexpr
.
However, this principle, in combination with potentially-throwing contract violation handlers, seems to be incompatible with several of the features proposed in this paper.
The design in P2900R6 makes contract_assert
a statement so that you cannot ask the question
whether it is noexcept
or not as a noexcept
expression requires its operand to also be
an expression. This defers needing to answer the question of whether or not contract_assert
is potentially throwing.
However, this question needs to be answered if we wanted to be able to use contracts in combination with the features proposed in this paper - something I think we should support
For example: What is the deduced exception specification of these functions?
int example1(int x) throw(auto) { contract_assert(x != 0); return 42 / x; } int example2(int x) throw(auto) pre(x != 0) { return 42 / x; }
For example: Are the following functions well-formed?
void example3(int x) throw() { contract_assert(x != 0); // ill-formed if potentially-throwing } void example4(int x) throw() pre(x != 0) { // does a function with a precondition have a potentially-throwing function-body? }
For example: Is the call to set_error()
below a discarded statement or not?
void example5(int x) throw() { try { contract_assert(x != 0); } catch (...) { constexpr bool is_reachable = sizeof...(declthrow(throw)) > 0; if constexpr (is_reachable) { set_error(std::current_exception()); } } }
If we follow the principle that introducing contracts should not change the satisfiability
of a concept then, for the purposes of computing the set of potentially-thrown exception types
of a contract_assert
statement, we would need to treat it as if it were non-throwing.
Otherwise, adding a contract_assert
might change the signature of a function with a deduced
exception specification, or might make a function with a static exception specification ill-formed.
However, this then seems at odds with the desire for people to make use of throwing violation handlers to recover from program bugs, as code surrounding contract-assertions is going to assume that they do not throw - which they don't, for a correct program which does not violate any of its contracts.
I think this conflict helps us get to the core question of what does an exception-specification mean in the presence of contract checks that might fail.
If I mark a function as noexcept
, does that mean that it guarantees that calling it will not
ever throw an exception?
Or does it mean it will not throw for an evaluation that does not violate any contract checks?
I don't have any solutions to propose here at this stage, but I would like to try to find a solution that allows both static exceptions and contracts to work well together.
7.9. Standard Library Impacts
7.9.1. Whether implementations add noexcept
to "Throws: Nothing" functions can now affect well-formedness of a program
A function in the standard library specified as "Throws: Nothing" but for which there is a pre-condition
are often specified as being noexcept(false)
functions. The main rationale here is to allow for the
possibility that an implementation might want to throw an exception if the pre-condition is violated,
since violating a pre-condition is undefined-behaviour the implementation is technically free to do
anything, including throw an exception.
However, implementations that do not throw exceptions on pre-condition violations and instead
choose to terminate/abort are currently free to add noexcept
to function declarations wherever they
feel is appropriate.
This permission is granted to them by [res.on.exception.handling] p5 which says
An implementation may strengthen the exception specification for a non-virtual function by adding a non-throwing exception specification.
For example MS-STL, libc++ and libstdc++ all declare std::vector::operator[]
as noexcept
despite it
having a pre-condition that the index is less-than size()
. It is worth noting, however, the same
standard library implementations do not mark std::vector<bool>::operator[]
with noexcept
.
With this paper and the ability to either declare a function as having a deduced throw-specification
or to call these functions from a context where the the exception-specification is checked, the
implementation differences on noexcept
qualification of these member functions can potentially
affect the well-formedness of the program.
For example: The following function would be well-formed on current major standard library implementations
which mark std::vector::operator[]
as noexcept
, but might be ill-formed on other conforming implementations
that do not mark std::vector::operator[]
as noexcept
, and thus would not be portable C++ code.
void example(const std::vector<int>& v) throw() { int sum = 0; for (size_t i = 0; i < v.size(); ++i) { sum += v[i]; } return sum; }
A portable implementation would either need to declare this function as noexcept
and allow
the possibility of a runtime std::terminate()
call if the implementation of example()
happens to contain a bug that violates a pre-condition and the implementation of vector::operator[]
handles this by throwing an exception.
Or you would need to surround the body in a function-try-block that calls either std::terminate()
or, if you are confident in the correctness of the code, calls std::unreachable()
in the handler.
For example:
void example2(const std::vector<int>& v) throw() try { int sum = 0; for (size_t i = 0; i < v.size(); ++i) { sum += v[i]; } return sum; } catch (...) { std::terminate(); }
Of course, this is then just semantically equivalent to declaring the function noexcept
.
Ideally we'd be able to declare that this handler was unreachable by calling std::unreachable()
instead, however std::unreachable()
is not specified as noexcept
and so might again throw
an exception which would violate the throw()
specifier checking.
The std::terminate()
function, on the other hand, is specified to be noexcept
,
and so can be called in this case to portably indicate that the function does not exit
with an exception.
Alternatively, the user can define a new nothrow_unreachable()
function as follows and call
that instead of std::terminate()
:
[[noreturn]] void nothrow_unreachable() noexcept { std::unreachable(); }
This would allow the compiler to assume that the handler is unreachable in modes where
calling std::unreachable()
does not throw an exception, and be equivalent to
std::terminate()
in modes where it does throw an exception.
7.9.2. Should implementations be allowed to strengthen "Throws: something" exception specification to a static exception specification?
Similar to the case where a function that is specified as "Throws: Nothing", there are also functions that are specified as "Throws: <some-concrete-list-of-types>".
For example:
std::vector::at(size_t n)
is specified to throwstd::out_of_range
ifn >= size()
.std::optional::value()
is specified to throwstd::bad_optional_access
if the optional does not contain a value.std::function::operator()
is specified to throw eitherstd::bad_function_call
or any exception thrown by the target object.
There are also many other cases where a function is specified to throw whatever some other expression throws.
In the same vein as the "Throws: Nothing" rule, should we also grant standard library implementations
permission to declare functions that can throw exceptions to have a narrower exception specification
than noexcept(false)
. i.e. to have a static-exception-specification.
For example, should we allow conforming implementations to declare std::vector::at()
with a
throw(std::out_of_range)
exception specifier?
This would greatly assist with calling such functions from a function with a checked exception specification,
which would otherwise require surrounding such calls in try/catch(...)
, and would also allow those functions
to have deterministic performance for the exceptional code-paths.
For example: A portable implementation would need to write an extra catch(...)
to handle any exceptions that are not specified
std::uint32_t walk_graph(const std::vector<std::optional<std::uint32_t>>& v, std::uint32_t start_at) throw() { std::uint32_t current = start_at; try { while (true) { current = v.at(current).value(); } } catch (std::out_of_range) {} catch (std::bad_optional_access) [} catch (...) { [] noexcept { std::unreachable(); }(); } // This line would be unnecessary with static exception specifications return current; }
Having a static exception specification for functions like std::vector::at()
and std::optional::value()
would actually make them more useful as their performance could now be guaranteed to be on the order of
normal control flow you could write yourself with extra if-checks, rather than the many thousands of cycles
that the current dynamic exception meachnisms would take.
However, user code written assuming such narrow exception declarations were placed on the standard library functions would, again, be non-portable, because other implementations might not put the tighter exception declarations on their implementations.
Note that specifying that these functions must have have a static exception specification would likely be an ABI break for many implemenations, so we are unlikely to be able to require implementations to do that.
7.9.3. Usage of the standard library in functions with static throw-specifications will be painful without more noexcept
Trying to use standard library functions with pre-conditions, and thus a noexcept(false)
exception specification, or even library functions with a Throws: some-type exception
specification from within a function with a static exception specification is going to
require callers to surround the calls in a try/catch(…) to ensure that the exception-specification
is not violated.
Thus, if this paper is adopted then there will be some pressure on vendors and on the
standard library to provide APIs with narrower exception specifications for these
kinds of functions to allow them to be used in contexts with static exception
specifications without having to write catch(...)
in any handlers.
7.9.4. Function-wrapper types
std::function
This type is parameterised by a function signature of form
R(Args...)
.This class is undefined for function signatures of the form
R(Args...) noexcept
, largely because doing so would have no effecton the resulting class.The
std::function::operator()
call can throw thestd::bad_function_call
exception if invoked on astd::function
instance that is empty and so theoperator()
could not be markednoexcept
anyway.The existing
std::function
class could be used as-is to wrap functions and function-objects whose call-operator as a non-empty static-exception specification. Although we would need to extend the function-pointer casting rules to allow casting fromvoid(*)() throw(X)
tovoid(*)() throw(...)
.We could also consider defining an additional partial-specialization of
std::function
that allowed specifying a function-signature with a static-exception specification and then computed a static-exception specification foroperator()
that addedstd::bad_function_call
to that exception-list.std::copyable_function
andstd::move_only_function
These types are parameterised on a function-type that inclues the exception-specification.
We would need to add additional partial specializations for cases where the function type passed as the first template argument has a non-empty static exception specification to have this apply that exception specification to its
operator()
, and also to require that the wrapped callable has a compatible exception specification.std::reference_wrapper
This type could have its
operator()
modified to have a deduced exception specification so that if the referenced object has anoperator()
with a static exception specification then thereference_wrapper::operator()
also has an equivalent exception specification.- Function objects
The following types could be extended to have call operators annotated with
throw(auto)
to have them deduce the exception specification based on the exception specification of the underlying operator implementation.Arithmetic function objects
std::plus
std::minus
std::multiplies
std::divides
std::modulus
std::negate
Comparison function object
std::less
std::greater
std::less_equal
std::greater_equal
std::equal_to
std::not_equal_to
std::compare_three_way
Logical Operations
std::logical_and
std::logical_or
std::logical_not
Bitwise Operations
std::bit_and
std::bit_or
std::bit_xor
std::bit_not
7.9.5. Algorithms
We could also consider adapting algorithms that take lambdas/function-objects as parameters that the user provides as being transparen to exceptions such that if the user-provided iterators/ranges and function-objects have static exception specifications then the algorithm itself has a static exception specification.
At the moment, algorithms tend to have noexcept(false)
exception specifications and so would
require a try/catch(...)
around calls to them if used inside a function with a static exception
specification.
8. Implementation Strategies
This section describes some potential implementation strategies that could be taken for implementing the semantics described above.
Note that this is just a sketch that talks through some of the potential challenges and techniques that could be used. It has not yet been implemented in a compiler, so treat this section as speculative until implementation experience has been gained.
8.1. Multiple return-paths/return-addresses
One of the intents of the semantics described above for functions with static-exception specifications is to model them effectively as a function that can either return a value object for the success return-path or can return an error object for each of a set of possible exception-types listed in the exception specification.
With a normal function call there is usually a single return-address that the callee must transfer control to when the callee completes.
The return-address is usually passed from the caller to the callee on the stack. Many hardware architectures have dedicated hardware instructions for pushing the address of the next instruction onto the stack before then jumping to the entry-point of the callee. When returning there is also often hardware support for popping this return address from the stack and then jumping to the return address.
If the callee is potentially throwing then there is, logically, a second potential return-address that the callee might transfer execution to when it exits - the exception unwind path. The unwind path is the path that, logically, exits scopes, destroying automatic-storage duration variables, until it reaches a matching handler and then transfers execution to that handler.
To avoid overhead in having to pass multiple return-addresses to a function and to take advantage of the dedicated hardware instructions for passing a single return-address and returning to that return-address upon completion, implementations often use auxiliary data-structures stored within the binary that allow looking up the exceptional return-address for the current function based on the return-address for the normal path.
With functions with static exception specifications we have a function that has 1 normal return path and zero or more possible exceptional return-paths.
If the caller knows what all of the possible exception types that might be thrown are, it can statically compute what the unwind path should be for each potential exception type thrown. e.g. whether it is handled locally within the function or is propagated through to its caller.
If the caller could pass, somehow, a list of return-addresses to the callee, one for each possible return-path, then the callee can then return directly to the return-address corresponding to either the normal return-path or a particular exception-type return-path.
However, we want to do so without increasing substantially the cost of making a normal call. For example, if we were to have to generate assembly code to push 5 separate return-addresses onto the stack for a function call to a function that could thrown 4 different exception types then this would increase the cost of making the function call and the stack-space used by the calling convention.
Also, many CPUs optimise their branch predictor for call/ret instructions to maintain a stack of recent return-addresses pushed onto the stack so that it can predict the target that a ret instruction will jump to, assuming that for every call instruction there is eventually a corresponding ret instruction. Any scheme that doesn't maintain balanced call/ret instructions can result in a misaligned return-address branch predictor cache which can significantly increase the number of subsequent branch mispredictions.
One approach to implementing this with a lower cost for a function call would be to provide a jump-table of possible return-addresses and to instead push a pointer to the jump-table as part of the call instruction.
On x86/x86-64 architectures this can be done relatively cheaply in one of two ways without increasing the amount of stack-space and would only require one additional instruction to be executed on the normal return path compared to a normal function-call.
8.1.1. Inline Jump Tables
The first option would be to immediately follow a call
instruction with a series of
5-byte jmp
instructions that jump to the relevant block for handling the corresponding
return-path. The first jmp instruction would jump to the code for the normal-return path,
and subsequent jmp instructions for each exception path.
This calling convention is backwards compatible with existing calling conventions.
The caller issues a call
instruction as per normal and the callee when it returns a value
just issues a ret
instruction to return, which transfers to the first jmp
instruction
which then unconditionally jumps to the code that handles this path.
The additional direct jmp
instruction should be reasonably able to be predicted/pipelined
by a CPU so shouldn't add much in the way of additional latency (NOTE: to be confirmed with data).
For example, given the following code:
int divide(int num, int den) throw(divide_by_zero, overflow) { if (den == 0) throw divide_by_zero{}; if (den == -1 && num == INT_MIN) throw overflow{}; return num/den; } int x, y; int example() { try { int result = divide(x, y); return 2 * result; } catch (divide_by_zero e) { return -1; } catch (overflow) { return -2; } }
The caller could be translated into assembly similar to the following:
x: .data 4 y: .data 4 example: mov edi, dword ptr [x] ; Load 'x' into register for param 1 mov esi, dword ptr [y] ; Load 'y' into register for param 2 call divide ; Jump to 'divide' and push address of .JUMP_TABLE onto stack .JUMP_TABLE: ; Each entry is a 'JMP rel32' encoded instruction which is 5 bytes jmp .SUCCESS ; entry[0] - success path jmp .ERR_0 ; entry[1] - error path for divide_by_zero jmp .ERR_1 ; entry[2] - error path for overflow .SUCCESS: # result is in eax mul eax, 2 ret .ERR_0: mov eax, -1 ret .ERR_1: mov eax, -2 ret
Note that for simplicity, this is assuming that the dividebyzero and overflow types are empty, trivially destructible types.
The callee function could be translated into assembly similar to the following:
divide: ;# NOTE: [rsp] points to address of .JUMP_TABLE test esi, esi je .THROW1 cmp esi, -1 jne .LBL1: cmp edi, 0x7fffffff jne .THROW2 .LBL1: mov eax, edi cdq idiv esi ret ; return to entry[0] .THROW1: ; [rsp] contains address of entry[0] instruction ; need to add '5' (size of JMP instruction) to this to get address of entry[1] ; do this in-place to avoid using an extra register to compute the address add qword ptr [rsp], 5 ret ; return to entry[1] .THROW2: ; [rsp] contains address of entry[0] instruction ; need to add '10' (size of 2 * JMP instruction) to this to get address of entry[2] ; do this in-place to avoid using an extra register to compute the address add qword ptr [rsp], 10 ret ; return to entry[2]
A few things are worth noting here.
- The calling code still just passes arguments in registers, calls the function with a call-instruction, which returns on the normal return-path back to the instruction after the call-instruction.
- The callee still executes a normal 'ret' instruction to return on the normal code-path.
- On the error return-path, the callee adjusts the return address by adding a constant to the location on the stack where the return-address is stored. On x86 this can be done with a single instruction and does not require any additional registers.
- After returning to the appropriate return-address, the caller then needs
to jump to the appropriate location to execute the logic for that code-path.
- For the success case, this is the one extra instruction that needs to be executed compared to traditional table-based exception-throwing calling conventions.
- There is no need for additional exception-unwind-lookup tables or runtime-type-information. All of the data/logic for handling the exceptions is local and inline within the caller and callee.
- There are no additional branches to execute on the caller side here.
Once the callee has branched to the necessary code path that executes the
throw
expression, it returns directly to the code-path in the caller that handles that case.
Of particular note is that overhead of returning via the exception code-path is one additional instruction.
A slight space-optimisation could be to inline the final code-path (in the above example, the code-path corresponding to the 'overflow' exception case) in lieu of having a jump instruction for the final block of code.
For example: Inlining the last case into the jump table
example: mov edi, dword ptr [x] ; Load 'x' into register for param 1 mov esi, dword ptr [y] ; Load 'y' into register for param 2 call divide ; Jump to 'divide' and push address of .JUMP_TABLE onto stack .JUMP_TABLE: ; Each entry is a 'JMP rel32' encoded instruction which is 5 bytes jmp .SUCCESS ; entry[0] - success path jmp .ERR_0 ; entry[1] - error path for divide_by_zero .ERR_1: mov eax, -2. ; entry[2] - error path for overflow (inlined as last jump table entry) ret .SUCCESS: ; result is in eax mul eax, 2 ret .ERR_0: mov eax, -1 ret
One concern with this approach is that the normal return-path is no longer inline immediately after the call instruction as there is now a number of jmp instructions interspersed between the call and the logic that handles the success case.
This can potentially have a detrimental effect on instruction-cache miss rate, particularly if the number of possible exception types is large. e.g. if there were more than 12 possible exception types listed in the signature, each of which required their own jump table entry, then you're pretty much guaranteed that the call instruction will live in a different cache-line to the normal return-path and so may be more likely to result in an instruction-cache miss. For cases with a small number of exception types, the overhead on the normal return-path is expected to be small, however.
The other (minor) concern is that returning with a ret
instruction with a modified
return-address is basically always going to mis-predict the return-address on CPUs
that use a return-address cache to predict the target of ret
addresses.
While a branch mis-predict would still be much faster than table-based, dynamic
exception throwing, there may be use-cases for which this is significant.
If it becomes an important enough performance problem, future CPU designs could
potentially solve the mis-prediction by introducing a dedicated retn
instruction
that encodes the index of the jump table in an immediate value, which would allow
the branch predictor to correctly predict the alternative return-paths.
Implementation experience is required to evaluate the actual impact of these issues on the performance of the normal call-path and on the performance of the error path.
8.1.2. "NOP Stuffing" Jump Tables
Another alternative approach of implementing the jump-table is to somehow embed a pointer/offset to an external jump-table in a location relative to the call instruction.
On x86/64 there are some nop
instruction encodings that have unused bytes
within them and this can be used to encode some data in the instruction stream
without that instruction having any effect on the state of the CPU registers
(other than advancing the instruction pointer).
You can place this instruction immediately following a call instruction and encode into the spare bits of the instruction, an offset to the jump table. As this instruction is a no-op, you can just immediately follow the instruction with the logic for handling the normal return-path. In this case, there is much less of a concern with instruction cache misses.
So in the above case, the caller would be replaced with:
example: mov edi, dword ptr [x] ; Load 'x' into register for param 1 mov esi, dword ptr [y] ; Load 'y' into register for param 2 call divide ; Jump to 'divide' and push address of .JUMP_TABLE onto stack .DATA_NOP nop DWORD (.JUMP_TABLE - .DATA_NOP) ; 3-byte prefix + 4-bytes jump table offset mul eax, 2 ret .JUMP_TABLE: ; Each entry of table is a pointer to return-address data DWORD (.ERR_0 - .DATA_NOP) data DWORD (.ERR_1 - .DATA_NOP) .ERR_0: mov eax, -1. ; entry[1] - error path for divide_by_zero ret .ERR_1: mov eax, -2. ; entry[2] - error path for overflow ret
Then the callee would be updated to look something like the following:
divide: ; NOTE: [rsp] points to address of nop encoding offset to .JUMP_TABLE test esi, esi je .THROW1 cmp esi, -1 jne .LBL1: cmp edi, 0x7fffffff jne .THROW2 .LBL1: mov eax, edi cdq idiv esi ret ; return to nop instruction (normal return) .THROW1: ; [rsp] contains address of nop instruction (i.e. return address) mov rax, QWORD PTR [rsp] ; load the address of the nop instruction mov edx, DWORD PTR [rax+3] ; load the offset-to-jump-table part of the nop instruction (assuming 7 byte encoding, last 4 bytes is offset) mov edx, DWORD PTR [rax+rdx] ; load the offset in entry[0] of table add QWORD PTR [rsp], rdx ; add offset to return-address ret ; return to unwind path for 'divide_by_zero' .THROW2: mov rax, QWORD PTR [rsp] ; load the address of the nop instruction mov edx, DWORD PTR [rax+3] ; load the offset-to-jump-table part of the nop instruction (assuming 7 byte encoding, last 4 bytes is offset) mov edx, DWORD PTR [rax+rdx+4] ; load the offset in entry[1] of table add QWORD PTR [rsp], rdx ; add offset to return-address ret ; return to unwind path for 'overflow'
Note that with this approach, the success return path can now place the
rest of the code inline after the NOP
instruction, and so is more likely to
be in-cache.
However, this comes at the cost of additional instructions for each of the exceptional return paths, including one additional memory load to load the jump table entry's offset.
It's unclear whether the "inline jump table" or "nop stuffing jump table" approach would be more efficient or smaller code-size overall. Both approaches will need to be tried on a variety of code-bases to evaluate effects on performance and code-size.
Other architectures may have alternative approaches that are possible or may have additional constraints - these will all need to be investigated.
8.2. Multiple return-value-slots
The previous section focused mostly on how to implement multiple return-addresses for a given function in an efficient way. We also need to consider how to return the exception objects themselves.
With the normal return path, there are a couple of common conventions for passing the return value back to the caller. If the return type is void there is nothing to do as the return-path does not need to pass a value back.
If the return-type is trivially copyable or trivially movable and the return-value fits within a register or two then the return-value can sometimes be passed back in designated register(s). e.g. on x86-64 this is often the eax/rax register
Otherwise, in order to support guaranteed copy-elision, the return object will need to be constructed into storage provided by the caller. For normal return-values the caller usually provides the address of this storage by passing it as an additional (hidden) parameter to the function, usually in a register, which the callee then uses to construct the return object before returning.
When returning an exception object from a function with a static exception specifcation we can use a lot of the same conventions for passing the excepton value back.
If the exception object is trivially copyable/movable and empty then we just return to the corresponding return-address without consuming a register.
If the exception object is trivially copyable/movable and fits within a register or two, then we can put the exception object in a register before returning to corresponding return-address. The caller can then just read the exception object value from the register(s).
If the exception object needs to be placed in memory then to get the same copy-elision behaviour as for normal return-values the caller needs to provide the address of the storage for that particular exception type.
This presents somewhat of a challenge, because now, instead of passing a single address for the return value, we potentially need to pass N addresses, one for the return object and one for each of the exception object types.
It is also worth noting here that if the called function accepts a return-value-slot parameter then the storage for the exception object must be distinct from the storage for the return-value. This is because an exception might be thrown while constructing the return-value - the exception-object might need to be constructed while the return-value is still partially initialized.
This paper describes two possible approaches to passing the storage address for exception objects. Other approaches are certainly possible, this paper does not seek to provide an exhaustive list of them, but rather to show that efficient implementations exist and also to discuss some of the tradeoffs.
8.3. Passing an exception-value-slot parameter
The first approach described is similar to the approach described in [Renwick].
If any of the potentially-thrown exception types of the function being called are non-trivial and thus unable to be returned in registers, the caller passes the address of storage for any non-trivial exception objects as an additional (hidden) parameter to the function call. This paramter is referred to has the exception-value-slot. This is in addition to any return-value-slot parameter.
The address must point to storage that is suitably sized and aligned to hold any of the non-trivial exception types listed in that call's throw specification.
If the caller handles all potentially-thrown exceptions locally, and does not rethrow any of the exceptions to its caller, then the caller can allocate storage for the exception object within its stack-frame (or coroutine-frame) and then pass the address of that storage as the exception-value-slot parameter.
If the caller does not locally handle all of the exceptions and the caller has
a dynamic exception specification, then this is handled as-if the function had
a try { /function-body/ } template catch (auto& e) { throw std::move(e); }
wrapped around the function body. This then rethrows any static exception objects
that escape the function as dynamic exception objects - initializing the dynamic
exception object by move-constructing it from the static exception object.
If the caller itself has a static exception specification and does not locally handle any of the potentially thrown exceptions then the caller can just pass through the address passed to it in its exception-value-slot parameter.
In this situation, as all of the exception-types are propagated through to the caller's caller then this implies that the caller's throw-specification is a super-set of the exception types listed in the callee's throw specification, otherwise the function would be ill-formed. Therefore, the storage provided by the caller's caller should already be guaranteed to be sized and aligned sufficiently to handle any of the thrown exceptions.
This leaves the more interesting situation where the caller handles some of the exceptions and propagates some exceptions to its caller.
For example: Assuming A
, B
and C
are non-trivial exception types
void callee() throw(A, B, C); void caller() throw(C) { try { callee(); } catch (const A& a) { print(a); } catch (const B& b) { print(b); } } void parent_caller() throw() { try { caller(); } catch (const C& c) { print(c); } }
In this example, if the size and alignment of the storage provided by parent_caller()
to caller()
for the exception object C
is also sufficient for storing an object of
either type A
or B
, then caller()
can still pass along the that storage to callee()
,
it just needs to make sure that if an A
or B
is thrown then the exception object is
destroyed before control returns to parent_caller()
.
For example, it could be lowered to something (roughly) equivalent to the following example.
Note that below, functions with multiple return-addresses are returning an int
to indicate which return-address it should return to. When lowered to assembly,
assume this would instead use one of the approaches described above for returning
to the corresponding return address.
int callee(void* __exception_value_slot); int caller(void* __exception_value_slot) { switch (callee(__exception_value_slot)) { case 0: goto normal_return; case 1: goto throw_a; case 2: goto throw_b; case 3: goto throw_c; } throw_a: const auto& a = *reinterpret_cast<A*>(__exception_value_slot); print(a); a.~A(); goto normal_return; throw_b: const auto& b = *reinterpret_cast<B*>(__exception_value_slot); print(b); b.~B(); goto normal_return; throw_c: // Propagate C exception to caller. return 1; normal_return: return 0; } void parent_caller() { alignof(C) unsigned char __exception_storage[sizeof(C)]; switch(caller(&__exception_storage[0])) { case 0: goto normal_return; case 1: goto throw_c; } throw_c: const auto& c = *reinterpret_cast<C*>(&__exception_storage[0]); print(c); c.~C(); goto normal_return; normal_return: return; }
However, if, say, the B
object was larger than the C
object, then the caller()
function cannot just pass the storage passed to its exception-value-slot onto callee()
as this storage is only guaranteed to be able to fit a C
.
So in this case, the caller()
function needs to allocate its own local storage, sufficiently
sized for storing either an A
, B
or C
, and then pass that to callee()
.
However, this means then that in the casethat callee()
throws a C
, which we want to propagate
to parent_caller()
, then we need to move-construct the C
object from caller()
's local storage
to its exception-value-slot.
The caller()
function from above would need to change to:
int caller(void* __exception_value_slot) { alignas(A) alignas(B) alignas(C) unsigned char __exception_storage[std::max(sizeof(A), std::max(sizeof(B), sizeof(C)))]; switch (callee(&__exception_storage)) { case 0: goto normal_return; case 1: goto throw_a; case 2: goto throw_b; case 3: goto throw_c; } throw_a: const auto& a = *reinterpret_cast<A*>(__exception_value_slot); print(a); a.~A(); goto normal_return; throw_b: const auto& b = *reinterpret_cast<B*>(__exception_value_slot); print(b); b.~B(); goto normal_return; throw_c: // Move exception object to storage for caller. C& __exception_object = *reinterpret_cast<C*>(&__exception_storage[0]); ::new(__exception_value_slot) C(std::move(__exception_object)); __exception_object.~C(); return 1; normal_return: return 0; }
This need to allocate local storage and then move a resulting exception object into the exception-value-slot storage is also required in the situation where an exception is caught and then, inside the handler, a new exception object is thrown and this new exception escapes the function.
This is because, at the point of construction of the new exception object (which will need to be constructed into storage passed in exception-value-slot) the existing exception object which has been caught is still alive and so its storage must be distinct from the exception-value slot storage.
Using this approach is expected to be highly efficient for cases where all exceptions are propagated, with the main cost being the need to pass an extra pointer to the function, using an extra register, possibly causing more spills of registers to the stack.
However, there will also be cases where an exception object must be moved multiple times from local storage to caller's storage as the exception propagates up the stack, which has the potential to be a performance issue for large exception objects.
8.4. Walking stack to find storage
If we want to avoid needing to move/copy exception objects as they propagate up the stack then we need to be able to construct the object in its final location. However, different exception object types are potentially going to be caught in different callers up the stack.
For example: If foo()
throws C
, the final storage is in bar()
, if it throws B
,
the final storage is in baz()
, and if it throws A
, the final storage is in main()
.
void foo() throw(A, B, C); void bar() throw(A, B) { try { foo(); } catch(const C&) {} } void baz() throw(A) { try { bar(); } catch(const B&) {} } int main() throw() { try { baz(); } catch(const A&) {} }
Since each of these locations is likely going to have different addresses, this means we effectively need to pass N exception-object addresses to a call that can potentially throw N non-trivial exception types.
The naiive approach of adding N additional parameters to the function may work for a handful of exception-types, but the overhead of making a function call increases linearly with the number of addresses that need to be passed as each call needs to either push onto the stack or load into registers N different pointers.
Instead, we ideally want a solution that has minimal to no overhead on the success path and runtime overhead on the error path that does not increase with the number of thrown exception types.
To do this, we can leverage either of the jump-table approaches described above and add an additional entry to the jump-table for each non-trivial exception type.
The callee can then call this jump-table entry, passing the address of the parent stack-frame, to compute the address of the exception object.
If the immediate caller handles the exception locally then this returns an address that is some offset into the stack-frame address. Otherwise, it loads the address of its caller's stack-frame and then tail-calls the corresponding jump-table entry for the exception-type from its return-address.
For example, given the C++ code above, we might expect the bar()
function to be
compiled to something like the following:
bar: push rbp mov rbp, rsp sub rsp, 8 ; allocate storage for C exception ; Call foo() with inline jump-table convention call foo jmp .SUCCESS jmp .THROW_A jmp .GET_A jmp .THROW_B jmp .GET_B jmp .THROW_C jmp .GET_C .SUCCESS: .RETURN: add rsp, 8 pop rbp ret .THROW_A: add QWORD PTR [rbp+8], 5 ; adjust return-address to entry[1] jmp .RETURN .THROW_B: add QWORD PTR [rbp+8], 15 ; adjust return-address to entry[3] jmp .RETURN .THROW_C: ; C handled locally ; Address of exception object in rax mov rdi, rax call C::~C() jmp .SUCCESS .GET_A: ; Query for address of A exception will call here. ; Forward query to caller ; Current stack frame in rdi mov rax, QWORD PTR [rdi+8] ; load return-address mov rdi, QWORD PTR [rdi] ; load parent frame-ptr add rax, 10 ; compute address of entry[2] jmp rax ; tail-call to entry[2] .GET_B: ; Query for address of B exception will call here. ; Forward query to caller mov rax, QWORD PTR [rdi+8] ; load return-address mov rdi, QWORD PTR [rdi] ; load parent frame-ptr add rax, 20 ; compute address of entry[4] jmp rax ; tail-call to entry[4] .GET_C: ; Query for address of C exception, handle locally ; Current stack frame in rdi lea rax, [rdi-8] ; compute address of storage for C on stack ret ; return to function that is about to throw
In the above code, if the call to foo()
completes normally then execution
returns to the jmp .SUCCESS
and this goes on to return from the function.
The overhead here should be negligible - the direct jmp
instruction is the
only additional instruction to be executed on the success path.
Otherwise, if the call to foo()
throws an exception then first there will
be a call to one of the "GET" jump targets to obtain the address to construct
the exception object in. Then the foo()
function will construct the exception
object at that address, perform any local unwinding and then return to the
corresponding "THROW" jump target.
In the above example, the GETA and GETB targets forward through to the corresponding
queries on bar()
's caller's jump table, while GETC is able to compute the value
locally. In the case of GETA, bar()
's implementation would then forward on to its
caller - main()
in the example above.
This is effectively doing a stack-walk to find the caller that is willing to provide storage. Only instead of having a generic stack-walking algorithm in a loop it is performing a series of tail-jumps to code in each of the callers corresponding to that exception type.
The walk is terminated by a GET entry that knows how to compute the address of the object within its stack-frame.
For example: In foo()
, code that throws an A
exception might look like the following:
foo: ; ... snip ;;;; ; throw A{} ;;;; ; first get the address for the exception object mov rax, QWORD PTR [rbp+8] ; load return-address / jump-table-address mov rdi, QWORD PTR [rbp] ; load parent frame address add rax, 10 ; adjust address to entry[2] call rax ; call the address query ; when this returns, the address to use will be in rax ; construct the A() object mov rdi, rax call A::A() ; ... any local unwind goes here ; now return to entry[1] add QWORD PTR [rbp+8], 5 ; adjust return-address add rsp, 24 pop rbp ret ; ... snip
Walking the stack like this to obtain the storage location allows constructing the object in its final place and minimizes the number of moves of the exception object as it propagates, even if it is caught and rethrown.
Once the exception object is constructed, then any local objects in scope are destroyed and then the function returns to the corresponding entry in the jump table.
Each entry returned to during unwind that is not a handler will just execute a sequence of calls to destructors before then returning to the corresponding for that type in its parent's jump-table. This continues until control returns to some entry that enters a handler for the exception.
Some benefits of this appraoch:
- No need for external exception tables to be consulted during exception unwinding.
- No need for run-time type information.
- Less copying/moving of exception objects as it propagates up the stack.
There are a couple of potential down-sides to this approach, which will need to be quantified once an implementation is available.
- The stack walk can potentially incur a number of cache misses during the walk, depending on how many frames need to be walked.
- The stack walk may also incur some branch mis-predictions as it jumps to different entries up the call-stack.
- There is more code-generation required compared to the exception-value-slot approach. The size of code grows with the number of exception types that might be thrown from the call.
- Worst case overhead of stack walking is where the immediate caller typically handles buy rarel) conditionally rethrows the exception which then propagates up to a handler many levels up the call-stack. The stack walk needs to walk all of these frames to find storage, but rarely benefits from placing the storage at this final handler.
Whether this approach ends up being more or less efficient than the exception-value-slot may depend on the context and types involved.
8.5. Unwinding through scopes with destructors
When unwinding due to an exception thrown from a function with a static exception specification, execution will return to an address that handles that particular exception being thrown.
The function will then need to call destructors for any in-scope variables, before then either entering a handler, or propagating the exception to its caller by returning to its
In the case that there are multiple exception types that propagate through a function, all of the code-paths for each exception type will need to call the same set of destructors. Also, there will be overlap in the set of destructors to call depending on which scope the exception was thrown at.
For example: Given the following function, f()
.
struct X { X() throw(); ~X(); int data; }; struct Y { Y() throw(); ~Y(); int data; }; void f(X&) throw(A, B); void g(Y&) throw(A, B); void caller(bool cond) throw(A, B) { X x; f(x); Y y; g(y); }
If the call to f()
throws an A
then it needs to call X::~X()
and then return to entry[1].
If the call to f()
throw a B
then it neesd to call X::~X()
and then return to entry[3].
If the call to g()
throws an A
then it needs to call Y::~Y()
and X::~X()
and then return to entry[1].
If the call to g()
throws a B
then it needs to call Y::~Y()
and X::~X()
and then return to entry[3].
There is a lot of overlap in these unwind paths which can be merged to avoid bloating the generated code for these unwind paths with duplicated instructions.
For example: the above function could be compiled to the following assembly:
caller: push rbp mov rbp, rsp sub rsp, 8 ; reserve storage for X and Y lea rdi, [rsp+4] call X::X() lea rdi, [rsp+4] call f(X&) jmp .F_SUCCESS jmp .F_THROW_A jmp .GET_A jmp .F_THROW_B jmp .GET_B .F_SUCCESS: mov rdi, rsp call Y::Y() mov rdi, rsp call g(Y&) jmp .G_SUCCESS jmp .G_THROW_A jmp .GET_A jmp .G_THROW_B jmp .GET_B .G_SUCCESS: mov rdi, rsp call Y::~Y() lea rdi, [rsp+4] call X::~X() .RETURN: add rsp, 8 pop rbp ret .F_THROW_A: add QWORD PTR [rbp+8], 5 ; adjust return-address to entry[1] (type A) jmp .UNWIND1 .F_THROW_B: add QWORD PTR [rbp+8], 15 ; adjust return-address to entry[3] (type B) .UNWIND1: push rax ; save address of exception-object jmp .LBL1 .G_THROW_A: add QWORD PTR [rbp+8], 5 ; adjust return-address to entry[1] (type A) jmp .UNWIND2 .G_THROW_B: add QWORD PTR [rbp+8], 15 ; adjust return-address to entry[3] (type B) .UNWIND2: push rax ; save address of exception-object lea rdi, [rsp+8] ; address of 'y' call Y::~Y() .LBL1: lea rdi, [rsp+12] ; address of 'x' call X::~X() pop rax ; restore address of exception-object jmp .RETURN .GET_A: mov rax, QWORD PTR [rdi+8] ; load return address mov rdi, QWORD PTR [rdi] ; load parent frame ptr add rax, 10 ; adjust to entry[2] jmp rax ; tail-call .GET_B: mov rax, QWORD PTR [rdi+8] ; load return address mov rdi, QWORD PTR [rdi] ; load parent frame ptr add rax, 20 ; adjust to entry[4] jmp rax ; tail-call
Notice here how the calls to the destructors during unwind can be rolled together so that we only need code for calling the destructor of each local object on unwind once, despite there being 4 different cases that need to be handled.
It can do this by first adjusting the return-address to the one corresponding to the exception type immediately after jumping to the jump-table target and then after this the code for calling destructors and returning to the caller is the same for all exception types.
8.6. Interop between static-exception functions and dynamic-exception functions
There are several cases that need to be considered with regards to interaction between functions with a checked static-exception specification and functions with dynamic-exception specifications.
When a function with a checked static exception specification calls a functon with a
dynamic-exception specification, the calling must surround the call in a try/catch(...)
,
otherise the set of potentially-thrown exceptions computed for the function-body would
make the function ill-formed.
The function therefore needs to catch and explicitly either rethrow an exception
whose type is marked final
or needs to throw a new exception object.
Both cases would need to initialize a new static exception object in order to
propagate the exception to its caller.
For example:
// Can potentially throw anything, but docs say it will // only throw something inherited from std::bad_alloc void callee(); void caller() throw(std::bad_alloc) { try { callee(); } catch (const std::bad_alloc& e) { // Rethrow a copy of the exception as a new bad_alloc exception throw e; } catch (...) { // throwing anything else would be a violation of its documented contract // contract_assert(false); std::terminate(); // or nothrow_unreachable() } }
If the existing dynamic exception facility uses table-based unwind, then the calling function still needs to have unwind table entries for catching the exception.
When the situation is reversed, and instead a function with a dynamic exception specification is calling a function with a static exception specification then if the exception is not handled locally, it must implicitly propgate out of the calling function.
This means that at the boundary of this function, the exception needs to be translated from a static exception object to a dynamic exception object and then rethrown. Thus the calling function will need to insert code to allocate a new dynamic exception and then move-construct the static exception into this allocated storage.
For example, given the following:
void callee() throw(std::bad_alloc, std::system_error); void caller() { callee(); }
the compiler may lower caller()
it to something equivalent to the following:
void caller() try { callee(); } template catch (auto& e) { throw std::move(e); // rethrow as a new dynamic exception at boundary }
8.7. Virtual function calls
Virtual functions generally act the same as normal functions do with regards to static exceptions, with the exception of the case where an override declares a narrower exception specification than the base class.
The following cases need to be handled specially:
- the base class function is declared with a dynamic exception specification and the derived class is declared with a non-empty static exception specification.
- the base class function is declared with a non-empty static exception-specification and the derived class is declared with a static exception specification with a subset of the exceptions listed in the base class' function declaration.
In both of these cases, the calling convention for calling the override via the base class interface is different from the calling convention for calling the override via the derived class interface.
Therefore, the use of a thunk is required to adapt between the calling conventions.
In the case that the base class signature has a dynamic exception specification and the derived class has a static exception specification, the thunk will need to allocate storage on the stack for the static exception, and call the override with the appropriate jump table for handling each exception type and then if the call completes with an exception then rethrow the static exception object as a dynamic exception.
8.8. Interaction with std::uncaught_exceptions()
During exception unwind due to a thrown static exception object, we will still want the
std::uncaught_exceptions()
function to return a value that indicates a new uncaught exception
is in-flight.
This means that, after constructing the exception object, but before starting the unwind process, the function needs to ensure that the thread-local count of uncaught exceptions is incremented. Then upon entering a handler, after initializing the object in the exception-declartion of the handler, it needs to decrement the count of uncaught exceptions.
For example, on platforms that use the Itanium C++ ABI, the thread-local exception state is obtained through the following API:
struct __cxa_exception { /* fields that describe an exception object */ }; struct __cxa_eh_globals { __cxa_exception* caughtExceptions; // linked list of caught exceptions - most recent first unsigned int uncaughtExceptions; // count of thrown but not yet caught exceptions }; __cxa_eh_globals* __cxa_get_globals(void); // get pointer to thread-local exception state (initializing on first call) __cxa_eh_globals* __cxa_get_globals_fast(void); // get pointer to thread-local exception state (no-initialization)
To modify the uncaught exceptions, the program needs to call __cxa_get_globals()
and then
increment/decrement the uncaughtExceptions
member of the object pointed to by the return value
at the appropriate point during exception throwing/handling.
If the __cxa_eh_globals()
function is annotated with appropriate attributes (e.g. __attribute__((const))
)
which let the compiler assume that it always returns the same value and has no side-effects, then the compiler
can cache the result. If the throwing function is inlined into the handling function and the
compiler can see both throw-site and handler at the same time and can see that there are no
opaque calls or references to the uncaughtExceptions
member during unwind, then it can even
optimize out the thread-local access and increment/decrement.
It should be relative straight-forward to ensure that an implementation of static exceptions
gives the specified behaviour of std::uncaught_exceptions()
.
8.9. Static exceptions and the Itanium C++ ABI
See https://godbolt.org/z/8578rKh6j for an proof-of-concept of how a local throw/catch within a function,
which under this proposal would be required to be a static exception object, would interact with
the Itanium C++ ABI and facilities such as std::uncaught_exceptions()
, std::current_exception()
and throw;
.
Support for static exception objects is likely to require some changes to the implementations of
the __cxa_*
functions that form the ABI, and thus would require relinking with the ABI libraries
(either statically or dynamically) but should otherwise be able to link against code previously
compiled against the existing ABI.
Additional work is required here to validate this approach.
9. Acknowledgements
Special thanks to Ben Craig and Corentin Jabot for providing feedback on drafts of this paper.
10. References
- [FastCasting] "Fast dynamic casting" (Gibbs, Stroustrup)
https://www.stroustrup.com/fast_dynamic_casting.pdf - [N3227] "Please reconsider
noexcept
" (Ottosen, 2010)
https://wg21.link/N3227 - [N3202] "To which extent can
noexcept
be deduced?"" (Stroustrup, 2010)
https://wg21.link/N3202 - [N3207] "
noexcept(auto)
" (Merrill, 2010)
https://wg21.link/N3207 - [N4473] "
noexcept(auto)
, again" (Voutilainen, 2015)
https://wg21.link/N4473 - [P0133R0] "Putting
noexcept(auto)
on hold, again" (Voutilainen, 2015)
https://wg21.link/P0133R0 - [P0709R4] "Zero-overhead deterministic exceptions: Throwing values" (Sutter)
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0709r4.pdf - [P1947R0] "C++ exceptions and alternatives" (Stroustrup)
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1947r0.pdf - [P2268R0] "Freestanding Roadmap" (Craig)
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p2268r0.html - [P2300] "
std::execution
" (Multiple authors)
https://isocpp.org/files/papers/P2300R8.html - [P2900R6] "Contracts for C++" (Multiple authors)
https://isocpp.org/files/papers/P2900R6.pdf - [P2830R1] "Standardized Type Ordering" (Nichols, Ažman)
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2830r1.pdf - [P2809R2] "Trivial infinite loops are not Undefined Behavior" (Bastien) https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2809r2.html
- [P3068R0] "Allowing exception-throwing in constant evaluation" (Dusíková)
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p3068r0.pdf - [Renwick] "Low-cost deterministic C++ exceptions for embedded systems" (Renwick, Spink, Franke)
https://dl.acm.org/doi/10.1145/3302516.3307346