Example: A Simple Container

We want to combine what we learned about allocating memory and move semantics into a class that exercises both.

Note

We could rewrite our Array class to manage its own memory via a unique_ptr, but here we do something simpler.

A simple container: attempt I

Here’s a simple container that manages its own memory (dynamic allocation on the heap), via a unique_ptr—this is a pointer that the compiler will automatically clean up after once it goes out of scope.

Tip

We previously used std::vector for this role, and that remains a good option. Here’s a discussion on unique pointers vs arrays.

First the class:

Listing 96 container_basic.H
#ifndef CONTAINER_H
#define CONTAINER_H

#include <iostream>
#include <fstream>
#include <memory>
#include <cstring>

class Container {

private:

    // this is a simple container class that
    // just stores a 1-d array buffer

    int _size;
    std::unique_ptr<double []> _data;

public:

    // default constructor

    Container()
        : _size(0), _data(nullptr)
    {std::cout << "in default constructor" << std::endl;}

    // parameteric constructor

    Container(int size)
        : _size(size)
    {

        // allocate a 1-d array with size elements
        // this is placed on the heap and managed by
        // a unique pointer

        _data = std::make_unique<double []> (size);

        std::cout << "in parametric constructor" << std::endl;

    }


    // getters

    int get_size() {return _size;}

    // we use the get() function on the unique_ptr to get the pointer to
    // the data that it is managing.

    double* get_data() {return _data.get();}

    // operators

    friend std::ostream& operator<< (std::ostream& os, const Container& c);

};

std::ostream& operator<< (std::ostream& os, const Container& c) {
    for (int i = 0; i < c._size; ++i) {
        os << c._data.get()[i] << " ";
    }
    os << std::endl;
    return os;
}

#endif

Now a simple driver that creates a Container and then creates a new one via copying:

Listing 97 test_copy.cpp
#include <iostream>

#include "container.H"

int main() {

    Container a(10);

    for (int i = 0; i < a.get_size(); ++i) {
        a.get_data()[i] = static_cast<double>(i);
    }

    std::cout << a << std::endl;

    auto b = a;

    std::cout << "resetting b to 0" << std::endl;

    for (int i = 0; i < b.get_size(); ++i) {
        b.get_data()[i] = 0.0;
    }

    std::cout << "contents of b" << std::endl;

    std::cout << b << std::endl;

    std::cout << "contents of a" << std::endl;

    std::cout << a << std::endl;

}

When we try to compile this, we get:

test_copy.cpp: In function ‘int main()’:
test_copy.cpp:15:14: error: use of deleted function ‘Container::Container(const Container&)’
   15 |     auto b = a;
      |              ^
