The copy constructor and the assignment operator in C++ are the two places where one object is made out of another. The copy constructor runs when a *new* object is created from an existing one; the copy assignment operator runs when an *existing* object is overwritten. For a class that owns a resource, getting these two right is the difference between working code and a double free.
Copying happens in two different places
Consider a class that owns a heap buffer. When you write Buffer b = a; a new object is created and the copy constructor runs. When you write b = a; no new object appears and the copy assignment operator runs.
#include
#include
class Buffer {
public:
Buffer(const std::string& label) : label_(label), data_(new int[4]{0, 0, 0, 0}) {
std::cout << "constructed " << label_ << '\n';
}
Buffer(const Buffer& other) : label_(other.label_ + " (copy)"), data_(new int[4]) {
for (int i = 0; i < 4; ++i) {
data_[i] = other.data_[i];
}
std::cout << "copy constructed " << label_ << '\n';
}
Buffer& operator=(const Buffer& other) {
if (this != &other) {
label_ = other.label_ + " (assigned)";
for (int i = 0; i < 4; ++i) {
data_[i] = other.data_[i];
}
std::cout << "copy assigned " << label_ << '\n';
}
return *this;
}
~Buffer() {
delete[] data_;
std::cout << "destroyed " << label_ << '\n';
}
void set(int index, int value) { data_[index] = value; }
int get(int index) const { return data_[index]; }
const std::string& label() const { return label_; }
private:
std::string label_;
int* data_;
};
int main() {
Buffer original("original");
original.set(0, 42);
Buffer copied = original;
std::cout << "copied label: " << copied.label() << '\n';
std::cout << "copied value: " << copied.get(0) << '\n';
Buffer assigned("assigned");
assigned = original;
std::cout << "assigned label: " << assigned.label() << '\n';
return 0;
}The output shows the three operations in the order they ran:
constructed original
copy constructed original (copy)
copied label: original (copy)
copied value: 42
constructed assigned
copy assigned original (assigned)
assigned label: original (assigned)
destroyed original (assigned)
destroyed original (copy)
destroyed originalNote the destruction order: the temporary objects go first and the original goes last, because objects are destroyed in reverse order of construction.
Shallow versus deep copies
If you write nothing at all, the compiler generates a copy constructor that copies each member. For a raw pointer member, that copies the *address*, not the data. Two objects then point at the same buffer.
class ShallowBuffer {
public:
ShallowBuffer() : data_(new int[3]{1, 2, 3}) {}
~ShallowBuffer() { delete[] data_; }
int* data_;
};
int main() {
ShallowBuffer a;
ShallowBuffer b = a; // shallow copy: both point at the same memory
b.data_[0] = 99;
std::cout << "a[0]: " << a.data_[0] << '\n';
// Both destructors now delete the same pointer: a double free.
}The output shows the shared buffer:
constructed original
copy constructed original (copy)
copied label: original (copy)
copied value: 42
constructed assigned
copy assigned original (assigned)
assigned label: original (assigned)
destroyed original (assigned)
destroyed original (copy)
destroyed originalWhen a and b are destroyed, both call delete on the same pointer. The second one frees memory that is already gone, which is a double free and usually crashes the program.
The rule of three for owning resources
A class that owns a raw resource needs all three of these to behave correctly.
- The copy constructor, which allocates fresh memory and copies the contents.
- The copy assignment operator, which releases the old resource and takes a copy of the new one.
- The destructor, which releases the resource.
The copy assignment operator is the one people forget, and forgetting it produces a subtle bug rather than an obvious one.
class Buffer {
public:
Buffer(const std::string& label) : label_(label), data_(new int[3]{1, 2, 3}) {}
~Buffer() { delete[] data_; }
private:
std::string label_;
int* data_;
};
int main() {
Buffer a("a");
Buffer b("b");
b = a; // the compiler-generated operator copies the pointer
std::cout << "assigned\n";
// b now owns the same pointer as a: the destructors double free.
}That program compiles, prints one line, and then crashes or corrupts memory when the destructors run. The fix is to write the assignment operator properly.
Self-assignment
A copy assignment operator must handle the case where an object is assigned to itself. Without the check, it releases its own memory and then copies from the freed memory.
#include
#include
class Buffer {
public:
explicit Buffer(const std::string& label) : label_(label), data_(new int[3]{1, 2, 3}) {}
Buffer& operator=(const Buffer& other) {
if (this == &other) { // the self-assignment guard
return *this;
}
delete[] data_; // release the old resource
data_ = new int[3];
label_ = other.label_;
for (int i = 0; i < 3; ++i) {
data_[i] = other.data_[i];
}
return *this;
}
~Buffer() { delete[] data_; }
int get(int i) const { return data_[i]; }
private:
std::string label_;
int* data_;
};
int main() {
Buffer buffer("buffer");
buffer = buffer; // self-assignment
std::cout << "value: " << buffer.get(0) << '\n';
std::cout << "survived\n";
return 0;
}The output shows the object surviving the self-assignment:
value: 1
survivedThe modern way to write the check is if (this != &other) at the top, or the copy-and-swap idiom, which handles self-assignment automatically.
The rule of five, in brief
The rule of five says that if you define any of these five special members, you probably need to think about all five.
- The destructor
- The copy constructor
- The copy assignment operator
- The move constructor
- The move assignment operator
The copy-and-swap idiom makes the copy constructor and copy assignment operator do the same work, which removes the self-assignment problem entirely.
#include
#include
#include
class Buffer {
public:
Buffer(const std::string& label, int value)
: label_(label), data_(new int[1]{value}) {}
Buffer(Buffer&& other) noexcept
: label_(std::move(other.label_)), data_(other.data_) {
other.data_ = nullptr;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
label_ = std::move(other.label_);
data_ = other.data_;
other.data_ = nullptr;
}
return *this;
}
Buffer(const Buffer& other)
: label_(other.label_), data_(new int[1]{other.data_[0]}) {}
Buffer& operator=(const Buffer& other) {
Buffer temp(other);
swap(temp);
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(label_, other.label_);
std::swap(data_, other.data_);
}
~Buffer() { delete[] data_; }
int value() const { return data_[0]; }
private:
std::string label_;
int* data_;
};
int main() {
Buffer a("a", 10);
Buffer b = a;
b = a;
std::cout << "a: " << a.value() << " b: " << b.value() << '\n';
return 0;
}The copy-and-swap assignment operator constructs a temporary copy, swaps the contents, and lets the temporary clean up. Self-assignment works because the temporary is a separate object.
Quick reference
- The copy constructor creates a new object from an existing one; the assignment operator overwrites an existing object.
- The compiler-generated copy is shallow for pointer members, which leads to double frees.
- A class that owns a raw resource needs a destructor, a copy constructor and a copy assignment operator.
- Always guard against self-assignment in the copy assignment operator.
- The copy-and-swap idiom removes the self-assignment problem by construction.
- The rule of five extends this to the move operations.
Try it yourself: write a small class that owns a heap buffer and deliberately omit the copy assignment operator. Assign one object to another and run it under the address sanitizer to see the double free reported. Then add a proper copy assignment operator and confirm the sanitizer is quiet. Finally, try assigning an object to itself before and after the fix.