3

I want to return a tuple containing types like std::vector or std::unordered_map etc. where the objects may be large enough that I care about not copying. I wasn't sure how copy elision / return value optimization will work when the returned objects are wrapped in a tuple. To this end I wrote some test code below and am confused by parts of its output:

#include <tuple>
#include <iostream>

struct A {
    A() {}

    A(const A& a) {
        std::cout << "copy constructor\n";
    }

    A(A&& a) noexcept {
        std::cout << "move constructor\n";
    }

    ~A() {
        std::cout << "destructor\n";
    }
};

struct B {

};

std::tuple<A, B> foo() {

    A a;
    B b;
    return { a, b };
}

std::tuple<A, B> bar() {
    A a;
    B b;
    return { std::move(a), std::move(b) };
}

std::tuple<A, B> quux() {
    A a;
    B b;
    return std::move(std::tuple<A, B>{ std::move(a), std::move(b) });
}

std::tuple<A, B> mumble() {
    A a;
    B b;
    return std::move(std::tuple<A, B>{ a, b });
}

int main()
{  
    std::cout << "calling foo...\n\n";
    auto [a1, b1] = foo();
    std::cout << "\n";

    std::cout << "calling bar...\n\n";
    auto [a2, b2] = bar();
    std::cout << "\n";

    std::cout << "calling quux...\n\n";
    auto [a3, b3] = quux();
    std::cout << "\n";

    std::cout << "calling mumble...\n\n";
    auto [a4, b4] = mumble();
    std::cout << "\n";

    std::cout << "cleaning up main()\n";

    return 0;
}

when I run the above (on VS2019) I get the following output:

calling foo...
copy constructor
destructor

calling bar...
move constructor
destructor

calling quux...
move constructor
move constructor
destructor
destructor

calling mumble...
copy constructor
move constructor
destructor
destructor

cleaning up main()
destructor
destructor
destructor
destructor

So from the above is looks like bar() is best which is return { std::move(a), std::move(b) }. My main question is why foo() ends up copying? RVO should elide the tuple from being copied but shouldn't the compiler be smart enough to not copy the A struct? The tuple constructor could be a move constructor there since it is firing in an expression that is being returned from a function i.e. because struct a is about to not exist.

I also don't really understand what is going on with quux(). I didnt think that additional std::move() call was necessary but I don't understand why it ends up causing an additional move to actually occur i.e. I'd expect it to have the same output as bar().

jwezorek
  • 8,592
  • 1
  • 29
  • 46
  • In `foo`, `a` needs to be copied into the temporary `tuple` that will be returned to the caller. The copy on the return will be elided. – user4581301 Sep 08 '21 at 21:26
  • 1
    The standard describes which constructor is called under the given circumstances which means the compiler must not get too smart here. As for `quux`: first you do a move info the tuple object created and next by passing the tuple to `std::move` you made sure the move constructor of tuple is used to create the actual result; this calls the move constructor of `A`; by passing the tuple to `std::move` you prevented the copy ellision that would have taken place... – fabian Sep 08 '21 at 21:32
  • Note also that while the objects are large, their move constructors are fast because nothing is being copied. The references to the data are transferred from the source to the destination. – Raymond Chen Sep 08 '21 at 21:49
  • Related: [When should std::move be used on a function return value?](https://stackoverflow.com/q/14856344/11082165) (tl;dr: never) – Brian61354270 Sep 08 '21 at 21:55

1 Answers1

5

My main question is why foo() ends up copying? RVO should elide the tuple from being copied but shouldn't the compiler be smart enough to not copy the A struct? The tuple constructor could be a move constructor

No, move constructor could only construct it from another tuple<> object. {a,b} is constructing from the component types, so the A and B objects are copied.

what it going on with quux(). I didnt think that additional std::move() call was necessary but I don't understand why it ends up causing an additional move to actually occur i.e. I'd expect it to have the same output as bar().

The 2nd move happens when you are moving the tuple. Moving it prevents the copy elision that occurs in bar(). It is well-know that std::move() around the entire return expression is harmful.

Eugene
  • 6,194
  • 1
  • 20
  • 31