JORDAN CAMPBELL
R&D SOFTWARE ENGINEER
cybernaut/0.1.4

Classes in C++

This is a little wrapper around a raw pointer to some data, demonstrating the 5 (7?) [con/de]structors.

// buffer.h

#include 

struct Buffer
{
  // Default constructor
  Buffer();

  // Destructor
  ~Buffer();

  // Create a buffer from some existing data
  Buffer(int *data, const int size);

  // Copy and copy assignment
  Buffer(const Buffer &src);
  Buffer &operator=(const Buffer &src);

  // Move and move assignment
  Buffer(Buffer &&src);
  Buffer &operator=(Buffer &&src);

  // A mechanism to get an element
  std::optional operator[](const int n);

  int *data_;
  int size_;
};

The actual implementation of the buffer is below. When I first read about the special member functions I found it hard to remember the details. The key point is that actually the copy, copy assignment, move, and move assignment are actually just simple and obvious: they do what it says on the tin.


#include "buffer.h"

Buffer::Buffer()
{
  // can also set these in the class definition directly, but then if we're not providing a default
  // constructor but we are providing the srcs then we would have to remember to add `Buffer() = default;`
  data_ = nullptr;
  size_ = 0;
}

Buffer::~Buffer()
{
  if (data_)
    delete[] data_;
  data_ = nullptr;
  size_ = 0;
}

Buffer::Buffer(int *data, const int size)
{
  // This is actually quite a dangerous constructor!
  // We're taking ownership of the pointer, and will
  // delete the memory when the destructor is called,
  // but we don't know what's happpened to the memory
  // outside of this class! Can easily lead to a
  // double free (although hopefully the compiler will
  // pick it up).
  // Would be safer to do a memcpy but this is just a
  // demo to highlight the basic constructor functionality.
  data_ = data;
  size_ = size;
}

// Copy constructor. We initialise everything in our
// object fresh, and leave the src object untouched.
Buffer::Buffer(const Buffer &src)
{
  if (!src.data_)
    return;

  size_ = src.size_;
  data_ = new int[size_];
  std::memcpy(data_, &src.data_[0], size_ * sizeof(int));
}

// The copy _assignment_ constructor is called if _this
// object already exists_. We first check that we're not assigning
// back to ourselves, and then we delete the existing resources
// and do a standard copy.
Buffer &Buffer::operator=(const Buffer &src)
{
  if (this != &src)
  {
    delete[] data_;
    size_ = src.size_;

    data_ = new int[size_];
    std::memcpy(data_, &src.data_[0], size_ * sizeof(int));
  }
  return *this;
}

// Move constructor. Take ownership of the resources from the
// src object, but don't delete them! You want the resources to
// still be valid -- we're just swapping pointers.
Buffer::Buffer(Buffer &&src)
{
  size_ = src.size_;
  src.size_ = 0;

  data_ = src.data_;
  src.data_ = nullptr;
}

// The move _assignment_ constructor is called if _this
// object already exists_.
Buffer &Buffer::operator=(Buffer &&src)
{
  if (this != &src)
  {
    delete[] data_;
    size_ = src.size_;
    src.size_ = 0;

    data_ = src.data_;
    src.data_ = nullptr;
  }
  return *this;
}

// A mechanism to get an element. Not actually used in the demo's.
std::optional Buffer::operator[](const int n)
{
  if (n < 0 || n >= size_)
    return std::nullopt;
    
  return data_[n];
}

A simple test of this class is below. Note that this whole implementation is actually kind of silly -- we allocate a 'buffer' (the array) at the start of main and then either share ownership of it (through our custom constructor), copy it, or move the pointers around. By the end of things we don't really have any decent guarantees about what's happening to the data. This implementation is probably actually a good lesson in what not to do, but regardless the point here is to write out the special member functions for reference, not stress about irrelevant implementation details.

// main.cpp

#include "buffer.h"
#include 

int main()
{
  const int size = 10;
  int *data = new int[size];
  for (int i = 0; i < size; ++i)
    data[i] = i;

  std::cout << "original:               ";
  for (int i = 0; i < size; ++i)
    std::cout << data[i] << " ";
  std::cout << std::endl;

  Buffer b{data, size};

  std::cout << "b [custom constructor]: ";
  for (int i = 0; i < size; ++i)
    std::cout << b.data_[i] << " ";
  std::cout << std::endl;

  Buffer c{b};
  std::cout << "c [copy]:               ";
  for (int i = 0; i < size; ++i)
    std::cout << c.data_[i] << " ";
  std::cout << std::endl;

  Buffer d;
  d = c;
  std::cout << "d [copy assignment]:    ";
  for (int i = 0; i < size; ++i)
    std::cout << d.data_[i] << " ";
  std::cout << std::endl;

  Buffer e{std::move(d)};
  std::cout << "e [move]:               ";
  for (int i = 0; i < size; ++i)
    std::cout << e.data_[i] << " ";
  std::cout << std::endl;

  Buffer f;
  f = std::move(e);
  std::cout << "f [move assignment]:    ";
  for (int i = 0; i < size; ++i)
    std::cout << f.data_[i] << " ";
  std::cout << std::endl;

  return 0;
}
  
$ clang++ --std=c++20 main.cpp buffer.cpp
$ ./a.out
> original:               0 1 2 3 4 5 6 7 8 9 
> b [custom constructor]: 0 1 2 3 4 5 6 7 8 9 
> c [copy]:               0 1 2 3 4 5 6 7 8 9 
> d [copy assignment]:    0 1 2 3 4 5 6 7 8 9 
> e [move]:               0 1 2 3 4 5 6 7 8 9 
> f [move assignment]:    0 1 2 3 4 5 6 7 8 9 

The actual point of this post is to set things up for the next one, where I look at when each of these functions are actually called -- i.e. how does the compiler decide that actually it can use a move rather than a copy?