Copy Semantics, Elision, and RVO

Learn how to implement copy semantics in C++, and take advantage of copy elision and return value optimization (RVO)
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

7b.jpg
Ryan McCombe
Ryan McCombe
Posted

When we’re dealing with dynamic memory and smart pointers, interesting questions and problems soon begin to arise.

Consider the following setup. We have Character objects who carry Sword objects. When a character is created, it creates a sword for itself, and it dutifully cleans up the sword when it gets destroyed:

struct Sword {};

class Character {
 public:
  Character() : Weapon{new Sword{}} {};
  ~Character() { delete Weapon; }

  Sword* Weapon;
};

Problems arise when we try to use this simple class. What happens if we copy it?

#include <iostream>

struct Sword {};

class Character {
 public:
  Character() : Weapon{new Sword{}} {};
  ~Character() { delete Weapon; }

  Sword* Weapon;
};

int main() {
  Character A{};
  Character B{A};

  std::cout << "A's Weapon: " << A.Weapon;
  std::cout << "\nB's Weapon: " << B.Weapon;
}
A's Weapon: 000002894CBD6F90
B's Weapon: 000002894CBD6F90

Our two characters are sharing the same sword.

Shallow Copying

This behavior is generally referred to as shallow copying. With shallow copying, only the properties on the “surface” are copied. So, we copy the pointer to the Sword but below the surface, the sword itself wasn’t copied.

When one of our characters gets deleted, the shared weapon is also deleted. Then, the other character has a pointer to an object that, unbeknownst to it, no longer exists. This is referred to as a dangling pointer, and it’s a problem that we generally try to avoid.

On some machines, this program will also crash right before it exits, suggesting a second problem. Specifically, both characters are trying to free the same memory address from their destructor. That works fine when the first character is deleted, but when the second character is being removed, it will try to free a pointer that had already been deleted. This is a double-free error, which will typically result in a crash.

Trying Shared Pointers

We could try switching our implementation to use a shared pointer instead. This solves our crashing problem:

#include <iostream>
#include <memory>

struct Sword {};

class Character {
 public:
  Character()
      : Weapon{std::make_shared<Sword>()} {};

  std::shared_ptr<Sword> Weapon;
};

int main() {
  Character A{};
  Character B{A};

  std::cout << "A's Weapon: " << A.Weapon;
  std::cout << "\nB's Weapon: " << B.Weapon;
}

But, it doesn’t solve the root problem - our characters are still sharing the same weapon:

A's Weapon: 000002E2E7C944E0
B's Weapon: 000002E2E7C944E0

Trying Unique Pointers

In our lesson on memory ownership, we saw that weak pointers take steps to try to prevent themselves from being copied.

As such, when we try to implement our above code using weak pointers, the compiler will throw an error:

#include <iostream>
#include <memory>

struct Sword {};

class Character {
 public:
  Character()
      : Weapon{std::make_unique<Sword>()} {};

  std::unique_ptr<Sword> Weapon;
};

int main() {
  Character A{};
  Character B{A};
}
'Character::Character(const Character &)':
attempting to reference a deleted function

This error is somewhat indirect - it does not mention unique pointers. Instead, it is telling us that it is trying to find a Character constructor that takes a reference to another Character and that the function has been deleted.

The first part of that error does make sense - the line in our code that is causing the error is Character B{A}. This expression is indeed trying to construct a Character by passing a reference to another Character.

The constructor that handles code like this is referred to as the copy constructor. We haven’t defined one before, but code like this has generally worked anyway. This is because our classes come with default copy constructors.

However, now that we’ve added a unique pointer to our class, and unique pointers can’t be copied, the default copy constructor can no longer help us. That explains the second part of the error - the default copy constructor has been deleted.

So, we need to step in and define what it means to copy a Character.

Copy Semantics

The process of defining what it means for our objects to be copied is often referred to as implementing copy semantics.

