SECTION 1
Historical Context & Motivation
From its earliest days, C++ distinguished itself from C by introducing classes — user-defined types that bundle data and behavior together. As soon as objects could own dynamically allocated resources such as heap memory, file handles, or network sockets, a fundamental question arose: what should happen when one object is used to initialize or overwrite another? In languages with garbage collection, such as Java or Python, this question is largely moot because the runtime tracks references automatically. In C++, however, the programmer bears direct responsibility for the lifetime of every resource, making the semantics of object copying a first-class design concern.
The evolution of copy semantics in C++ tracks the language's broader maturation. Bjarne Stroustrup's original "C with Classes" compiler already generated default member-wise copy operations, but the pitfalls of shallow copying — where two objects inadvertently share the same underlying resource — became apparent almost immediately. The community's response crystallized into guidelines like the Rule of Three, later extended to the Rule of Five with C++11 move semantics. Understanding the copy constructor and the copy-assignment operator is the gateway to all of these idioms.
1979 • C with Classes
1985 • First C++ Release
1991 • The Rule of Three Emerges
1998 • ISO C++98 Standard
2011 • C++11 Move Semantics
The central question this lesson addresses is deceptively simple: how does C++ duplicate the state of one object into another, and what can go wrong if the programmer does not intervene? Answering this question properly is essential to writing correct, resource-safe C++ code.
SECTION 2
Core Principles & Definitions
Before diving into code, it is important to establish the vocabulary that C++ programmers use when discussing object duplication. Two distinct operations exist — copy construction and copy assignment — and they fire in different situations. Conflating them is a common source of confusion for students encountering C++ for the first time.
Copy Constructor
T(const T& other). It is invoked whenever a new object is created from an existing one — for example, T b = a; or when passing by value.Copy-Assignment Operator
operator= that replaces the state of an already-initialized object with the state of another. Its canonical signature is T& operator=(const T& other). It is invoked on an object that already exists — for example, b = a; after b has been constructed.Shallow vs. Deep Copy
Rule of Three
Self-Assignment Safety
a = a; gracefully. A naïve implementation that frees resources before copying may destroy the very data it needs, leading to undefined behavior.KEY TAKEAWAY
SECTION 3
Visual Explanation — Shallow vs. Deep Copy
The most dangerous pitfall in C++ copying is the shallow copy problem. When a class manages a raw pointer to heap memory, the compiler-generated copy constructor simply copies the pointer value — not the data it points to. Both the original and the copy then believe they own the same heap block, leading inevitably to a double-free when both destructors run. The diagram below illustrates this contrast.
obj_a and obj_b hold the same pointer value 0xA0, so destroying one invalidates the other. On the right, a deep copy allocates fresh memory at 0xB0 and copies the array contents, ensuring safe independent destruction.The diagram makes clear why the compiler-generated copy constructor is insufficient for resource-owning classes. When obj_b's destructor runs and calls delete[] data_, the heap block at 0xA0 is freed. When obj_a's destructor subsequently runs, it attempts to free the same address — this is undefined behavior and typically crashes the program or silently corrupts the heap. The deep copy on the right eliminates this problem by giving each object its own allocation, at the cost of one extra new[] call and a memcpy (or element-wise copy).
SECTION 4
How Copy Construction & Assignment Work
Understanding when each operation fires is critical. C++ distinguishes between initialization (creating a new object) and assignment (overwriting an existing object). The equals sign in C++ is syntactically ambiguous — it can invoke either the copy constructor or the copy-assignment operator depending on context.
Copy Constructor Invocation Contexts
- Direct initialization:
MyClass b(a);— explicitly calls the copy constructor. - Copy initialization:
MyClass b = a;— despite the=sign, this is initialization, not assignment, becausebis being created. - Pass by value:
void func(MyClass param);— when called asfunc(a),paramis copy-constructed froma. - Return by value:
MyClass func() { MyClass x; return x; }— conceptually copy-constructs the return value (though modern compilers apply copy elision / NRVO).
Copy-Assignment Operator Invocation
The copy-assignment operator is called when = is used on an already-constructed object: MyClass a; MyClass b; b = a;. Here, b was already default-constructed on the previous line, so this is genuine assignment. The assignment operator must handle two tasks that the copy constructor does not face: (1) releasing old resources held by the left-hand side, and (2) guarding against self-assignment.
Canonical Signatures
COPY CONSTRUCTOR SIGNATURE
Takes a const reference to another object of the same type. Cannot be called with a non-const rvalue in older standards. The parameter must be a reference to avoid infinite recursion (copying the argument would itself call the copy constructor).
COPY-ASSIGNMENT OPERATOR SIGNATURE
Returns a reference to *this to allow chained assignment (a = b = c). Takes a const reference to avoid modifying the source object. Must release any resources currently held by *this before copying from other.
Copy Elision (RVO / NRVO)
SECTION 5
Anatomy of a Resource-Managing Class
To solidify the concepts, let us examine a minimal class that manages a dynamically allocated integer array. This IntArray class illustrates the Rule of Three in action: it defines a destructor, a copy constructor, and a copy-assignment operator. The diagram below maps each special member function to its role in the object's lifecycle.
Notice how the copy-assignment operator's logic is essentially a destroy-then-copy-construct sequence. This pattern is so common that C++ programmers often factor it out into the copy-and-swap idiom, which achieves strong exception safety by leveraging the copy constructor to create a temporary, then swapping the temporary's guts with *this. We will explore this idiom in a subsequent lesson.
| Property | Copy Constructor | Copy-Assignment Operator |
|---|---|---|
| When invoked | Object is being created for the first time | Object already exists and is being overwritten |
| Old state to clean up? | No — the object has no prior state | Yes — must release current resources first |
| Self-assignment concern? | Not applicable | Yes — must guard against a = a |
| Return type | None (constructor) | T& (reference to *this) |
| Syntax trigger | T b(a); T b = a; | b = a; (b already constructed) |
Key differences between copy constructor and copy-assignment operator
SECTION 6
Worked Example — Implementing a Safe IntArray
Let us implement the IntArray class step by step, writing a correct copy constructor and copy-assignment operator that perform deep copies.
Implementing Rule of Three for IntArray
- 1
Step 1 — Define the Class with Raw Members
We start with a class that owns a dynamically allocated array:class IntArray { private: int* data_; size_t size_; public: explicit IntArray(size_t n) : data_(new int[n]{}), size_(n) {} };The constructor allocates an array ofnzero-initialized integers on the heap.Class owns a heap resource viadata_— triggers the need for the Rule of Three. - 2
Step 2 — Write the Destructor
Since the constructor callsnew[], the destructor must calldelete[]:~IntArray() { delete[] data_; }Without this, everyIntArrayobject would leak its heap allocation.Destructor ensures deterministic resource release. - 3
Step 3 — Write the Copy Constructor (Deep Copy)
The copy constructor must allocate its own block of memory and copy the elements one by one (or usestd::copy):IntArray(const IntArray& other) : data_(new int[other.size_]) , size_(other.size_) { std::copy(other.data_, other.data_ + size_, data_); }By allocating fresh memory in the initializer list, we guarantee thatdata_points to independent storage before the body copies the values.Deep copy achieved — each object owns its own heap block. - 4
Step 4 — Write the Copy-Assignment Operator
The assignment operator must handle self-assignment, free old memory, then allocate and copy:IntArray& operator=(const IntArray& other) { if (this != &other) { // self-assignment guard delete[] data_; // free old resource data_ = new int[other.size_]; // allocate new size_ = other.size_; std::copy(other.data_, other.data_ + size_, data_); } return *this; // enable chaining }The self-assignment checkthis != &otherprevents deletingdata_before reading from it when both sides refer to the same object.Rule of Three fully satisfied. The class is safe to copy and assign. - 5
Step 5 — Verify with a Test Scenario
Consider the following usage:IntArray a(3); // Constructor: allocates 3 ints a[0] = 10; a[1] = 20; a[2] = 30; IntArray b = a; // Copy constructor: b gets its own [10,20,30] b[0] = 99; // Modifies only b's array a = b; // Copy-assignment: a's old memory freed, // a gets copy of [99,20,30]Afterb[0] = 99,a[0]remains 10 because they are independent copies. Aftera = b,a's data is[99, 20, 30]— a fresh copy ofb's data.Both copy construction and copy assignment produce fully independent objects with identical values.
SECTION 7
Strengths, Limitations & Common Pitfalls
Deep-copy semantics are straightforward but carry trade-offs. In performance-critical code, unnecessary copies can dominate runtime. Conversely, neglecting them can introduce catastrophic bugs. The table below summarizes the key considerations.
| Aspect | Strength | Limitation / Pitfall |
|---|---|---|
| Correctness | Deep copy ensures full value semantics — objects are independent. | Forgetting to define one of the three (destructor, copy ctor, copy assign) leads to resource leaks or double-frees. |
| Performance | Works well for small or infrequently copied objects. | Deep copies of large data structures incur O(n) allocation and copy costs. Move semantics (C++11) mitigate this. |
| Exception Safety | Naïve implementation provides the basic guarantee. | If new[] throws after delete[], the object is left in a destroyed state. The copy-and-swap idiom solves this. |
| Self-assignment | A simple pointer comparison guards against the common case. | Forgetting the guard causes the object to read from freed memory — undefined behavior. |
| Compiler defaults | For classes with only built-in or well-behaved types, the implicit copy operations are correct and free. | As soon as a raw pointer or OS handle is added, the implicit copies become dangerously wrong. |
Trade-offs when implementing copy operations
KEY TAKEAWAY
SECTION 8
Connection to Move Semantics & Smart Pointers
The copy constructor and copy-assignment operator are the foundation upon which C++11's move semantics are built. A move constructor — T(T&& other) noexcept — transfers ownership of resources from a temporary or expiring object instead of duplicating them, turning an O(n) deep copy into an O(1) pointer swap. Understanding why copies are expensive provides the motivation for moves.
| Feature | Copy Operations (this lesson) | Move Operations (C++11) |
|---|---|---|
| Source object state after | Unchanged — still valid and usable | Moved-from — valid but unspecified state |
| Performance cost | O(n) — duplicates all data | O(1) — swaps pointers / handles |
| When triggered | Source is an lvalue (named object) | Source is an rvalue (temporary or std::move'd) |
| Rule | Rule of Three | Rule of Five (adds move ctor + move assign) |
Copy vs. Move operations
Modern C++ style often advises the Rule of Zero: prefer std::unique_ptr and std::vector as members so that the compiler-generated copy and move operations do the right thing automatically. However, understanding the copy constructor and copy-assignment operator at a mechanical level is essential — it is what smart pointers and containers are implementing internally, and knowing the underlying semantics makes you a far more effective debugger and library author.
What's Next?
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
struct Point { double x; double y; }; but potentially dangerous for a class class DynStr { char* buf_; size_t len_; };. What specific runtime error could occur with DynStr?Show answer
Point, the compiler's member-wise copy duplicates x and y by value — since they are plain doubles with no external resources, the copy is complete and correct. For DynStr, the compiler copies the pointer buf_ by value, meaning both the original and the copy point to the same heap-allocated character array. When one of them is destroyed and calls delete[] buf_, the other is left with a dangling pointer. Attempting to access or free that pointer again results in a double-free — undefined behavior that often manifests as a crash or heap corruption.Question 2: BASIC
Widget a(42);
Widget b = a; // Line A
Widget c(10);
c = a; // Line BShow answer
= sign, b is being created (initialized) for the first time, so C++ treats this as copy initialization. Line B invokes the copy-assignment operator. Here c was already constructed on the previous line, so the = sign triggers operator=, which must handle releasing c's old state.Question 3: INTERMEDIATE
class Buffer {
double* arr_;
int cap_;
public:
Buffer(int c) : arr_(new double[c]{}), cap_(c) {}
~Buffer() { delete[] arr_; }
// TODO: copy-assignment operator
};Show answer
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] arr_;
cap_ = other.cap_;
arr_ = new double[cap_];
std::copy(other.arr_, other.arr_ + cap_, arr_);
}
return *this;
}
The self-assignment guard this != &other prevents the object from deleting its own array and then trying to copy from it. The return of *this by reference enables chained assignment such as a = b = c. Note that this implementation provides only the basic exception-safety guarantee; if new double[cap_] throws, arr_ is a dangling pointer. The copy-and-swap idiom addresses this.Question 4: APPLIED
class FileHandle {
int fd_;
public:
FileHandle(const char* path) : fd_(open(path, O_RDONLY)) {}
~FileHandle() { if (fd_ >= 0) close(fd_); }
};
They report that sometimes after copying a FileHandle into a container, subsequent reads from the original object fail with EBADF (bad file descriptor). Diagnose the bug and propose a fix.Show answer
fd_ bit-for-bit. When the container's copy is destroyed, it closes the file descriptor, leaving the original holding an invalid descriptor value. The fix is to either (a) delete the copy operations to make the class non-copyable (FileHandle(const FileHandle&) = delete;), (b) implement a deep copy using dup(fd_) to duplicate the OS-level file descriptor, or (c) use reference counting via std::shared_ptr. Option (a) is the simplest and often the most appropriate for handle-like types.Question 5: CRITICAL THINKING
new[] throws std::bad_alloc after the deletion, what state is the object left in? Sketch (in pseudocode or C++) an alternative implementation that provides the strong exception-safety guarantee — either the assignment succeeds completely, or the original object is unchanged.Show answer
new[] throws after delete[], the object's data_ pointer is dangling and size_ may or may not have been updated — the object is in a broken state. The strong guarantee can be achieved via the copy-and-swap idiom: allocate first, then swap.
IntArray& operator=(IntArray other) { // note: pass by value
swap(*this, other); // non-throwing swap
return *this;
}
Here, the parameter other is copy-constructed (which may throw), but the copy is made before any mutation of *this. If the copy construction throws, *this is untouched. If it succeeds, the swap (which should be noexcept) exchanges the internals, and other's destructor cleans up the old data. This is the idiomatic strong-exception-safe assignment in C++.SUMMARY
Lesson Summary
Summary
The copy constructor (T(const T&)) initializes a brand-new object as a duplicate of an existing one, while the copy-assignment operator (T& operator=(const T&)) overwrites an already-constructed object with another's state. The compiler generates default versions of both that perform a shallow, member-wise copy — safe for classes of plain data, but dangerous for classes that manage dynamically allocated resources such as heap memory, file descriptors, or sockets. For such classes, a user-defined deep copy is essential.
The Rule of Three states that if a class defines any one of the destructor, copy constructor, or copy-assignment operator, it should define all three. The copy-assignment operator faces two challenges the copy constructor does not: it must release old resources and guard against self-assignment. Looking ahead, move semantics (C++11) extend these principles to rvalue references, and the copy-and-swap idiom provides a pattern for writing exception-safe assignment operators.