In file included from test_copy.cpp:3:
container_basic.H:9:7: note: ‘Container::Container(const Container&)’ is implicitly deleted because the default definition would be ill-formed:
    9 | class Container {
      |       ^~~~~~~~~
container_basic.H:9:7: error: use of deleted function ‘std::unique_ptr<_Tp [], _Dp>::unique_ptr(const std::unique_ptr<_Tp [], _Dp>&) [with _Tp = double; _Dp = std::default_delete<double []>]’
In file included from /usr/include/c++/11/memory:76,
                 from container_basic.H:6,
                 from test_copy.cpp:3:
/usr/include/c++/11/bits/unique_ptr.h:723:7: note: declared here
  723 |       unique_ptr(const unique_ptr&) = delete;
      |       ^~~~~~~~~~

Here the compiler is telling us that for this class, it the default copy constructor would not be correct, so it doesn’t create one. Therefore we need to explicitly write one.

A simple container: attempt II

What if we try to implement the copy constructor as:

Container(const Container& c)
    : _size(c._size), _data(c._data.get()) {}

Here we are using member list initialization to set _data to be the same pointer as c._data—the .get() function on a unique_ptr returns the underlying pointer to the data.

If we add this, and run with it, we will see:

in parametric constructor
0 1 2 3 4 5 6 7 8 9

0 0 0 0 0 0 0 0 0 0

0 0 0 0 0 0 0 0 0 0

free(): double free detected in tcache 2
Aborted (core dumped)

Two things happened here: first we did a shallow-copy of the data. The two objects shared the same underlying data region, so anything we did to one was reflected in the other. And second, when the destructor was called at the very end, it tried to free the data pointer twice.

A simple container: attempt III

We want the copy constructor to do a deep copy—it should create its own memory space and copy the data, element-by-element from the input Container to the new one.

C++ provides a memcpy function to copy from one buffer to another. We’ll use that.

Here’s a final implementation of the class. This also implements the move operations, which we’ll talk about in a minute.

Listing 98 container.H
#ifndef CONTAINER_H
#define CONTAINER_H

#include <iostream>
#include <fstream>
#include <memory>
#include <cstring>

class Container {

private:

    // this is a simple container class that
    // just stores a 1-d array buffer

    int _size;
    std::unique_ptr<double []> _data;

public:

    // default constructor

    Container()
        : _size(0), _data(nullptr)
    {std::cout << "in default constructor" << std::endl;}

    // parameteric constructor

    Container(int size)
        : _size(size)
    {

        // allocate a 1-d array with size elements
        // this is placed on the heap and managed by
        // a unique pointer

        _data = std::make_unique<double []> (size);

        std::cout << "in parametric constructor" << std::endl;

    }

    // destructor -- since we are using a unique ptr to
    // manage our data, there is nothing to do

    ~Container() {std::cout << "in destructor" << std::endl;}

    // copy constructor

    Container(const Container& c)
        : _size{c._size}
    {

        std::cout << "in copy constructor" << std::endl;

        // reset the data buffer

        _data = std::make_unique<double []> (_size);

        // copy the data over from c to this
        // this has the form: destination, source, number of bytes
        std::memcpy(_data.get(), c._data.get(),
                    _size * sizeof(double));

    }

    // assignment operator

    Container& operator= (const Container& c) {

        std::cout << "in assignment operator" << std::endl;

        // make sure we are not assigning ourselves

        if (this != &c) {

            _size = c._size;

            _data = std::make_unique<double []> (_size);

            // copy the data over from c to this
            // this has the form: destination, source, number of bytes
            std::memcpy(_data.get(), c._data.get(),
                        _size * sizeof(double));

        }

        return *this;
    }

    // move constructor

    Container(Container&& c) noexcept
        : _size(0), _data(nullptr)
    {
        std::cout << "in move constructor" << std::endl;

        std::swap(_size, c._size);

        // swap the data pointers

        _data.swap(c._data);
    }

    // move assignment

    Container& operator= (Container&& c) noexcept {

        std::cout << "in move assignment" << std::endl;

        std::swap(_size, c._size);

        // swap the data pointers

        _data.swap(c._data);

        return *this;
    }

    // getters

    int get_size() {return _size;}

    double* get_data() {return _data.get();}

    // operators

    friend std::ostream& operator<< (std::ostream& os, const Container& c);

};

std::ostream& operator<< (std::ostream& os, const Container& c) {
    for (int i = 0; i < c._size; ++i) {
        os << c._data.get()[i] << " ";
    }
    os << std::endl;
    return os;
}

#endif

If we use this version in test_copy.cpp, then it works as expected.

Also take a look at the assignment operator. We need to be careful that we don’t try to do a copy when if we try something like:

Container a(10);

a = a;

Moving

The latest iteration of the class also implements the move constructor:

Container(Container&& c) noexcept
    : _size(0), _data(nullptr)
{
    std::cout << "in move constructor" << std::endl;

    std::swap(_size, c._size);

    // swap the data pointers

    _data.swap(c._data);
}

This first invalidates the pointer of the data region and then it uses std::swap() to swap the size from the incoming Container to ours and also does a swap on the pointers.

At the end of this operation, the incoming Container c will be invalid.

The move assignment works in a similar fashion.

Here’s a driver that exercises all of these functions:

Listing 99 test_container.cpp
#include <iostream>
#include <vector>

#include "container.H"

Container fill_new() {

    std::cout << "in fill_new()" << std::endl;
    Container c_new(20);

    for (int i = 0; i < c_new.get_size(); ++i) {
        c_new.get_data()[i] = 1.0;
    }

    std::cout << "leaving fill_new()" << std::endl;
    return c_new;
}

int main() {

    std::cout << "1. doing Container c(10)" << std::endl;

    Container c(10);

    std::cout << std::endl;

    auto c_data = c.get_data();
    auto c_size = c.get_size();

    for (int i = 0; i < c_size; ++i) {
        c_data[i] = static_cast<double>(i);
    }

    std::cout << c << std::endl;

    std::cout << "2. doing c1 = c" << std::endl;

    auto c1 = c;

    std::cout << c1 << std::endl;

    std::cout << "3. doing c1 = fill_new()" << std::endl;

    c1 = fill_new();

    std::cout << c1 << std::endl;

    std::cout << "4. doing auto c2 = fill_new()" << std::endl;

    auto c2 = fill_new();

    std::cout << std::endl;

    std::cout << "5. doing cvec.push_back(fill_new())" << std::endl;

    std::vector<Container> cvec;
    cvec.push_back(fill_new());

    std::cout << std::endl;

    std::cout << "done with main" << std::endl;

}

There are prints in each of the functions so we can see where each comes into play.

Note

In case 4,

auto c2 = fill_new();

we might expect that the compiler would do a move constructor here, but instead it does return value optimization. We can disable this with LLVM as:

clang++ -fno-elide-constructors -o test_container test_container.cpp

to see that it will do a move constructor when it is not allowed to do RVO.