University of Wales Trinity Saint David
ACCB5017 - Games Architecture and Mechanics

Rule of Three

Overview

This lab compares two classes that both own a raw heap array, BrokenBuffer and SafeBuffer, to show why a class that writes its own destructor almost always needs to write its own copy constructor and copy assignment operator too, the Rule of Three. BrokenBuffer is given complete and unedited. it declares a destructor but relies on the compiler-generated copy constructor and copy assignment operator, and copying it silently produces two objects that share the same array. You will then implement SafeBuffer's copy constructor and copy assignment operator by hand so each copy owns its own array, and complete a demo that prints both classes' addresses side by side to make the difference directly visible.

Learning Objectives

By the end of this lab you will be able to:

Concept

A class that allocates a resource in its constructor and releases it in its destructor, such as BrokenBuffer allocating int* data with new int[size] and releasing it with delete[] data, has told the compiler exactly how to destroy one of its objects. It has said nothing about how to copy one. If the class declares a destructor but no copy constructor and no copy assignment operator, the compiler generates both automatically, and its default for each is a member-wise copy. for a pointer member like data, that copies the pointer's value, not the array it points to. After BrokenBuffer copy = original;, original.getData() and copy.getData() return the exact same address. two objects now think they separately own one array, and if both are ever destroyed normally, each destructor calls delete[] on that same address, a double free.

The Rule of Three is the observation that these three special member functions, the destructor, the copy constructor and the copy assignment operator, all answer the same underlying question. who owns this resource, and what has to happen when ownership is duplicated or released. Writing a custom version of even one of them, because the compiler-generated default is wrong, is a strong signal the other two need writing too. SafeBuffer answers that question correctly. its copy constructor, SafeBuffer(const SafeBuffer& other), allocates a new array with new int[size] and copies other's contents into it, a deep copy, so the new object never shares memory with the one it was copied from.

The copy assignment operator, SafeBuffer& operator=(const SafeBuffer& other), does the same deep copy, but has two extra responsibilities a constructor does not. it must release whatever the target object already owned before overwriting it with delete[] data;, and it must guard against self-assignment, a = a, with if (this == &other) return *this;. Without that guard, a = a would delete[] the array before the loop that reads from other.data ever runs, since other and *this are the same object.

Tasks

Task 1 - The problem: BrokenBuffer's compiler-generated copy

Open BrokenBuffer.h and BrokenBuffer.cpp. Both files are already complete. there is nothing to implement in this task. Read through the constructor, destructor, get(), set() and getData(), and notice what is missing. no copy constructor and no copy assignment operator are declared anywhere.

  1. Note that the constructor allocates data with new int[size], and the destructor releases it with delete[] data.
  2. Note that BrokenBuffer declares a destructor but nothing else related to copying, so the compiler generates a copy constructor and copy assignment operator itself.
  3. Before running anything, predict. after BrokenBuffer copy = original;, will original.getData() and copy.getData() print the same address, or two different addresses?

Success condition: you can state, in your own words, what a compiler-generated copy constructor does with a pointer member, and what you expect original.getData() and copy.getData() to print. Task 3's demo will confirm whether your prediction was right.

BrokenBuffer.h
#pragma once

// BrokenBuffer owns a raw heap array (data). It declares its own
// destructor to release that array, but no copy constructor and no copy
// assignment operator - it relies on whatever the compiler generates for
// those two. The compiler's default is a plain member-wise copy, which
// copies the data pointer itself, not the array it points to. Compare
// this declaration to SafeBuffer's: the only difference is the copy
// constructor and copy assignment operator SafeBuffer writes by hand.
// That difference is the whole point of this project.
class BrokenBuffer
{
private:
    int* data;
    int size;

public:
    BrokenBuffer(int size);
    ~BrokenBuffer();

    int get(int index) const;
    void set(int index, int value);

    // Exposed only so the demo can print the raw address and show two
    // objects secretly sharing it - not something a real class would
    // normally expose.
    const int* getData() const;
};
BrokenBuffer.cpp
#include "BrokenBuffer.h"
#include <iostream>

BrokenBuffer::BrokenBuffer(int size)
{
    this->size = size;
    data = new int[size];
    for (int i = 0; i < size; i++)
    {
        data[i] = 0;
    }
}

BrokenBuffer::~BrokenBuffer()
{
    std::cout << "  [release] BrokenBuffer freeing " << (const void*)data << std::endl;
    delete[] data;
}

int BrokenBuffer::get(int index) const
{
    return data[index];
}

void BrokenBuffer::set(int index, int value)
{
    data[index] = value;
}

const int* BrokenBuffer::getData() const
{
    return data;
}

Task 2 - SafeBuffer: a hand-written copy constructor and copy assignment operator

Open SafeBuffer.h, already fully declared with a destructor, a copy constructor and a copy assignment operator alongside the same get(), set() and getData() as BrokenBuffer. Open SafeBuffer.cpp and complete the two functions BrokenBuffer never declared.

  1. Implement SafeBuffer::SafeBuffer(const SafeBuffer& other), the copy constructor. copy other.size, allocate a new array with new int[size], then copy each element across from other.data in a loop, so this object never shares memory with other.
  2. Implement SafeBuffer::operator=(const SafeBuffer& other), the copy assignment operator. first guard against self-assignment with if (this == &other) return *this;, then release this object's existing array with delete[] data; before allocating and deep-copying a new one the same way the copy constructor does.
  3. Return *this from operator= so assignment chains, a = b = c, keep working the way they do for a built-in type.

