Is std::declval outdated because of guaranteed copy elision?
Asked Answered
H

3

6

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?

Holyhead answered 3/6, 2021 at 17:40 Comment(7)
very related/maybe dupe: #20303750. Also: #25707941Melanosis
Class c2(c1); fails. Why should is_constructible say it doesn't? In your example you're not constructing a new object at all.Abundance
@AyxanHaqverdili Class c2(c1) is trying to construct from Class& (or Class const&). And this would be denied correctly by is_constructibleHolyhead
@Holyhead Type of c1 is Class, not Class&.Abundance
@AyxanHaqverdili, the type of the variable c1 is. But the type of the expression is a reference.Holyhead
@Holyhead no, that's not right. Type of an expression cannot be a reference. eel.is/c++draft/expr#type-1Abundance
@Melanosis These are pre-C++17Holyhead
H
6

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.

Hertzfeld answered 3/6, 2021 at 17:53 Comment(9)
The "all types are constructible" is quite convincing in your answer... But for the preceding paragraph: Why shouldn't is_constructible<T,T> mean from prvalue here, we already have is_constructible<T,T&> and is_constructible<T,T const&> to denote "construct from a given object". And how would that argument generalize to is_invocable?Holyhead
@Tobi: I think the way to think about it is that use of a prvalue to initialize something is not construction at all.Archipenko
@Tobi: "Why shouldn't is_constructible<T,T> mean from prvalue here" Because there's no such thing as a prvalue type. Expressions are prvalues, but types are not.Hertzfeld
IMO declval should best approximate the inversion of decltype. And given a prvalue to decltype, it gives the type of the expression "T", which has no rvalue references around. IMO this is a quirk of declval. And IMO this is independent of is_constructible. is_same_v<T, decltype(declval<T>())> looks desirable.Gales
@David And though it's independent from the ideal behavior of declval, why should string s(10) not be described to "construct" a string? Isn't it even the case that a constructor is called?Gales
And it should be noted, guaranteed elision means that all types are "constructible" from a prvalue of its own type. I think this is an excellent example for what is wrong here. Why not make it just intuitively work? If I wanted to test against xvalue constructibility, I could pass T&&. If I wanted to test for prvalue constructibility, I would pass T.Gales
As another answer noted, all this is probably for forwarding consistency. When I view it in that context, it all makes sense. Forwarding prvalues as prvalues is not possible. But IMO other attempts at explaining why something may not be "constructible" from a prvalue are not convincing.Gales
What if you wanted to ask if struct X { X(Class); }; is constructible from a prvalue of type Class? It certainly isn't constructible from an xvalue of type ClassFrieze
@Artyer: Why would you want to ask if a type is constructible specifically from a prvalue?Hertzfeld
B
1

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.

Bort answered 3/6, 2021 at 17:56 Comment(2)
Seeing it in that context makes sense to me. Though why not write std::forward in the return type? args is visible here. Feel it needs a better example.Gales
@JohannesSchaub-litb Yeah I came up with a simple example that shows forwarding. A more useful example of std::forward would be inside type traits and other places where you don't have args...Bort
F
0

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.

Frieze answered 26/6, 2023 at 19:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.