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:
- Explain why a class that owns a raw resource and writes a custom destructor usually also needs a custom copy constructor and copy assignment operator
- Describe what the compiler-generated copy constructor and copy assignment operator do by default, and why a member-wise copy is wrong for a pointer member
- Implement a copy constructor that performs a deep copy of a heap-allocated array
- Implement a copy assignment operator that releases the target object's existing resource, guards against self-assignment, and performs a deep copy
- Use printed addresses to confirm whether two objects own separate memory after a copy, or silently share the same memory
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.
- Note that the constructor allocates
datawithnew int[size], and the destructor releases it withdelete[] data. - Note that
BrokenBufferdeclares a destructor but nothing else related to copying, so the compiler generates a copy constructor and copy assignment operator itself. - Before running anything, predict. after
BrokenBuffer copy = original;, willoriginal.getData()andcopy.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.
#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;
};
#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.
- Implement
SafeBuffer::SafeBuffer(const SafeBuffer& other), the copy constructor. copyother.size, allocate a new array withnew int[size], then copy each element across fromother.datain a loop, so this object never shares memory withother. - Implement
SafeBuffer::operator=(const SafeBuffer& other), the copy assignment operator. first guard against self-assignment withif (this == &other) return *this;, then release this object's existing array withdelete[] data;before allocating and deep-copying a new one the same way the copy constructor does. - Return
*thisfromoperator=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.
#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;
};
#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.
- Print
"--- BrokenBuffer: compiler-generated copy ---", then in its own scope constructBrokenBuffer original(3);and calloriginal.set(0, 42);. - Construct a copy with
BrokenBuffer* copy = new BrokenBuffer(original);, allocated withnewand deliberately never deleted, so both objects are never destroyed together and the demo stays safe to run. print both objects'getData()addresses. - Call
copy->set(0, 99);, then printoriginal.get(0)to show it changed even though onlycopywas written to. - Print a blank line, then
"--- SafeBuffer: user-defined copy ---". in a new scope, constructSafeBuffer original(3);, calloriginal.set(0, 42);, then constructSafeBuffer copy = original;, calling the copy constructor from Task 2. - Print both objects'
getData()addresses, callcopy.set(0, 99);, then printoriginal.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.
#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.