Success condition: both functions compile, and SafeBuffer copy = original; followed by copy.set(0, 99); leaves original.get(0) unchanged, unlike the aliasing you predicted for BrokenBuffer in Task 1.

SafeBuffer.h
#pragma once

// SafeBuffer owns the same kind of raw heap array as BrokenBuffer, but
// declares its own destructor, copy constructor and copy assignment
// operator - the Rule of Three. Whenever a class writes even one of these
// three by hand because the default behaviour is wrong, it usually needs
// all three, because all three deal with the same question: who owns
// this resource, and what has to happen when ownership is duplicated or
// released.
class SafeBuffer
{
private:
    int* data;
    int size;

public:
    SafeBuffer(int size);
    ~SafeBuffer();

    SafeBuffer(const SafeBuffer& other);
    SafeBuffer& operator=(const SafeBuffer& other);

    int get(int index) const;
    void set(int index, int value);
    const int* getData() const;
};
SafeBuffer.cpp
#include "SafeBuffer.h"
#include <iostream>

SafeBuffer::SafeBuffer(int size)
{
    this->size = size;
    data = new int[size];
    for (int i = 0; i < size; i++)
    {
        data[i] = 0;
    }
}

SafeBuffer::~SafeBuffer()
{
    std::cout << "  [release] SafeBuffer freeing " << (const void*)data << std::endl;
    delete[] data;
}

SafeBuffer::SafeBuffer(const SafeBuffer& other)
{
    // TODO: copy other.size into size, allocate a new array with
    // new int[size], then copy each element across from other.data in a
    // loop - a deep copy, so this object never shares memory with other.
    // Print "  [copy] SafeBuffer deep-copied into " << (const void*)data
    // afterwards so the demo can show it happened.
}

SafeBuffer& SafeBuffer::operator=(const SafeBuffer& other)
{
    // TODO: guard against self-assignment (if (this == &other) return
    // *this;), then release this object's existing array with
    // delete[] data, then deep-copy other's size and contents the same
    // way the copy constructor does above, then return *this
}

int SafeBuffer::get(int index) const
{
    return data[index];
}

void SafeBuffer::set(int index, int value)
{
    data[index] = value;
}

const int* SafeBuffer::getData() const
{
    return data;
}

Task 3 - Comparing both buffers side by side

Open RuleOfThreeDemo.cpp and complete main() so it builds and copies a BrokenBuffer, then a SafeBuffer, printing each pair's addresses so the difference from Task 1's prediction is directly visible.

  1. Print "--- BrokenBuffer: compiler-generated copy ---", then in its own scope construct BrokenBuffer original(3); and call original.set(0, 42);.
  2. Construct a copy with BrokenBuffer* copy = new BrokenBuffer(original);, allocated with new and deliberately never deleted, so both objects are never destroyed together and the demo stays safe to run. print both objects' getData() addresses.
  3. Call copy->set(0, 99);, then print original.get(0) to show it changed even though only copy was written to.
  4. Print a blank line, then "--- SafeBuffer: user-defined copy ---". in a new scope, construct SafeBuffer original(3);, call original.set(0, 42);, then construct SafeBuffer copy = original;, calling the copy constructor from Task 2.
  5. Print both objects' getData() addresses, call copy.set(0, 99);, then print original.get(0) to show it is unchanged this time.

Success condition: the BrokenBuffer block prints two identical addresses and shows original.get(0) changing to 99 after only copy was written to. the SafeBuffer block prints two different addresses and shows original.get(0) staying at 42.

RuleOfThreeDemo.cpp
#include <iostream>
#include "BrokenBuffer.h"
#include "SafeBuffer.h"

int main()
{
    // TODO: print "--- BrokenBuffer: compiler-generated copy ---"
    {
        // TODO: construct BrokenBuffer original(3), call original.set(0, 42)

        // TODO: construct BrokenBuffer* copy = new BrokenBuffer(original) -
        // allocated with new and deliberately never deleted, so this block
        // stays safe to run

        // TODO: print original.getData() and copy->getData() side by side

        // TODO: call copy->set(0, 99), then print original.get(0) to show
        // it changed even though only copy was written to
    } // original's destructor runs here and frees the shared array once

    std::cout << std::endl;

    // TODO: print "--- SafeBuffer: user-defined copy ---"
    {
        // TODO: construct SafeBuffer original(3), call original.set(0, 42)

        // TODO: construct SafeBuffer copy = original, calling the copy
        // constructor from Task 2

        // TODO: print original.getData() and copy.getData() side by side

        // TODO: call copy.set(0, 99), then print original.get(0) to show
        // it is unchanged this time
    } // both destructors run here, each freeing its own array

    return 0;
}

Solution

Try the tasks above before revealing the solution.