C.M. C.M. - 1 year ago 80
C++ Question

Weird behavior when passing argument by value

Stumbled upon few articles claiming that passing by value could improve performance if function is gonna make a copy anyway.

I never really thought about how pass-by-value might be implemented under the hood. Exactly what happens on stack when you do smth like this: F v = f(g(h()))?

After pondering a bit I came to conclusion that I'd implement it in such way that value returned by g() is created in locations where f() expects it to be. So, basically, no copy/move constructor calls -- f() will simply take ownership of object returned by g() and destroy it when execution leaves f()'s scope. Same for g() -- it'll take ownership of object returned by h() and destroy it on return.

Alas, compilers seem to disagree. Here is the test code:

#include <cstdio>

using std::printf;

struct H
H() { printf("H ctor\n"); }
~H() { printf("H dtor\n"); }
H(H const&) {}
// H(H&&) {}
// H(H const&) = default;
// H(H&&) = default;

H h() { return H(); }

struct G
G() { printf("G ctor\n"); }
~G() { printf("G dtor\n"); }
G(G const&) {}
// G(G&&) {}
// G(G const&) = default;
// G(G&&) = default;

G g(H) { return G(); }

struct F
F() { printf("F ctor\n"); }
~F() { printf("F dtor\n"); }

F f(G) { return F(); }

int main()
F v = f(g(h()));
return 0;

On MSVC 2015 it's output is exactly what I expected:

H ctor
G ctor
H dtor
F ctor
G dtor
F dtor

But if you comment out copy constructors it looks like this:

H ctor
G ctor
H dtor
F ctor
G dtor
G dtor
H dtor
F dtor

I suspect that removing user-provided copy constructor causes compiler to generate move-constructor, which in turn causes unnecessary 'move' which doesn't go away no matter how big objects in question are (try adding 1MB array as member variable). I.e. compiler prefers 'move' so much that it chooses it over not doing anything at all.

It seems like a bug in MSVC, but I would really like someone to explain (and/or justify) what is going on here. This is question #1.

Now, if you try GCC 5.4.0 output simply doesn't make any sense:

H ctor
G ctor
F ctor
G dtor
H dtor
F dtor

H has to be destroyed before F is created! H is local to g()'s scope! Note that playing with constructors has zero effect on GCC here.

Same as with MSVC -- looks like a bug to me, but can someone explain/justify what is going on here? That is question #2.

It is really silly that after many years of working with C++ professionally I run into issues like this... After almost 4 decades compilers still can't agree on how to pass values around?

Answer Source

Both M.M's and Ahmad's answers were sending me in right direction, but they both weren't fully correct. So I opted to write down a proper answer below...

  • function call and return in C++ has following semantic:
    • value passed as function argument gets copied into function scope and function gets invoked
    • return value gets copied into caller's scope, gets destroyed (when we reach end of return full expression) and execution leaves function scope

When it comes to implementing this on IA-32-like architecture it becomes painfully obvious that these copies are not required -- it is trivial to allocate uninitialized space on stack (for return value) and define function calling conventions in such way that it knows where to construct return value.

Same for argument passing -- if we pass rvalue as function argument, compiler can direct creation of that rvalue in such way that it will be created right were (subsequently called) function expects it to be.

I imagine this is main reason why copy elision was introduced to standard (and is made mandatory in C++17).

I am familiar with copy elision in general and read this page before. Unfortunately I missed two things:

  1. the fact that this also applies to initialization of function arguments with rvalue (C++11 12.8.p32):

when a temporary class object that has not been bound to a reference (12.2) would be copied/moved to a class object with the same cv-unqualified type, the copy/move operation can be omitted by constructing the temporary object directly into the target of the omitted copy/move

  1. when copy elision kicks in it affects object lifetime in a very peculiar way:

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the copy/move constructor and/or destructor for the object have side effects. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object, and the destruction of that object occurs at the later of the times when the two objects would have been destroyed without the optimization. This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies)

This explains GCC output -- we pass some rvalue into a function, copy elision kicks in and we end up with one object being referred via two different ways and lifetime = longest of all of them (which is a lifetime of temporary in our F v = ...; expression). So, basically, GCC output is completely standard compliant.

Now, this also means that MSVC is not standard compliant! It successfully applied both copy elisions, but resulting object lifetime is too short.

Second MSVC output conforms the standard -- it applied RVO, but decided to not apply copy elision for function parameter. I still think it is a bug in MSVC, even though code is ok from standard point of view.

Thank you both M.M and Ahmad for pushing me in right direction.

Now little rant about lifetime rule enforced by standard -- I think it was meant to be used only with RVO.

Alas it doesn't make a lot of sense when applied to eliding copy of function argument. In fact, combined with C++17 mandatory copy elision rule it permits crazy code like this:

T bar();
T* foo(T a) { return &a; }

auto v = foo(bar())->my_method();

this rule forces T to be destroyed only at the end of full expression. This code will become correct in C++17. It is ugly and should not be allowed in my opinion. Plus, you'll end up destroying these objects on caller side (instead of inside of a function) -- needlessly increasing code size and complicating process of figuring out if given function is a nothrow or not.

In other words, I personally prefer MSVC output #1 (as most 'natural'). Both MSVC output #2 and GCC output should be banned. I wonder if this idea can be sold to C++ standardization committee...

edit: apparently in C++17 lifetime of temporary will become 'unspecified' thus allowing MSVC's behavior. Yet another unnecessary dark corner in the language. They should have simply mandated MSVC's behavior.

Recommended from our users: Dynamic Network Monitoring from WhatsUp Gold from IPSwitch. Free Download