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:
#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:
#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.
#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:
#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.