Perfect-forwarding a return value with auto&&

There are two deductions here. One from the return expression, and one from the std::invoke expression. Because decltype(auto) is deduced to be the declared type for unparenthesized id-expression, we can focus on the deduction from the std::invoke expression.

Quoted from [dcl.type.auto.deduct] paragraph 5:

If the placeholder is the decltype(auto) type-specifier, T shall be the placeholder alone. The type deduced for T is determined as described in [dcl.type.simple], as though e had been the operand of the decltype.

And quoted from [dcl.type.simple] paragraph 4:

For an expression e, the type denoted by decltype(e) is defined as follows:

  • if e is an unparenthesized id-expression naming a structured binding ([dcl.struct.bind]), decltype(e) is the referenced type as given in the specification of the structured binding declaration;

  • otherwise, if e is an unparenthesized id-expression or an unparenthesized class member access, decltype(e) is the type of the entity named by e. If there is no such entity, or if e names a set of overloaded functions, the program is ill-formed;

  • otherwise, if e is an xvalue, decltype(e) is T&&, where T is the type of e;

  • otherwise, if e is an lvalue, decltype(e) is T&, where T is the type of e;

  • otherwise, decltype(e) is the type of e.

Note decltype(e) is deduced to be T instead of T&& if e is a prvalue. This is the difference from auto&&.

So if std::invoke(std::forward<Callable>(op), std::forward<Args>(args)...) is a prvalue, for example, the return type of Callable is not a reference, i.e. returning by value, ret is deduced to be the same type instead of a reference, which perfectly forwards the semantic of returning by value.


I had a similar question, but specific to how to properly return ret as if we called invoke directly instead of call.

In the example you show, call(A, B) does not have the same return type of std::invoke(A, B) for every A and B.

Specifically, when invoke returns an T&&, call returns a T&.

You can see it in this example (wandbox link)

#include <type_traits>
#include <iostream>

struct PlainReturn {
    template<class F, class Arg>
    decltype(auto) operator()(F&& f, Arg&& arg) {
        decltype(auto) ret = std::forward<F>(f)(std::forward<Arg>(arg));
        return ret;
    }
};

struct ForwardReturn {
    template<class F, class Arg>
    decltype(auto) operator()(F&& f, Arg&& arg) {
        decltype(auto) ret = std::forward<F>(f)(std::forward<Arg>(arg));
        return std::forward<decltype(ret)>(ret);
    }
};

struct IfConstexprReturn {
    template<class F, class Arg>
    decltype(auto) operator()(F&& f, Arg&& arg) {
        decltype(auto) ret = std::forward<F>(f)(std::forward<Arg>(arg));
        if constexpr(std::is_rvalue_reference_v<decltype(ret)>) {
            return std::move(ret);
        } else {
            return ret;
        }
    }
};

template<class Impl>
void test_impl(Impl impl) {
    static_assert(std::is_same_v<int, decltype(impl([](int) -> int {return 1;}, 1))>, "Should return int if F returns int");
    int i = 1;
    static_assert(std::is_same_v<int&, decltype(impl([](int& i) -> int& {return i;}, i))>, "Should return int& if F returns int&");
    static_assert(std::is_same_v<int&&, decltype(impl([](int&& i) -> int&& { return std::move(i);}, 1))>, "Should return int&& if F returns int&&");
}

int main() {
    test_impl(PlainReturn{}); // Third assert fails: returns int& instead
    test_impl(ForwardReturn{}); // First assert fails: returns int& instead
    test_impl(IfConstexprReturn{}); // Ok
}

So it appears that the only way to properly forward the return value of a function is by doing

decltype(auto) operator()(F&& f, Arg&& arg) {
    decltype(auto) ret = std::forward<F>(f)(std::forward<Arg>(arg));
    if constexpr(std::is_rvalue_reference_v<decltype(ret)>) {
        return std::move(ret);
    } else {
        return ret;
    }
}

This is quite a pitfall (which I discovered by falling into it!).

Functions which return T&& are rare enough that this can easily go undetected for a while.


auto&& is always a reference type. On the other hand, decltype(auto) can be either a reference or a value type, depending on the initialiser used.

Since ret in the return statement is not surrounded by parenthesis, call()'s deduced return type only depends on the declared type of the entity ret, and not on the value category of the expression ret:

template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args) {
   decltype(auto) ret{std::invoke(std::forward<Callable>(op),
                                  std::forward<Args>(args)...)};
   ...
   return ret;
}

If Callable returns by value, then the value category of op's call expression will be a prvalue. In that case:

  • decltype(auto) will deduce res as a non-reference type (i.e., value type).
  • auto&& would deduce res as a reference type.

As explained above, the decltype(auto) at call()'s return type simply results in the same type as res. Therefore, if auto&& would have been used for deducing the type of res instead of decltype(auto), call()'s return type would have been a reference to the local object ret, which does not exist after call() returns.


However, doesn't decltype(auto) also form a reference to xvalue/lvalue?

No.

Part of decltype(auto)'s magic is that it knows ret is an lvalue, so it will not form a reference.

If you'd written return (ret), it would indeed have resolved to a reference type and you'd be returning a reference to a local variable.

tl;dr: decltype(auto) is not always the same as auto&&.