Search code examples
c++perfect-forwardingstdtuplestd-invoke

Are value categories preserved inside a tuple (perfect forwarding through tuples)?


I wrote the following snippet to test if I could perfectly forward values through a tuple and std::invoke. However the generated assembly looks kind of odd.

Demo

#include <concepts>
#include <string>
#include <cstdio>
#include <tuple>
#include <functional>


auto foo(std::string str, std::string str2 = "temp")
{
    printf("Foo executed with %s and %s!\n", str.data(), str2.data());
}


template <typename Cb, typename... Args>
auto async(Cb&& fn, Args&&... args)
{
    constexpr std::size_t cnt = sizeof...(Args);
    auto tuple = std::make_tuple<Args...>(std::forward<Args>(args)...);

    std::invoke([&]<std::size_t... I>(std::index_sequence<I...>){
        foo(std::get<I>(tuple)...);
    }, std::make_index_sequence<cnt>{});
}


int main()
{
    async(&foo, std::string("mystring"), std::string("other"));
}

x86-46 gcc 12.2 -Wall -Os --std=c++20

Excerpt from line 30:

        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&) [complete object constructor]
        lea     rsi, [rsp+16]
        lea     rdi, [rsp+176]
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&) [complete object constructor]
        lea     rsi, [rsp+144]
        lea     rdi, [rsp+112]
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) [complete object constructor]
        lea     rsi, [rsp+176]
        lea     rdi, [rsp+80]
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) [complete object constructor]
        lea     rsi, [rsp+112]
        lea     rdi, [rsp+80]
        call    foo(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)

As you can see there's a call to the copy constructor of std::string. Where does it originate from? My suspicion is the call to foo() using std::get<I>(tuple) which doesn't seem to perfectly forward the values. In this regard I'm lacking understanding: How is "rvalue-ness" routed through a std::tuple? Is the value category preserved inside the tuple or is everything converted to either copy or lvalue-reference? Is there a chance to optimize this as well?

Clarification:

My goal is essentially to achieve perfect forwarding from the callsite of async() to foo(). That means value categories should be preserved as-is and no unnecessary copies should be made in between.

E.g. When I call async(&foo, std::string("string1"), std::string("string2")) it should invoke the move constructor for the strings constructed in foo's parameter list.

E.g. When I call async(&foo, str1, str2) with predefined strings they should be picked up as rvlaue-references instead and foo's parameters should be copy-constructed.


Solution

  • std::make_tuple makes a new tuple of values of the passed types. The result isn't a tuple storing references at all.

    If you want a tuple of references you need std::forward_as_tuple instead. If you save the result in a variable, you are of course responsible to make sure that the referenced objects outlive the result tuple.

    Also, neither std::make_tuple nor std::forward_as_tuple are supposed to be given template arguments explicitly. That will break their expected properties. (I guess for std::make_tuple it isn't that strict of a rule, but for std::forward_as_tuple it is.)

    // tuple is a `std::tuple` of references of appropriate kind
    // matching the value categories of `args`
    auto tuple = std::forward_as_tuple(std::forward<Args>(args)...);
    

    Furthermore, std::get will forward the value category only if you pass the tuple as rvalue. This means you need to use std::move explicitly. This prevents that an element referenced by the tuple can be moved without any explicit indication of that in the code:

    std::invoke([&]<std::size_t... I>(std::index_sequence<I...>){
        foo(std::get<I>(std::move(tuple))...);
    }, std::make_index_sequence<cnt>{});
    

    However, the standard library already has std::apply which does exactly this for you:

    std::apply(fn, std::move(tuple));
    

    Also, I assume that you need the tuple for something else as well, because otherwise you can simply do

    std::invoke(fn, std::forward<Args>(args)...);
    

    (And you probably want std::forward<Cb>(fn) instead of fn as well in case that is something more complex than a function pointer.)


    Also note that using async as name for your function is not a good idea. There is a function template with the same name, template head and function parameter list in the std namespace. As a consequence, whenever your async is called with a type being from or depending on the namespace std, it will be ambiguous with std::async in overload resolution because it is found via ADL.

    Therefore users would need to always make sure to call your async only by qualified name (which doesn't cause ADL). Otherwise the user's code may fail to compile randomly (because any standard library might or might not include std::async indirectly) and will certainly break any user that included <future>.

    See https://godbolt.org/z/zan7YorYT for a demonstration.