In the previous example, we are constructing a Character by passing a reference to an existing Character. As with any construction, this calls a constructor on our class, specifically the copy constructor.

In the past, when we hadn’t defined a copy constructor, the compiler was calling a default one. That constructor was implementing the shallow copy behavior we were seeing.

Let's start to implement copy semantics, by implementing our copy constructor, thereby defining how our objects should be copied:

#include <iostream>
#include <memory>

struct Sword {};

class Character {
 public:
  Character()
      : Weapon{std::make_unique<Sword>()} {};

  Character(const Character& Source) {
    std::cout << "I'm being copied!";
  }

  std::unique_ptr<Sword> Weapon;
};

int main() {
  Character A{};
  Character B{A};

  std::cout << "\nA's Weapon: " << A.Weapon;
  std::cout << "\nB's Weapon: " << B.Weapon;
}

Within our copy constructor, we’re constructing our new object, and we have a reference to our source object from which we can grab what we need. In the above constructor, we’re creating B, and Source is a reference to A.

Typically we don’t want to modify the source object when copying it, so we mark that reference as const.

Our code now compiles, although our new object doesn't have a Weapon:

I'm being copied!
A's Weapon: 000001A6C70170D0
B's Weapon: 0000000000000000

Let's update our copy constructor so our new Character gets their own Weapon:

#include <iostream>
#include <memory>

struct Sword {};

class Character {
 public:
  Character()
      : Weapon{std::make_unique<Sword>()} {};

  Character(const Character& Source)
      : Weapon{std::make_unique<Sword>()} {
    std::cout << "I'm being copied!";
  }

  std::unique_ptr<Sword> Weapon;
};

int main() {
  Character A{};
  Character B{A};

  std::cout << "\nA's Weapon: " << A.Weapon;
  std::cout << "\nB's Weapon: " << B.Weapon;
}
I'm being copied!
A's Weapon: 00000135B3CF6D90
B's Weapon: 00000135B3CF6E10

Implementing Deep Copying

The previous example gives our new Character its own new Weapon, but that might not be exactly what we want. Our second character's weapon isn’t copying the values from the original character’s weapon. Consider this example:

#include <iostream>
#include <memory>

struct Sword {
  int Damage{10};
};

class Character {
 public:
  Character()
      : Weapon{std::make_unique<Sword>()} {};

  Character(const Character& Source)
      : Weapon{std::make_unique<Sword>()} {}

  std::unique_ptr<Sword> Weapon;
};

int main() {
  Character A{};

  A.Weapon->Damage = 20;

  Character B{A};

  std::cout << "\nA's Weapon: " << A.Weapon;
  std::cout << "\nA's Weapon Damage: "
            << A.Weapon->Damage;
  std::cout << "\nB's Weapon: " << B.Weapon;
  std::cout << "\nB's Weapon Damage: "
            << B.Weapon->Damage;
}
A's Weapon: 00000244F4126410
A's Weapon Damage: 20
B's Weapon: 00000244F4126FD0
B's Weapon Damage: 10

We modified A's weapon damage to 20 before we created a copy, but the copy had the default value of 10. This is because, within the Character's copy constructor, we’re not passing any arguments when we create the Weapon. Therefore, we’re creating the weapon using the default constructor:

Character(const Character& Source)
      : Weapon{std::make_unique<Sword>()} {}

We may instead what to call the copy constructor of the Weapon too, by passing in a reference to the source character’s weapon:

Character(const Character& Source)
    : Weapon{std::make_unique<Sword>(
          *Source.Weapon)} {}

The Weapon class isn’t using any unique pointers, so it still has its default copy constructor in place. Our code now looks like this:

#include <iostream>
#include <memory>

struct Sword {
  int Damage{10};
};

class Character {
 public:
  Character()
      : Weapon{std::make_unique<Sword>()} {};

  Character(const Character& Source)
      : Weapon{std::make_unique<Sword>(
            *Source.Weapon)} {}

  std::unique_ptr<Sword> Weapon;
};

