C++ / CLASSES AND OBJECT LIFECYCLE
Copy assignment and self-assignment safety
Write a copy assignment operator that survives a = a: acquire the new state before releasing the old, and return *this so assignments chain.
What you will learn
- Declare copy assignment as T& operator=(const T& other) and end it with return *this;
- Allocate and fill the new buffer before deleting the old one, so a = a stays valid
- Use if (this == &other) return *this; as a speed shortcut, not as the only safeguard
- Use copy-and-swap to reuse the copy constructor and get self-assignment safety free
Understanding Copy assignment and self-assignment safety
A copy constructor builds a brand new object out of an existing one; copy assignment has the harder job, because the object on the left already exists and already owns something. The usual shape is IntArray& operator=(const IntArray& other), and the body has to do two separate things in the right order: acquire a copy of other's state, and dispose of the state this object is currently holding. Returning *this by reference is what makes a = b = c work and makes (a = b) refer to a itself, matching the way assignment behaves for an int.
Self-assignment is the case that exposes a wrong order. If the body starts with delete[] data_; and then copies from other.data_, and other happens to be the same object, the source buffer was just freed, so the copy reads released memory and every later read of the object returns whatever the allocator left behind. This is not a contrived scenario, because assignment normally arrives through an alias: items[to] = items[from] with equal indices, *p = *q, or a reference parameter that happens to bind to the receiver.
There are two cures and they are not equivalent. The identity test if (this == &other) return *this; bails out early, but it only covers the aliasing case and does nothing about the other failure of delete-first code: if the allocation throws, the object is left pointing at memory that was already released. Building the new state into a local variable first and installing it afterwards fixes both at once, since self-assignment then reads from a buffer that is still alive and a failed new leaves the object exactly as it was, which demotes this == &other to an optimisation that skips redundant work.
That ordering rule is what the rest of this lesson applies, in three forms: the hand-written allocate-then-release operator, the copy-and-swap idiom that gets the ordering for free, and the reference return that makes the result usable.
<algorithm>
<cstddef>
<iostream>
class IntArray {
public:
IntArray(std::size_t n, int fill) : size_(n), data_(new int[n]) {
std::fill(data_, data_ + n, fill);
}
IntArray(const IntArray& other)
: size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
IntArray& operator=(const IntArray& other) {
std::cout << "operator=: size " << size_ << " <- size " << other.size_ << '\n';
if (this == &other) {
std::cout << " self-assignment, nothing to do\n";
return *this;
}
int* fresh = new int[other.size_]; // 1. acquire the new state
std::copy(other.data_, other.data_ + other.size_, fresh); // 2. fill it from other
delete[] data_; // 3. release the old state
data_ = fresh;
size_ = other.size_;
return *this;
}
~IntArray() { delete[] data_; }
std::size_t size() const { return size_; }
int first() const { return data_[0]; }
private:
std::size_t size_;
int* data_;
};
int main() {
IntArray a(3, 7);
IntArray b(5, 1);
IntArray c(1, 9);
a = b;
std::cout << "a: size=" << a.size() << " first=" << a.first() << '\n';
IntArray& alias = a; // assignment usually arrives through an alias
a = alias;
std::cout << "a: size=" << a.size() << " first=" << a.first() << '\n';
a = b = c; // right-associative, needs the reference return
std::cout << "a: size=" << a.size() << " first=" << a.first() << '\n';
}
Copy assignment replaces state the object already owns, so acquiring the new state before releasing the old one is what makes a = a and a failed allocation both harmless.
Worked examples
Self-assignment arrives through indices
Shows a copy assignment operator with no identity test staying correct when the caller assigns an array element to itself.
<cstddef>
<iostream>
<string>
class Label {
public:
explicit Label(const std::string& t) : text_(new std::string(t)) {}
Label(const Label& other) : text_(new std::string(*other.text_)) {}
Label& operator=(const Label& other) {
std::string* fresh = new std::string(*other.text_); // source is still alive here
delete text_;
text_ = fresh;
return *this;
}
~Label() { delete text_; }
const std::string& text() const { return *text_; }
private:
std::string* text_;
};
void copyElement(Label* items, std::size_t from, std::size_t to) {
items[to] = items[from]; // nothing here promises from != to
}
int main() {
Label items[3] = { Label("alpha"), Label("beta"), Label("gamma") };
copyElement(items, 0, 2);
copyElement(items, 1, 1); // self-assignment, and the caller does not know it
for (const Label& l : items) {
std::cout << l.text() << '\n';
}
}
Example explained
Line 1copyElement(items, 1, 1) expands to items[1] = items[1]; a function taking two indices cannot rule this out.
Line 2new std::string(*other.text_) runs before delete text_, so when other is *this the source string is still allocated and the copy is a genuine copy.
Line 3Because the old string dies only after fresh exists, items[1] still prints beta rather than freed memory.
Line 4Swapping those two statements would copy from a deleted std::string and then destroy the same object twice in ~Label.
Copy-and-swap
Takes the parameter by value so the copy constructor does the work, which makes the operator self-assignment safe without any check.
<algorithm>
<cstddef>
<iostream>
<utility>
class Tags {
public:
explicit Tags(std::size_t n) : size_(n), data_(new int[n]) {
std::fill(data_, data_ + n, static_cast<int>(n));
}
Tags(const Tags& other) : size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + other.size_, data_);
std::cout << "copy ctor: " << size_ << '\n';
}
// by value: the copy exists before the body runs, so nothing of ours is gone yet
Tags& operator=(Tags other) {
std::cout << "swap in: " << other.size_ << '\n';
std::swap(size_, other.size_);
std::swap(data_, other.data_);
return *this; // other leaves holding our old buffer and destroys it
}
~Tags() {
std::cout << "dtor: " << size_ << '\n';
delete[] data_;
}
std::size_t size() const { return size_; }
private:
std::size_t size_;
int* data_;
};
int main() {
Tags a(2);
Tags b(4);
a = b;
std::cout << "a.size() = " << a.size() << '\n';
Tags& alias = a;
a = alias; // no identity test anywhere, still correct
std::cout << "a.size() = " << a.size() << '\n';
}
Example explained
Line 1Tags& operator=(Tags other) still counts as the copy assignment operator; passing b by value calls the copy constructor, which is the copy ctor: 4 line.
Line 2dtor: 2 inside the first assignment is the parameter being destroyed at the end of operator=, taking a's original two-slot buffer with it.
Line 3On a = alias the parameter is copied while a's buffer is untouched, so no test for this == &other is needed to stay correct.
Line 4The price is visible: self-assignment still allocated and copied four ints before discarding an identical buffer, which an identity test would have skipped.
Why the return type is T&
Demonstrates that returning *this by reference makes the assignment expression denote the left operand itself.
<iostream>
class Counter {
public:
explicit Counter(int v) : value_(v) {}
Counter& operator=(const Counter& other) {
value_ = other.value_;
return *this; // a reference to the assigned-to object, not a copy
}
int value() const { return value_; }
private:
int value_;
};
int main() {
Counter a(1), b(2), c(3);
a = b = c; // parsed as a = (b = c)
std::cout << a.value() << ' ' << b.value() << ' ' << c.value() << '\n';
std::cout << "result is a: " << (&(a = b) == &a) << '\n';
std::cout << "read straight back: " << (c = a).value() << '\n';
}
Example explained
Line 1Assignment is right-associative, so b = c runs first and its result, a Counter&, becomes the argument of a's operator=.
Line 2&(a = b) == &a prints 1 because return *this hands back the object itself; a version returning Counter by value would produce a temporary and this expression would not even compile.
Line 3(c = a).value() reads the freshly assigned object through the returned reference, with no intermediate copy.
Line 4Declaring the operator void would leave the class usable for single assignments but reject a = b = c outright.
Important notes
If the class only holds ints, std::string, or std::vector members, do not write copy assignment at all: the compiler-generated one assigns member by member, and each of those members is already self-assignment safe.
The by-value copy-and-swap form doubles as move assignment when the type has a move constructor, since a = std::move(b) then builds the parameter by moving; without a move constructor it always makes a full copy.
Common mistakes
Starting the body with delete[] data_; and copying from other.data_ afterwards. When other is *this, the source has already been released, so the object ends up filled with whatever the allocator handed back, and the failure often shows up much later.
Copying the elements but forgetting size_ = other.size_, or updating size_ before allocating with it. The pointer and the length then disagree and every later loop reads past the end of the new buffer.
Declaring the operator void, or returning T by value. a = b = c stops compiling or starts copying the whole buffer an extra time per assignment, and (a = b) no longer refers to a.
Try it yourself
Change, predict, then run
Write a Bag class owning int* data_ and std::size_t size_ whose operator= deletes the old buffer before copying from other, then bind Bag& alias = a;, run a = alias; and print a's contents. Reorder the body to allocate and copy before the delete, and confirm the contents survive.
Open the C++ workspaceCheck your understanding
A copy assignment operator allocates a new buffer, copies the source into it, and deletes the old buffer only afterwards, with no this == &other test. What happens on a = a?
- It behaves correctly; the identity test would only skip a redundant allocation and copy
- It leaves a holding a dangling pointer, because a's buffer is freed before it is read
- It corrupts a only when the buffer is large enough to change the allocator's behaviour
- It compiles but the destructor later frees the same pointer twice
Show answer
The copy reads other.data_ while the old buffer is still allocated, so other being the same object changes nothing: at that moment nothing has been released. Option 1 describes delete-first code, which is exactly the ordering this operator avoids, and there is no double free either, since the member now holds fresh while the deleted block is no longer referenced.