27 February, 2022

A look behind the scenes of mandatory copy elision.

Mandatory Copy Elision in C++

The term copy elision may seem a little strange, but it is an important part of C++ because it means that a copy can often be omitted (or elided) which in turn makes pass-by-value far cheaper and practical than it would be if copies were not elided.

This is highly desirable, so under some circumstances copy elision is mandatory.

According to cppreference.com, copy elision is mandatory…

  • “In a return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type.”
  • “In the initialization of an object, when the initializer expression is a prvalue of the same class type (ignoring cv-qualification) as the variable type.”

When copy elision occurs, copy constructors, move constructors, and destructors are omitted, even if they have demonstrable side-effects.

What does this mean in practice, and how does it work?

Demonstrating Copy Elision

Here is a class, Elide. It has a constructor, a copy constructor, and a destructor.

class Elide
{
public:
    Elide();                // Constructor.
    Elide(const Elide& e);  // Copy constructor.
    ~Elide();               // Destructor.

private:
    int x_;
    int y_;
    int z_;
};

As a side effect, each function writes a message to stdout when called.

Elide::Elide() : x_{1}, y_{2}, z_{3}
{
    std::cout << "\tElide::Elide();\n"; // Side effect.
}

Elide::Elide(const Elide& e)
{
    std::cout << "\tElide::Elide(const Elide& e);\n"; // Side effect.
    x_ = e.x_;
    y_ = e.y_;
    z_ = e.z_;
}

Elide::~Elide()
{
    std::cout << "\tElide::~Elide();\n"; // Side effect.
}

The following function, MakeElide(), creates and returns an instance of Elide. It does so via copy-elision, because this is a “return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type.”

Elide MakeElide()
{
    return Elide();
}

Demonstration

We can see the effects of copy elision when using Elide by running the following code…

{
    Elide e1;                             // Calls Elide's constructor only.
    Elide e2(MakeElide());                // Calls Elide's constructor only. Copy elision occurs.
    Elide e3(e2);                         // Calls Elide's copy constructor.
    Elide e4 = e3;                        // Calls Elide's copy constructor.
    Elide e5 = Elide(Elide(MakeElide())); // Calls Elide's constructor only. Copy elision occurs.
}

Here’s the output.

        Elide::Elide();
        Elide::Elide();
        Elide::Elide(const Elide& e);
        Elide::Elide(const Elide& e);
        Elide::Elide();
        Elide::~Elide();
        Elide::~Elide();
        Elide::~Elide();
        Elide::~Elide();
        Elide::~Elide();

Explanation

So, what happened here? Let’s take the statements one at a time.

This runs the default constructor only. No surprises here.

    Elide e1;

This also runs the default constructor only, even though it looks as if copy construction is taking place. Instead, we get copy-elision because “the intializer expression is a prvalue value of the same class type (ignoring cv-qualification) as the variable type.”

    Elide e2(MakeElide());

Here we get to see the copy constructor run, because e2 is an lvalue, not a prvalue. The requirements for mandatory copy elision are not met.

    Elide e3(e2);

It runs here again, because Elide doesn’t have a copy assignment operator, and e3 is an lvalue expression.

    Elide e4 = e3;

This runs the default constructor only, and it runs it just once, because copy elision is taking place.

    Elide e5 = Elide(Elide(MakeElide()));

Finally, the destructors run when e1, e2, e3, e4, and e5 go out of scope.

        Elide::~Elide();
        Elide::~Elide();
        Elide::~Elide();
        Elide::~Elide();
        Elide::~Elide();

Behind the scenes

We can look at the generated assembly language to understand this better. In the example below, gcc has been used to compile it to x64, with no optimization otherwise there wouldn’t be much to see.

Here’s some code that initializes e2 with the result of a call to MakeElide().

    Elide e2(MakeElide());                // Calls Elide's constructor only. Copy elision occurs.

Here’s the corresponding assembly language. It is using the System V AMD64 ABI, so the first (hidden) parameter to MakeElide() is passed in RDI. Looking at the following code, when MakeElide() is called, RDI holds the address of e2.

    lea     rax, [rbp-40]   ; put the address of e2 into RAX
    mov     rdi, rax        ; copy it into RDI, so now RDI holds the address of e2
    call    MakeElide()     ; call MakeElide

Here’s MakeElide() itself.

Elide MakeElide()
{
    return Elide();
}

Here’s the corresponding assembly language. When MakeElide() was called above, RDI held the address of e2, and here MakeElide() passes it through to Elide’s constructor.

    push    rbp
    mov     rbp, rsp
    sub     rsp, 16    
    mov     QWORD PTR [rbp-8], rdi  ; save RDI (which holds the address of e2) on the stack
    mov     rax, QWORD PTR [rbp-8]
    mov     rdi, rax                ; RDI holds the address of e2
    call    Elide::Elide()          ; call Elide's constructor with RDI holding the address of e2
    mov     rax, QWORD PTR [rbp-8]
    leave
    ret          

Here’s Elide’s constructor.

Elide::Elide() : x_{1}, y_{2}, z_{3}
{
    std::cout << "\tElide::Elide();\n"; // Side effect.
}

Here’s the corresponding assembly language. As before, RDI holds the address of e2, so the following code directly initializes e2 and no copy takes place. In other words, the copy was elided.

    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     QWORD PTR [rbp-8], rdi  ; save RDI on the stack
    
    ; x_{1}
    mov     rax, QWORD PTR [rbp-8]
    mov     DWORD PTR [rax], 1
    
    ; y_{2}
    mov     rax, QWORD PTR [rbp-8]
    mov     DWORD PTR [rax+4], 2
    
    ; z_{3}
    mov     rax, QWORD PTR [rbp-8]  ; z_{3}
    mov     DWORD PTR [rax+8], 3 

    ; std::cout << "\t...";
    mov     esi, OFFSET FLAT:.LC0
    mov     edi, OFFSET FLAT:_ZSt4cout
    call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)   

    nop
    leave
    ret
}

Conclusion

Mandatory copy elision makes pass-by-value in C++ practical and efficient as it allows objects to be constructed directly by omitting the copy. Without it, pass-by-value would be far less practical.

Further Reading

For more information, follow these links: