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_ptrs 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 the r.error() example above
  • This use of Context.takeReplaceableUses()
  • This use of CreateInfoOutputFile, which returns a unique_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 been release

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 a PredicateInfo 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_ptrs by value, and so we mustn’t make prvalue exception_ptrs behave any differently from lvalue exception_ptrs.

Posted 2024-07-03