int main() {
  Character A{};

  A.Weapon->Damage = 20;

  Character B{A};

  std::cout << "\nA's Weapon: " << A.Weapon;
  std::cout << "\nA's Weapon Damage: "
            << A.Weapon->Damage;
  std::cout << "\nB's Weapon: " << B.Weapon;
  std::cout << "\nB's Weapon Damage: "
            << B.Weapon->Damage;
}
A's Weapon: 0000021852C570D0
A's Weapon Damage: 20
B's Weapon: 0000021852C57110
B's Weapon Damage: 20

We’ve now deeply copied our Character. Our new Character's weapon has the same values as the source Character's weapon. But, they are different weapons. Each Character now has its own, which can subsequently be updated and deleted independently.

Copy Assignment Operator

Copy construction isn’t the only way we can copy objects. We can also create copies with the assignment operator, =. Let's make a small adjustment to our main function to create an example of that:

int main() {
  Character A{};
  A.Weapon->Damage = 20;
  Character B;
  B = A;
}

Once again we’re getting a compiler error, which sounds similar to what we had before:

'&Character::operator =(const Character &)':
attempting to reference a deleted function

Similar to the copy constructor, classes come with a copy assignment operator by default, which implements shallow copying. But, given our class contains a unique pointer that cannot be copied, we now have to extend our copy semantics to implement the copy operator too.

The copy operator receives a reference to the object to be copied. Typically, we don’t want to modify the original object, so that reference will be const.

Our function needs to return a reference to itself, which we can do via the this pointer.

Beyond these considerations, the implementation of copy operators tends to be very similar to copy constructors:

Character& operator=(
    const Character& Source) {
  Weapon =
      std::make_unique<Sword>(*Source.Weapon);

  return *this;
}

If the above code is unclear, we have a two-part lesson on operator overloading which is likely to be helpful:

Our objects should now work as expected, regardless of how they are copied:

#include <iostream>
#include <memory>

struct Sword {
  int Damage{10};
};

class Character {
 public:
  Character()
      : Weapon{std::make_unique<Sword>()} {};

  Character(const Character& Source)
      : Weapon{std::make_unique<Sword>(
            *Source.Weapon)} {}

  Character& operator=(
      const Character& Source) {
    Weapon =
        std::make_unique<Sword>(*Source.Weapon);

    return *this;
  }

  std::unique_ptr<Sword> Weapon;
};

int main() {
  Character A{};
  A.Weapon->Damage = 20;
  Character B;
  B = A;

  std::cout << "A's Weapon: " << A.Weapon;
  std::cout << "\nA's Weapon Damage: "
            << A.Weapon->Damage;
  std::cout << "\nB's Weapon: " << B.Weapon;
  std::cout << "\nB's Weapon Damage: "
            << B.Weapon->Damage;
}
A's Weapon: 000001F5879668D0
A's Weapon Damage: 20
B's Weapon: 000001F587966F50
B's Weapon Damage: 20

Objects Copying to Themselves

There is an edge case where both operands to the copy operator can be the same object:

int main() {
  Character A{};
  A = A;
}

This is quite an unusual expression, but it is valid code, and it means we can’t assume that the argument passed to the copy operator is different from the object we’re updating. If the logic in our operator is predicated on that, we are leaving a landmine in our code.

We can test whether the objects are the same by comparing the address of the incoming object to the this pointer:

#include <iostream>

class Character {
 public:
  Character& operator=(
      const Character& Source) {
    if (this == &Source) {
      std::cout << "Objects are the same\n";
    } else {
      std::cout << "Objects are different\n";
    }
    return *this;
  }
};

int main() {
  Character A{};
  A = A;

  Character B;
  B = A;
}
Objects are the same
Objects are different

Copy Semantics and Pass-by-Value

Copy semantics also come into play when we have code that implicitly creates copies of our object. Typically, this involves scenarios where we’re passing our objects around by value.

