std::try_cast
and (const&&)=delete
std::try_cast
and (const&&)=delete
P2927 “Inspecting exception_ptr
,”
proposes a facility (formerly known as std::try_cast
) with this signature:
template<class T>
const T* exception_ptr_cast(const exception_ptr&) noexcept;
You might use it in a slight modification of Nicolas Guillemot’s example from 2013 (Godbolt):
FooResult lippincott(std::exception_ptr eptr) {
assert(eptr != nullptr);
if (auto *ex = std::exception_ptr_cast<MyException1>(eptr)) {
return FOO_ERROR1;
} else if (auto *ex = std::exception_ptr_cast<MyException2>(eptr)) {
return FOO_ERROR2;
} else {
if (auto *ex = std::exception_ptr_cast<std::exception>(eptr)) {
log::warning("%s", ex->what());
}
return FOO_UNKNOWN;
}
}
try {
foo::DoThing();
} catch (...) {
return lippincott(std::current_exception());
}
Notice the grotesque redundancy of the name
exception_ptr_cast
. In an ideal world you should name your functions primarily for what they do, not for what argument types they take.
Someone pointed out that it’s dangerous to write
if (auto *ex = std::exception_ptr_cast<std::exception>(std::current_exception())) {
std::cout << ex->what() << "\n";
}
because std::current_exception
is permitted to copy the in-flight exception to storage that lives
only as long as the exception_ptr
’s reference count remains above zero. MSVC actually does
something along these lines; see “MSVC can’t handle move-only exception types” (2019-05-11).
So if you write the line above, it will compile everywhere, work fine on Itanium-ABI platforms,
but cause a use-after-free on Windows.
“Okay, why don’t we just make it ill-formed to call exception_ptr_cast
with an rvalue
exception_ptr
? Just =delete
the overload that preferentially binds to rvalues.
Then you have to store the thing in a variable, so you know the resulting exception*
can’t dangle. Like this:”
template<class T> const T* exception_ptr_cast(const exception_ptr&) noexcept;
template<class> void exception_ptr_cast(const exception_ptr&&) = delete;
This, as all readers of my blog know, is wrong! Value category is not lifetime. We certainly want to continue being able to write (Godbolt):
if (Result r = DoThing(); r.has_error())
if (auto *ex = std::exception_ptr_cast<std::exception>(r.error()))
std::cout << ex->what() << "\n";
…even when r.error()
returns by value, so that r.error()
is a prvalue expression.
And vice versa, even if we were to =delete
the rvalue overload as shown above,
that wouldn’t stop someone from writing (Godbolt):
auto *ex = std::exception_ptr_cast<std::exception>(DoThing().error());
// dangles when r.error() returns by reference
When is deleting const&&
overloads desirable?
Short answer, I believe it’s never desirable. Because value category is not lifetime.
However, even if you believe that value category is lifetime, there’s still a major difference between
the places that the STL uses (const&&)=delete
today, and the scenario with exception_ptr_cast
sketched above.
The eleven places where (const&&)=delete
appears today are:
template<class T>
const T* addressof(const T&&) = delete;
template<class T>
void as_const(const T&&) = delete;
template<class T> void ref(const T&&) = delete;
template<class T> void cref(const T&&) = delete;
and in ref_view
, and in
reference_wrapper
’s single-argument constructor
(for parity with std::cref
), and lastly, in the five constructors that construct
regex_iterator
or regex_token_iterator
from const regex&&
.
In all of these cases, the function being deleted is a function that would return you a pointer or reference to the argument itself, which we know is syntactically an rvalue expression and thus is being explicitly signaled by the programmer as “I’m done with this object” (regardless of how long it’ll actually live, relative to the useful lifetime of the returned pointer or reference).
In the exception_ptr_cast
case, the function returns you a pointer, not to the argument object,
but to the thing pointed to by the argument object: not eptr
but *eptr
. Semantically, this is
the same operation as shared_ptr::operator*
. Consider the potential for dangling here:
int& r = *std::make_shared<int>(42);
std::cout << r;
// r is dangling!
int *p = std::make_shared<int>(42).get();
std::cout << *p;
// p is dangling!
Yet we don’t mark shared_ptr::operator*() const&&
or shared_ptr::get() const&&
as deleted;
because we realize that it’s actually quite useful to be able to pass around shared_ptr
s by value.
Even if value category were lifetime, the lifetime of a single shared_ptr
is only weakly correlated
with the lifetime of the object it points to.
Just for fun, I =delete
‘d the rvalue overloads of get
, *
, and ->
for both smart pointers
and recompiled Clang to see what broke. The surprising answer: A lot of things!
- This use of
CI.getPCHContainerOperations()
, exactly like ther.error()
example above - This use of
Context.takeReplaceableUses()
- This use of
CreateInfoOutputFile
, which returns aunique_ptr
that is dereferenced, used, and discarded - These uses of
TMBuilder.create
, ditto - Almost every use of
createArgument
, ditto (honestly a lot of these look like null dereferences waiting to happen) - This questionable use of
NotNull
- This redundant
std::move
probably indicates a bug of some kind - Here, an actual (though very minor) bug:
get
should have beenrelease
In fact, LLVM contains at least one instance of the following pattern:
std::make_unique<PredicateInfo>(F, DT, AC)->verifyPredicateInfo();
Why not simply
PredicateInfo(F, DT, AC).verifyPredicateInfo()
? Maybe aPredicateInfo
is extremely large, such that the latter would blow their stack. I don’t think that’s actually the case, but it’s a plausible reason one might heap-allocate a temporary object like this.
In our exception_ptr_cast
scenario, that would be like:
std::cout << std::exception_ptr_cast<std::exception>(std::current_exception())->what();
// no risk of dangling here
In conclusion: Deleting rvalue overloads is a bad way to deal with dangling bugs, because value category is not lifetime.
But even where the STL already uses =delete
, it always uses it to address dangling references to the argument itself,
never to anything more abstractly “managed” or reference-counted by the argument. We want to preserve the ability to pass
and return exception_ptr
s by value, and so we mustn’t make prvalue exception_ptr
s behave any differently from lvalue
exception_ptr
s.