Is std::declval outdated because of guaranteed copy elision?

221 Views Asked by At

The standard library utility declval is defined as:

template<class T> add_rvalue_reference_t<T> declval() noexcept;

To add a rvalue reference here seemed like a good idea, if you think about the language when it was introduced in C++11: Returning a value involved a temporary, that was subsequently moved from. Now C++17 introduced guaranteed copy elision and this does not apply any more. As cppref puts it:

C++17 core language specification of prvalues and temporaries is fundamentally different from that of the earlier C++ revisions: there is no longer a temporary to copy/move from. Another way to describe C++17 mechanics is "unmaterialized value passing": prvalues are returned and used without ever materializing a temporary.

This has some consequences on other utilities implemented in terms of declval. Have a look at this example (view on godbolt.org):

#include <type_traits>

struct Class {
    explicit Class() noexcept {}    
    Class& operator=(Class&&) noexcept = delete;
};

Class getClass() {
    return Class();
}

void test() noexcept {
    Class c{getClass()}; // succeeds in C++17 because of guaranteed copy elision
}

static_assert(std::is_constructible<Class, Class>::value); // fails because move ctor is deleted

Here we have a nonmovable class. Because of guaranteed copy elision, it can be returned from a function and then locally materialised in test(). However the is_construtible type trait suggests this is not possible, because it is defined in terms of declval:

The predicate condition for a template specialization is_­constructible<T, Args...> shall be satisfied if and only if the following variable definition would be well-formed for some invented variable t:
T t(declval<Args>()...);

So in our example, the type trait states if Class can be constructed from a hypothetical function that returns Class&&. Whether the the line in test() is allowed cannot be predicted by any of the current type traits, despite the naming suggests that is_constructible does.

This means, in all situations where guaranteed copy elision would actually save the day, is_constructible misleads us by telling us the answer to "Would it be constructible in C++11?".

This is not limited to is_constructible. Extend the example above with (view on godbolt.org)

void consume(Class) noexcept {}

void test2() {
    consume(getClass()); // succeeds in C++17 because of guaranteed copy elision
}

static_assert(std::is_invocable<decltype(consume), Class>::value); // fails because move ctor is deleted

This shows that is_invocable is similarly affected.

The most straightforward solution to this would be to change declval to

template<class T> T declval_cpp17() noexcept;

Is this a defect in the C++17 (and subsequent, i.e. C++20) standard? Or am I missing a point why these declval, is_constructible and is_invocable specifications are still the best solution we can have?

3

There are 3 best solutions below

9
On

However the is_construtible type trait suggests this is not possible, because it is defined in terms of declval:

Class is not constructible from an instance of its own type. So is_constructible should not say that it is.

If a type T satisfies is_constructible<T, T>, the expectation is that you can make a T given an object of type T, not that you can make a T specifically from a prvalue of type T. This is not a quirk of using declval; it is what the question is_constructible means.

What you're suggesting is that is_constructible should answer a different question than the one it is intended to answer. And it should be noted, guaranteed elision means that all types are "constructible" from a prvalue of its own type. So if that was what you wanted to ask, you already have the answer.

2
On

The std::declval function is primarily meant for forwarding. Here's an example:

template<typename... Ts>
auto f(Ts&&... args) -> decltype(g(std::declval<Ts>()...)) {
    return g(std::forward<Args>(args)...);
}

In that common case, having std::declval returning a prvalue is wrong, since there's no good way to forward a prvalue.

0
On

In C++23, with the addition of std::reference_converts_from_temporary/std::reference_constructs_from_temporary, there is now precedence for T being a prvalue, T& being an lvalue and T&& being an xvalue in a type trait.

It is defined in the standard in terms of VAL<T>, which is basically declval<T>() if T is a reference type, otherwise a prvalue of type T.

This VAL<T> is very similar declval_cpp17<T>(). It would be useful in the examples you've mentioned.

However, the definition of std::is_constructible_v can never be changed to whether T t(VAL<Args>...); compiles. Too many existing call sites that look something like:

template<typename... Args> requires(std::is_constructible_v<T, Args...>)
void construct(Args&&... args);

Which would have to be changed to is_constructible_v<T, Args&&...> to be correct.

I believe add_rvalue_reference_t exists in declval since it's meant to be used with perfect forwarding. I.e., std::forward<T>(expr) would have the same type and value category as std::declval<T>(). This should have been std::declval<T&&>() all along, but it can't be changed now.