The main scenario where this happens is when we’re passing an object by value into a function parameter. There, the copy constructor is used to create the object that our function receives.

Below, we’ve defined a copy constructor for our class. We’re passing by value twice in the following code - once when we pass A into our function parameter B, and again when that function is returning B

The exact behavior of the following code can vary based on the compiler, but for me, the copy constructor is used twice, and we have three different objects throughout the lifecycle of our program:

#include <iostream>

struct MyStruct {
  MyStruct() = default;
  MyStruct(const MyStruct& Source) {
    std::cout << "\nCopying " << &Source
              << " to " << this;
  }
};

MyStruct Function(MyStruct B) {
  return B;
};

int main() {
  MyStruct A{};
  Function(A);
}
Copying 000000448D99FC34 to 000000448D99FD14
Copying 000000448D99FD14 to 000000448D99FD54

These two instructions requiring three different copies of our object in memory seem like a waste of resources.

In many cases, this is true - particularly if those objects are complex, and the act of deeply copying them is an elaborate process.

In the next lesson, we’ll see how we can implement move semantics, which allows us to make our classes more efficient.

Copy Elision and Return Value Optimisation (RVO)

Creating copies of our objects always has a performance overhead, so eliminating unnecessary copies is desirable. Compilers can help us here, through what is known as copy elision.

In the following example, likely, our MyStruct object is never copied. In almost all compilers, our std::cout message will not be displayed:

#include <iostream>

struct MyStruct {
  MyStruct() = default;
  MyStruct(const MyStruct& Source) {
    std::cout << "Copying";
  }
};

MyStruct GetStruct() {
  MyStruct Example{};
  return Example;
};

int main() {
  GetStruct();
}

When the compiler sees our Example struct being created in our GetStruct function, it understands where in memory our object is going to end up. From looking at the return value, Example is guaranteed to end up in the stack frame of main. So it just creates it there directly.

As a result, when we reach the return statement, we no longer need to copy our object from the GetStruct stack frame to the main stack frame - it was in the main stack frame the entire time. In this case, the compiler has avoided (or elided) the unnecessary copy. This is an example of return value optimization or RVO.

With a subtle change to our program, the compiler may no longer be able to invoke this form of RVO. Depending on the compiler, the following program likely creates both objects in the GetStruct frame and then copies the chosen one back to main.

This would trigger a single copy operation, noted by the output to the console:

#include <iostream>

struct MyStruct {
  MyStruct() = default;
  MyStruct(const MyStruct& Source) {
    std::cout << "Copying";
  }
};

MyStruct GetStruct(bool a) {
  MyStruct ExampleA{};
  MyStruct ExampleB{};
  return a ? ExampleA : ExampleB;
};

int main() {
  GetStruct(true);
}
Copying

The fact that a compiler can choose whether or not to call one of our functions is likely making some feel uneasy. The compiler making our program more performant is great, but it is also changing the behavior in this case.

Our copy constructor has a side-effect: it writes things to the console. How is it reasonable that the compiler can sometimes change the behavior of our program?

In this case, the compilers are doing what the C++ standard asks of them. The rules state that the compilers can (and in some cases, must) eliminate unnecessary copying, even if the copy constructor has side effects like updating global variables or outputting messages.

Because we don’t always know when copies are going to be created, it’s important that we only use the copy constructor for its specific purpose: copying objects.

If the compiler avoids creating a copy, any arbitrary code we had in a copy constructor will not run. But, if our constructor's only purpose is to copy objects, and no objects need to be copied, there’s no issue. We can enjoy the performance benefits the compiler gave us by not running it.

This same recommendation applies to destructors. When we have fewer copies, we also have fewer objects to destroy, so copy elision also indirectly reduces the number of destructors being run.

Was this lesson useful?

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Memory Management
7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access!

This course includes:

  • 106 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Move Semantics, std::move and rvalues

Learn how to implement move semantics in C++, the difference between lvalues and rvalues, and what std::move really does.
73.jpg
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved