Why is this an issue?
Throwing an exception from a destructor may result in a call to std::terminate
.
By default, compilers implicitly declare destructors as noexcept
, so std::terminate
is called when they exit with an
exception. Destructors may still propagate an exception if they are explicitly declared as noexcept(false)
. However, even a destructor
declared as noexcept(false)
calls std::terminate
when it throws during stack unwinding.
The following example illustrates the severity of the underlying problem:
The destructor of a container needs to call the destructors for all managed objects. Suppose a call to an object’s destructor throws an exception.
In that case, there are only two conceptual ways to proceed:
- Abort the destruction. This results in a partially destroyed object and possibly many more objects whose destructors are never called.
- Ignore the exception and proceed with destroying the remaining objects. However, this potentially results in more partially destroyed objects
if further destructors throw an exception.
Because both options are undesired, destructors should never throw
exceptions.
What is the potential impact?
In most cases, throwing exceptions in destructors makes the program unreliable:
- If
std::terminate
is called, the program terminates in an implementation-defined, abrupt, and unclean manner.
- The program’s behavior is undefined if a standard library component (a container, an algorithm, …) manages a user-defined object that throws
an exception from its destructor.
How to fix it
This rule raises an issue when an exception is thrown from within a destructor or from a function transitively called by a destructor.
Such an exception should be caught and handled before the destructor exits.
Code examples
Noncompliant code example
In the following example, an exception is thrown inside the Builder
destructor when calling std::optional::value
on an
empty optional. It follows that the program abruptly terminates when b
gets destroyed during stack unwinding in
application()
if the user name lookup fails.
void logBuilderDestruction(int userId);
class Builder {
std::optional<int> _id;
public:
void setId(int userId) { _id = userId; }
~Builder() {
auto userId = _id.value(); // Noncompliant: may throw std::bad_optional_access
logBuilderDestruction(userId);
}
};
std::unordered_map<std::string, int>& getDatabase();
int lookupUserId(std::string const& name) {
return getDatabase().at(name); // May throw std::out_of_range.
}
void application(std::string const& name) {
Builder b;
b.setId(lookupUserId(name));
// lookupUserId throws an exception when the name is unknown.
// This causes the stack to unwind: local variables alive at
// this point, such a "b", are destroyed. This happens before
// the invocation of "b.setId()" so "b._id" is still empty
// when its destructor is executed.
// ...
}
Compliant solution
The solution below uses std::optional::value_or
to ensure no exceptions are thrown from the destructor.
void logBuilderDestruction(int userId);
class Builder {
std::optional<int> _id;
public:
void setId(int userId) { _id = userId; }
~Builder() {
auto userId = _id.value_or(-1); // Compliant: never throws.
logBuilderDestruction(userId);
}
};
std::unordered_map<std::string, int>& getDatabase();
int lookupUserId(std::string const& name) {
return getDatabase().at(name); // May throw std::out_of_range.
}
void application(std::string const& name) {
Builder b;
b.setId(lookupUserId(name));
// lookupUserId throws an exception when the name is unknown.
// This causes the stack to unwind: local variables alive at
// this point, such a "b", are destroyed. This happens before
// the invocation of "b.setId()" so "b._id" is still empty
// when its destructor is executed.
// ...
}
Noncompliant code example
How to deal with exceptions in destructors highly depends on the application. Below, we present another way to solve the issue with an RAII-based
class representing a temporary directory.
// Delete the given directory; throws OSException on failure.
void deleteDirectory(Path path) noexcept(false) {
// ...
}
class TemporaryDirectory {
Path tmp;
public:
TemporaryDirectory(); // Create a new temporary directory.
TemporaryDirectory(TemporaryDirectory const&) = delete;
TemporaryDirectory(TemporaryDirectory&&) = delete;
TemporaryDirectory& operator=(TemporaryDirectory const&) = delete;
TemporaryDirectory& operator=(TemporaryDirectory&&) = delete;
~TemporaryDirectory() {
deleteDirectory(tmp); // Noncompliant: may throw.
}
};
Compliant solution
Depending on the use case for those temporary directories, applying some remedial actions to avoid leaking secrets may be essential. Yet, it may be
reasonable to simply log and silence the exception, for example, in the context of unit tests.
It is possible to redesign the class:
- Add a
remove
member function for scenarios that must carefully and reliably handle any OSException
. In sensitive
contexts, the application should not solely rely on the destructor.
- Call this
remove
function from the destructor and catch any exception. This preserves the original class intent: an attempt to
delete the directory is made.
// Delete the given directory; throws OSException on failure.
void deleteDirectory(Path path) noexcept(false) {
// ...
}
class TemporaryDirectory {
Path tmp;
public:
TemporaryDirectory(); // Create a new temporary directory.
TemporaryDirectory(TemporaryDirectory const&) = delete;
TemporaryDirectory(TemporaryDirectory&&) = delete;
TemporaryDirectory& operator=(TemporaryDirectory const&) = delete;
TemporaryDirectory& operator=(TemporaryDirectory&&) = delete;
void remove() { deleteDirectory(tmp); }
~TemporaryDirectory() {
try {
remove();
} catch (OSException const& e) {
logFailureToRemoveDirectory(e);
}
}
};
Pitfalls
Using a function-try-block in a destructor does not prevent the destructor from exiting with an exception.
For example, the following destructor does not prevent the exception from escaping.
~TemporaryDirectory() try {
remove();
} catch (OSException const& e) {
logFailureToRemoveDirectory(e);
}
// `e` is automatically rethrow as if `throw;` was used.
Instead, a try-block should be used within the destructor’s body.
Going the extra mile
It is possible to detect whether a destructor is executed during stack unwinding and act accordingly; for example, to implement a transaction
rollback action.
The C++17 std::uncaught_exceptions
function can be used for
this purpose, as explained in N4152. This function ends with an
s
and should not be confused with std::uncaught_exception
, which got removed in C++20 for the reasons exposed in the
paper.
Resources
Documentation
External coding guidelines
Related rules
- S3654 - Destructors should be "noexcept"