DEV Community

Cover image for Dealing with non-copyable objects - (C++ Tutorial)
Daniel Brétema
Daniel Brétema

Posted on • Edited on

Dealing with non-copyable objects - (C++ Tutorial)

Sometimes you need to take decisions and decisions always have consequences.

The story begin with an object like this:

// Foo.h

struct Foo
{
  Foo(int v) : val(v) {};
  ~Foo() = default;

  // Copy semantics : OFF
  Foo(Foo const &) = delete;
  Foo& operator=(Foo const &) = delete;

  // Move semantics : ON
  Foo(Foo &&) = default;
  Foo& operator=(Foo &&) = default;

  int val { 7 };
};
Enter fullscreen mode Exit fullscreen mode

Cool, right? The object defines its intention of disallow the copy of itself, forcing the use of move semantics whenever it is possible. And that's the key whenever it is possible:

// main.cpp

#include "Foo.h"

#include <vector>
#include <unordered_map>

int main()
{
    // Instances
  Foo a{1}, b{2};
  //b = a; // Nope
  b = std::move(a); // Yes

  // On vector
  std::vector<Foo> v1;
  v1.emplace_back( Foo(1) ); // Yes
  // v1.emplace_back( b ); // Nope
  v1.emplace_back( std::move(b) ); // Yes
  // std::vector<Foo> v2 { Foo(1), Foo(2), Foo(3) }; // Nope

  // On unordered_map
  std::unordered_map<int, Foo> um1;
  um1.emplace( 0, Foo(0) ); // Yes
  // std::unordered_map<int, Foo> um2 { {0, Foo(0)}, {1, Foo(1)} }; // Nope
}
Enter fullscreen mode Exit fullscreen mode

Some of you may already get the issue. The comfy brace-initializer needs copy-constructor. But beyond that, you could face a situation like this one:

// Bar.h

#include "Foo.h"
#include <vector>

struct Bar {
  Bar() {
    for(int i=0; i < 3; ++i) {
      foos.emplace_back( Foo(i) );
      // foos.emplace_back( i ); // Makes the same as above
    }
  }
  std::vector<Foo> foos { }; // Yes
  // std::vector<Foo> foos { Foo(1), Foo(2) }; // Nope

  /*
   * Rule of Zero, so this object should be:
   * default copyable
   * default movable
   * default destructor
    * BUT: 'foos' contained data is not copyable :(
  */
};
Enter fullscreen mode Exit fullscreen mode

And now update the main.cpp:

// main.cpp

#include "Bar.h"
#include <vector>

int main()
{
    // Instances
  Bar a, b;
  // a = b; // Nope
  b = std::move(a); // Yes

  // On vector
  std::vector<Bar> v1;
  v1.emplace_back( Bar() ); // Yes
  // std::vector<Bar> v2 { Bar() }; // Nope
}
Enter fullscreen mode Exit fullscreen mode

So the issue here... If you read Bar declaration you may think that it implicitly define copy-semantics, because it is not explicitly deleted.

But as it has a vector of Foo objects and this has explicitly deleted copy-semantics, Bar has implicitly deleted its copy-semantics!

The simplest and more communicative solution is that whenever you need many references to the same Foo object, use a shared_ptr<Foo>. This gives you a reference counting mechanism out-of-the-box with a name that describe its intention.

To this, suppose the following modification of Bar:

// BarShared.h

struct BarShared {
  BarShared() {
    for(int i=0; i < 3; ++i) {
      foos.emplace_back( std::make_shared<Foo>(i) );
      // foos.emplace_back( i ); // Same as above but val==0
      // foos.emplace_back( i ); // Nope
    }
  }
  std::vector<std::shared_ptr<Foo>> foos; // Yes
  std::vector<std::shared_ptr<Foo>> foos3 { std::make_shared<Foo>(5), std::make_shared<Foo>(6) }; // Yes

  /*
   * Rule of Zero, so this object should be:
   * default copyable
   * default movable
   * default destructor
     * NOW: 'foos' contained data is copyable :)
  */
};
Enter fullscreen mode Exit fullscreen mode

The cons of this approach is the use of heap and the mandatory of write std::make_shared<Foo>.

If you couldn't make use of heap... You should implement the copy-semantics and manage in global scope that ref-counting mechanism, using something like unordered_map<int,int> where the key will be an object UUID and value the active references to increment on copy-semantics and decrement on object destructor.

Using that design, relaying the copy responsibility to shared_ptr, we are able to achieve a class that behave as we expect and transmit its intentions.

// main.cpp

#include "Bar.h"
#include <vector>

int main()
{
    // Instances
  BarShared a, b;
  a = b; // Yes
  b = std::move(a); // Yes

  // On vector
  std::vector<BarShared> v1;
  v1.emplace_back( BarShared() ); // Yes
  std::vector<BarShared> v2 { BarShared() }; // Yes
}
Enter fullscreen mode Exit fullscreen mode

We can even add some functions to BarShared like addFoo(int) or removeFoo(int) to manage the internal container and avoid the constant repetition of std::make_shared<Foo>

Code playground: https://www.mycompiler.io/new/cpp?fork=C6I0o7J

That's my first post, so any feedback is welcome ^^

Top comments (0)