C++ / CLASSES AND OBJECT LIFECYCLE
Copy constructors and deep versus shallow copies
Write a correct copy constructor for a class that owns heap memory, and tell a memberwise shallow copy from a deep copy that gives each object its own buffer.
What you will learn
- Write T(const T& other) and recognise every place the compiler calls it
- See that the generated copy is memberwise, so a pointer member copies only an address
- Allocate inside the copy constructor so each object owns exactly one block
- Skip writing one when members such as std::string and std::vector already copy deeply
Understanding Copy constructors and deep versus shallow copies
A copy constructor is the constructor whose parameter is a reference to its own class, normally T(const T& other). It runs whenever a new object is built from an existing one: Text b = a; Text b(a); passing a Text by value; or storing one in a container. It is construction, not assignment, because the object being written has no previous contents to release first, and that difference is exactly why it is a separate function from operator=.
Declare no copy constructor and the compiler writes one, and what it writes is memberwise: each member is initialised from the matching member of the source using that member's own copy semantics. For class-type members that means calling their copy constructors; for built-in types, including pointers, it means copying the value. A char* member is therefore copied as an address, so the new object points at the old object's block while believing it owns it, and the second destructor to run will delete memory that is already gone.
A deep copy restores the invariant that each object owns exactly one block: the copy constructor allocates fresh storage sized from other, copies the contents into it, and copies the plain members separately. The mental model worth keeping is that a copy is not shallow or deep as a whole, since depth is decided one member at a time. That is why a class assembled from std::string and std::vector members needs no copy constructor: those members allocate for themselves when copied, so the compiler's memberwise copy is already deep.
<cstddef>
<cstring>
<iostream>
class Text {
public:
explicit Text(const char* s)
: len_(std::strlen(s)), data_(new char[len_ + 1]) {
std::memcpy(data_, s, len_ + 1);
std::cout << "ctor " << data_ << '\n';
}
// Deep copy: our own block first, then the contents.
Text(const Text& other)
: len_(other.len_), data_(new char[other.len_ + 1]) {
std::memcpy(data_, other.data_, len_ + 1);
std::cout << "copy ctor " << data_ << '\n';
}
~Text() {
std::cout << "dtor " << data_ << '\n';
delete[] data_;
}
void capitalise() {
if (len_ > 0 && data_[0] >= 'a' && data_[0] <= 'z')
data_[0] = static_cast<char>(data_[0] - ('a' - 'A'));
}
const char* c_str() const { return data_; }
const void* block() const { return data_; }
private:
std::size_t len_;
char* data_;
};
int main() {
Text a("hello");
Text b = a; // copy constructor, not copy assignment
b.capitalise();
std::cout << "a = " << a.c_str() << ", b = " << b.c_str() << '\n';
std::cout << "same block? " << (a.block() == b.block() ? "yes" : "no") << '\n';
}A copy is only as deep as its members, so the generated copy constructor duplicates a pointer's address while a deep copy allocates new storage and copies the contents into it.
Worked examples
What the generated copy actually duplicates
Shows the compiler's memberwise copy sharing a pointee while keeping plain members independent.
<iostream>
// No destructor and no ownership here: this only shows what the generated copy does.
struct View {
int* data;
int n;
};
int main() {
int storage[3] = {1, 2, 3};
View v{storage, 3};
View w = v; // implicit copy constructor, member by member
w.data[0] = 99; // writes through the pointer both objects hold
w.n = 1; // touches only w's own int
std::cout << "v: " << v.data[0] << " n=" << v.n << '\n';
std::cout << "w: " << w.data[0] << " n=" << w.n << '\n';
std::cout << "same array? " << (v.data == w.data) << '\n';
}Example explained
Line 1View w = v; calls the implicit copy constructor, which copies each member with that member's own semantics.
Line 2data is an int*, and copying a pointer copies the address, so v.data and w.data name the same three ints.
Line 3w.data[0] = 99; therefore changes what v sees as well, which is the visible symptom of a shallow copy.
Line 4w.n = 1; proves the int member is genuinely independent, so only the pointed-to storage is shared.
Where copies get made
Traces copy constructor calls for a by-value parameter versus a const reference parameter.
<iostream>
class Counter {
public:
Counter(int v) : v_(v) {}
Counter(const Counter& other) : v_(other.v_) {
std::cout << "copy made\n";
}
int value() const { return v_; }
private:
int v_;
};
void byValue(Counter c) { std::cout << "byValue " << c.value() << '\n'; }
void byRef(const Counter& c) { std::cout << "byRef " << c.value() << '\n'; }
int main() {
Counter c(7);
byRef(c);
byValue(c);
Counter d(c);
std::cout << "d = " << d.value() << '\n';
}Example explained
Line 1byRef(c) binds a reference to the existing object, so no copy line is printed at all.
Line 2byValue(c) initialises the parameter from c, and that initialisation is a copy construction, which is why copy made prints before the function body.
Line 3Counter d(c); is direct initialisation and reaches the same copy constructor that Counter d = c; would.
Line 4Each copy made line marks a full construction, which is the reason wide objects are passed by const reference.
Deep by default when members are
Shows that a class built from std::string and std::vector copies deeply with no copy constructor written.
<iostream>
<string>
<vector>
struct Playlist {
std::string name;
std::vector<std::string> tracks;
};
int main() {
Playlist a{"Focus", {"Kiara", "Nocturne"}};
Playlist b = a; // generated copy constructor, already deep
b.name = "Focus (copy)";
b.tracks[0] = "Prelude";
std::cout << a.name << ": " << a.tracks[0] << ", " << a.tracks[1] << '\n';
std::cout << b.name << ": " << b.tracks[0] << ", " << b.tracks[1] << '\n';
std::cout << "same buffer? " << (a.tracks.data() == b.tracks.data()) << '\n';
}Example explained
Line 1Playlist b = a; uses the compiler-generated copy constructor because every member already knows how to copy itself.
Line 2The copy constructors of std::string and std::vector allocate their own storage, so the memberwise copy comes out deep without any code from you.
Line 3b.tracks[0] = "Prelude"; leaves a.tracks[0] as Kiara, which is only possible if the two vectors hold separate buffers.
Line 4a.tracks.data() == b.tracks.data() prints 0, the direct confirmation that no block is shared.
Important notes
Initialising from a temporary, as in Text t = Text("hi"), may skip the copy constructor entirely, and since C++17 that elision is required, so never use copy constructor side effects to count objects.
A class that needs a hand-written copy constructor almost always needs a destructor and copy assignment as well; supplying only one of the three leaves the other paths broken.
Common mistakes
Declaring the parameter by value as T(T other): copying the argument would need the copy constructor itself, so the declaration is ill-formed and the class will not compile.
Hand-writing a copy constructor that only does data_ = other.data_, which leaves two objects owning one block, so the second destructor frees memory that is already released and corrupts the heap.
Believing Text b = a; runs operator=, then fixing the shallow copy inside copy assignment only; the initialisation still calls the copy constructor and the crash stays.
Try it yourself
Change, predict, then run
Write a Grid class that stores int* cells with rows and cols and fills the cells in its constructor, then add a copy constructor that allocates its own cells array. Copy a grid, write a new value into element 0 of the copy, and print both grids to show the original is unchanged.
Open the C++ workspaceCheck your understanding
A class stores std::string name and int* scores, where scores is allocated with new int[n] in the constructor. No copy constructor is declared. What does copying an object of this class do?
- The compiler refuses to generate a copy constructor because the class contains a raw pointer.
- Both members are copied shallowly, so the two objects also share one character buffer.
- name gets its own character buffer, while both objects' scores point at the same int array.
- Both members are copied deeply, because one member with a deep copy constructor makes the whole copy deep.
Show answer
The generated copy constructor is memberwise: it calls std::string's copy constructor, which allocates its own characters, and copies int* as a plain value, which duplicates only the address. Option 3 is tempting because std::string really does copy deeply, but that depth belongs to that member alone and nothing propagates to the pointer; a pointer member also does not stop the compiler from generating the copy constructor.