C++ difference in forwarding references between template arguments and lambda's `auto` parameters

119 Views Asked by At

Compare the following two situations:

template<class... Args>
void f1(Args&&... args) { do_smth(std::forward<Args>(args)...); }

auto f2 = [](auto&&... args) { do_smth(std::forward<decltype(args)>(args)...); };

If there any difference in the type that will be passed to std::forward or not?

In my understanding, when you specify a forwarding reference using T&&, && is not "matched" against the passed argument, but rather, it's a way to "store" the "original reference" information into T (T will be &, const &, && or const && according to the value category of the passed argument), and then such "original reference" information is combined with && to calculate the final reference type using the usual & + && = & rules, etc.

What I don't know is if decltype(args) will resolve to the type "before" or "after" such computation, or if the resulting type will be ever different from the "original reference", and so if there's any difference in the type specified for std::forward in f1 and f2.

NOTE: I can't use C++20 and template lambdas are outside the question.

2

There are 2 best solutions below

0
HolyBlackCat On

T will be &, const &, && or const && according to the value category

T is never deduced as an rvalue reference. It can be deduced as a non-reference though (for rvalue arguments), possibly const.

I don't know is if decltype(args) will resolve to the type "before" or "after" such computation

After, of course.

Which means when (the imaginary) T is non-reference, decltype receives T && instead. But std::forward treats T and T && in the same way, so your usage is ok.

Moreover, you don't even need forward and can just use decltype(args)(args) (a cast to the decltyped type), though some prefer to spell out the redundant std::forward for clarity.

3
alagner On

Reference collapsing rules again ;)

Remember that:

& and & => &

&& and & => &

& and && => &

&& and && => &&

Now, T&& is a forwarding reference. However, the && still means it's an rvalue reference. Simply, in case of lvalues, T is deduced to an lvalue reference, while for rvalues T becomes just a value. Therefore, assuming an int is passed to the function, what you get as a result is either int& && (which effectively becomes int&) or int &&. Note however, that regardless of the value category, the type is always T&&, just the T differs.

Note however what the type of T:

#include <iostream>
#include <type_traits>

template<typename T>  //uncomment the line below and see how it fails:
    // requires std::is_same_v<int,T>
void foo(T&& t)
{
    static_assert(std::is_same_v<decltype(t), T&&>);
    static_assert(std::is_same_v<decltype(t), int&&> || std::is_same_v<decltype(t), int&>);

    if constexpr(std::is_lvalue_reference_v<T>) {
       std::cout << "&\n";
    }
    
    if constexpr(!std::is_reference_v<T>) {
        std::cout << "value\n";
    }
}

int main(int, char*[])
{
    foo(3);
    int x = 5;
    foo(x);
}

Small demo to play around with: https://godbolt.org/z/5fa94sfxv

Now for auto, what you're operating on is decltype(t), but the deduction rules remain the same.

Therefore, should we assume the following implementation of std::forward:

static_cast<T&&>(t); the reference collapsing rules kick in again:

//T (named template type parameter) is int, x is int&&:
//this happens with forward<T>(x)
static_cast<int&&>(3); // -> rvalue ref int&&

//T (named or not) is int&, x is int&
//this happens with forward<T>(x) and forward<decltype(x)>(x) for lvalue case
//this should be int& &&, but this is not valid C++, therefore decltype/decval combo
static_cast<decltype(std::declval<int&>())&&>(t)); //->lvalue ref int&

//x is int&&, this happens with auto&&, i.e. unnamed type.
//std forward<decltype(x)>(x), think of it as of int&& &&
static_cast<decltype(std::declval<int&>())&&>(t)); // -> rvalue ref int&&

I omitted consts for brevity, feel free to experiment with all possible variant by yourself.

@ABu as you requested in your comment:

template<typename T>
void foo(T&& t, char const* str)
{
    std::cout << "\nCase " << str << "\n=============\n";
    std::cout << boost::core::demangle(__PRETTY_FUNCTION__) << '\n';
    if constexpr(std::is_same_v<decltype(t), T>)
        std::cout << "decltype(t) is T\n";

    if constexpr(std::is_same_v<decltype(t), T&>)
        std::cout << "decltype(t) is T&\n";

    if constexpr(std::is_same_v<decltype(t), T&&>)
        std::cout << "decltype(t) is T&&\n";
    
    if constexpr(std::is_same_v<decltype(t), int>)
        std::cout << "decltype(t) is int\n";

    if constexpr(std::is_same_v<decltype(t), int&>)
        std::cout << "decltype(t) is int&\n";

    if constexpr(std::is_same_v<decltype(t), int&&>)
        std::cout << "decltype(t) is int&&\n";

    if constexpr(std::is_same_v<T, T&>)
        std::cout << "T is T&\n";

    if constexpr(std::is_same_v<T, T&&>)
        std::cout << "T is T&&\n";
    
    if constexpr(std::is_same_v<T, int>)
        std::cout << "T is int\n";

    if constexpr(std::is_same_v<T, int&>)
        std::cout << "T is int&\n";

    if constexpr(std::is_same_v<T, int&&>)
        std::cout << "T is int&&\n";
}

https://godbolt.org/z/97KKManbd I have had the function display its prototype via boost::core::demangle, may that will shed some light on it.

Also, I have added some extra examples for completeness.

To better understand what happens here:

template<typename T>
void foo(T&& t, char const* str)
{
//whatever
}

int x = 5;
foo(x, "x"); //this is called as foo<int&>(x, "x");
foo(3, "3"); //this is called as foo<int>(3, "3");

Thus, for case x, you can picture it as (not valid C++, but you should get the idea):

void foo(int&     &&, char*);
          ^^^
    deduced T      ^the "&&" from T&&
            & and && collapses into &, becoming int&

Therefore: decltype(x) is int&, difficult to argue about that. T is int&, it is deduced that way. Same as decltype(t).

decltype(x) is T&, because it is an lvalue reference. Lvalue reference (&) to lvalue reference results in an lvalue reference.

It is this kind of equivalence:

using T = int&;
static_assert(std::is_same_v<T, T&>, "");

Or rather, what happens in the function:

using DeducedT = int&; // "bare" T
using WithSignature = DeducedT&&; //what actually goes into the function. In other words: decltype(t)
static_assert(std::is_same_v<WithSignature, WithSignature&>, "");

decltype(x) is T&& is the rvalue reference to lvalue reference becoming lvalue reference:

using DeducedT = int&;
using WithSignature = DeducedT&&;
static_assert(std::is_same_v<WithSignature, WithSignature&&>, "");
//rvalue reference to rvalue reference to lvalue reference is lvalue reference.
//T& && && === T&, how ugly is that first part;)

In case of rvalues it's way easier:

using DeducedT = int;
using WithSignature = DeducedT&&;
static_assert(std::is_same_v<WithSignature, WithSignature&&>, "");

and that's the end of the story.

In theory, you are also free to do this: foo<int&&>(std::move(x), "explicit move"); but it usually it is not an useful case. It is legal though so, after breaking it down:

using DeducedT = int&&; //actually it's explicitly specified
using WithSignature = DeducedT&&;
static_assert(std::is_same_v<WithSignature, WithSignature&&>, "");

Because an argument is never a reference

That's the most overlooked part, I'm afraid, and the root of all confusion. For lvalues, T is a reference.

A peculiar thing happens here though:

int y = 8;
int&& z = std::move(y);
foo(z, "named rvalue");
//prints: void foo(T &&, const char *) [T = int &]

It is somewhat counter-intuitive at first, but after giving it a longer thought it becomes clearer: z is a named rvalue reference therefore its value category is actually an lvalue, despite its type being an rvalue reference.