Lecture File
slides/13_big5.pdf
C++ OOP
Destructors, copy constructors, copy assignment, move operations, copy elision, and the rule of five for resource-owning classes.
The Big 5 are the special member functions a resource-owning C++ class usually needs:
The lecture's List owns heap-allocated nodes. That makes ownership explicit: exactly one List should be responsible for deleting a node chain at any time. The Big 5 are the tools for preserving that rule across object lifetimes, copies, assignments, moves, and destruction.
From lecture_code/cpp/big5/list/list.h:
class List {
struct Node {
int data;
Node *next;
Node(int data, Node *next);
~Node();
Node(const Node &o);
};
int len;
Node *head;
void swap(List &o);
public:
List();
List(const List &l);
List(List &&o);
~List();
List &operator=(const List &o);
List &operator=(List &&o);
List &cons(int data);
};
Node is nested because it is an implementation detail of List. Client code should not create nodes or manipulate node pointers.
Invariant:
len is the number of nodes reachable from head.head is either nullptr or points to a heap-allocated node chain.next.List objects own the same node chain unless the class is deliberately designed for shared ownership, which this one is not.Problem:
List l;
l.cons(1).cons(2).cons(3);
cons uses new Node{...}. If nothing deletes those nodes, every list leaks memory.
Destructor:
List::~List() {
delete head;
}
List::Node::~Node() {
delete next;
}
This works recursively:
next.next runs the next node's destructor.nullptr.Important: delete nullptr is safe, so the base case is natural.
Failure modes:
Node does not delete next: leak the rest.Copy construction creates a new object from an existing object:
List l1;
l1.cons(1).cons(2).cons(3);
List l2 = l1; // copy constructor
The compiler-generated copy constructor copies fields memberwise:
l2.len = l1.len
l2.head = l1.head
That is a shallow copy. Both lists point at the same nodes.
Consequences:
Correct copy constructor:
List::Node::Node(const List::Node &o)
: data{o.data},
next{o.next ? new Node{*o.next} : nullptr} {}
List::List(const List &o)
: len{o.len},
head{o.head ? new Node{*o.head} : nullptr} {}
This is a deep copy. Every source node gets a newly allocated destination node. After copying, l1 and l2 have equal values but independent ownership.
List(const List &o);
If the copy constructor took List o by value, creating the parameter would itself require copying a List, which would call the copy constructor before it has even started. That is infinite recursion conceptually and illegal in practice.
Common cases:
List b = a; // initialize new object from existing object
List b{a}; // same idea
void f(List p); // passing by value copies into p
return localList; // returning by value may copy or move, unless elided
Construction means a new object is being created. If the left-hand object already exists, it is assignment, not construction.
Copy assignment writes into an existing object:
List l1;
List l2;
l2 = l1; // copy assignment, not copy construction
The target l2 may already own nodes. A correct assignment must:
*this by reference so chained assignment worksList &List::operator=(const List &o) {
head = o.head ? new Node{*o.head} : nullptr;
len = o.len;
return *this;
}
If this already owned a chain, the old head pointer is overwritten and lost. Leak.
List &List::operator=(const List &o) {
delete head;
head = o.head ? new Node{*o.head} : nullptr;
len = o.len;
return *this;
}
For l = l, o.head and this->head are the same pointer. The code deletes the chain, then tries to copy from the deleted chain. Undefined behavior.
Self-assignment can be accidental:
arr[i] = arr[j];
If i == j, the object is assigned to itself.
List &List::operator=(const List &o) {
if (this == &o) return *this;
Node *oldHead = head;
head = o.head ? new Node{*o.head} : nullptr;
len = o.len;
delete oldHead;
return *this;
}
This avoids the self-assignment bug and deletes old memory only after the new copy succeeds. But it duplicates copy logic already present in the copy constructor.
Lecture implementation:
void List::swap(List &o) {
std::swap(len, o.len);
std::swap(head, o.head);
}
List &List::operator=(const List &o) {
List tmp{o};
swap(tmp);
return *this;
}
Trace for q = l:
tmp is copy-constructed from l. If allocation fails, q is unchanged.q.swap(tmp) gives q the new copied chain and gives tmp q's old chain.tmp goes out of scope.tmp's destructor deletes q's old chain.Why it is strong:
The swap itself should be simple and non-throwing. If swap can fail halfway, the safety argument breaks.
Copying a whole list is wasteful when the source is a temporary that is about to die.
Example from lecture:
List incrementList(List p) {
for (int i = 0; i < p.getLen(); ++i) {
p.setIth(i, p.getIth(i) + 1);
}
return p;
}
List l2{incrementList(l1)};
The argument p is a copy of l1, so mutating it is safe. When p is returned by value, it is about to leave scope. Deep-copying its nodes just to destroy p is unnecessary.
Move constructor:
List::List(List &&o)
: len{o.len}, head{o.head} {
o.head = nullptr;
o.len = 0;
}
Trace:
The moved-from object must remain valid, but its old value is not guaranteed.
For this course:
List a;
List b{a}; // a is an lvalue, copy constructor
List c{incrementList(a)}; // function result is an rvalue, move constructor may be used
List && is an rvalue reference parameter. It lets the class provide a special constructor for temporary source objects.
Important subtlety: a named variable of type List && is an lvalue when used by name inside the function. The lecture does not dwell on this, but it explains why move implementations must be deliberate.
Assignment from a temporary:
List l2;
l2 = incrementList(l1);
Without move assignment, this can call copy assignment and deep-copy from a temporary. Move assignment handles rvalue right-hand sides:
List &List::operator=(List &&o) {
swap(o);
return *this;
}
Trace:
o refers to the temporary result.this swaps its old chain with o's chain.this now owns the useful temporary's data.o owns the old chain.This reuses swap and avoids writing deletion logic again.
Sometimes expected copy or move constructor prints do not appear. The compiler is allowed to construct an object directly in its final destination.
Example pattern:
Foo makeFoo() {
Foo ret{5};
return ret;
}
Foo f{makeFoo()};
Without elision, there may be moves from ret to a return-value object and from that return-value object to f. With elision, the compiler can construct directly in f's storage.
The lecture mentions -fno-elide-constructors as a way to see the hidden moves in experiments.
Exam point: copy elision can change which constructor calls you observe, but it does not excuse incorrect copy or move implementations. Your class must be correct when the Big 5 are called.
lecture_code/cpp/sepComp/dynArray/list.cc owns an array:
List::List() : len{0}, cap{4}, arr{new int[cap]} {}
List::~List() {
delete[] arr;
}
The same ownership reasoning applies:
delete[]arr and resets source to nullptr, len = 0, cap = 0This shows the Big 5 are not about linked lists specifically. They are about any class that owns a resource.
Shallow copy:
l1.head ---> [3] -> [2] -> [1] -> null
l2.head -----^
Two owners, one chain. Bad.
Deep copy:
l1.head ---> [3] -> [2] -> [1] -> null
l2.head ---> [3] -> [2] -> [1] -> null
Two owners, two chains. Mutating one list does not mutate the other. Destructors delete separate allocations.
Move:
Before:
temp.head ---> [4] -> [3] -> null
dest.head ---> old chain
After move construction:
dest.head ---> [4] -> [3] -> null
temp.head ---> null
After move assignment by swap:
dest.head ---> temp's useful chain
temp.head ---> dest's old chain
Then the temporary destructor frees the old chain.
For a class List:
~List();
List(const List &o);
List &operator=(const List &o);
List(List &&o);
List &operator=(List &&o);
Why these forms:
const List &.const List & and returns List &.List &&.List && and returns List &.A class like:
struct Point {
int x;
int y;
};
can usually be copied memberwise. The fields contain the whole value.
The lecture List is different:
List object contains: len and head pointer
heap contains: actual node chain
Copying the pointer does not copy the list. It only creates two owners for the same resource. The Big 5 exist because object lifetime operations must respect ownership.
Ownership test:
If the answer to any of these is yes, the compiler defaults need scrutiny.
Before destruction:
l.len = 3
l.head --> A(data=3) -> B(data=2) -> C(data=1) -> nullptr
List::~List() runs:
delete head;
Deleting A calls Node::~Node() on A:
delete next;
That deletes B, whose destructor deletes C, whose destructor deletes nullptr. Then the calls unwind. The ownership structure is recursive: each node owns the suffix of the list starting at next.
This design is compact. An iterative destructor would also be valid and would avoid deep recursion for very long lists. The lecture version is chosen because it makes resource ownership visible in the type itself.
Before:
l1.head --> A(3) -> B(2) -> C(1) -> null
Expression:
List l2 = l1;
List::List(const List &o) receives o as an alias to l1.
It initializes:
len{o.len}
head{o.head ? new Node{*o.head} : nullptr}
Since o.head is not null, it allocates a new node copied from A. Node's copy constructor copies A's data and recursively allocates a copy of B, which recursively allocates a copy of C.
After:
l1.head --> A(3) -> B(2) -> C(1) -> null
l2.head --> A2(3) -> B2(2) -> C2(1) -> null
Mutating l2 changes A2/B2/C2, not A/B/C. Destroying l1 deletes A/B/C. Destroying l2 deletes A2/B2/C2.
Assignment should return the assigned object:
x = y = z;
groups as:
x = (y = z);
So y = z must evaluate to y. For a class method, the left-hand object is *this, so the copy assignment operator returns:
return *this;
with return type:
List &operator=(const List &o);
Returning by value would create an unnecessary copy. Returning void would break chained assignment.
The dangerous assignment pattern is:
delete head;
head = o.head ? new Node{*o.head} : nullptr;
For l = l, o.head and this->head are the same pointer. The code deletes the chain and then tries to copy from the deleted chain.
Even without self-assignment, this order is weak for allocation failure. If new Node{*o.head} fails after the old head has been deleted, the target object has lost its original value and may contain a dangling or inconsistent state.
A safer hand-written order is:
Node *newHead = o.head ? new Node{*o.head} : nullptr;
delete head;
head = newHead;
len = o.len;
This keeps the old value until the new chain exists. Copy-and-swap is a cleaner version of the same idea.
For:
q = l;
implementation:
List tmp{o};
swap(tmp);
return *this;
Trace:
before:
q.head --> Q1 -> Q2
l.head --> L1 -> L2 -> L3
copy:
tmp.head --> L1copy -> L2copy -> L3copy
swap:
q.head --> L1copy -> L2copy -> L3copy
tmp.head --> Q1 -> Q2
tmp destructor:
deletes Q1 -> Q2
If copying l into tmp fails, the swap never happens and q remains unchanged. If it succeeds, q receives the new value and the temporary cleans up the old value. This is the strong exception-safety story behind the idiom.
For self-assignment:
l = l;
copy-and-swap first makes an independent copy of l, swaps with it, and lets the temporary delete the old chain. It is correct, though it does extra work. A this == &o check can be added as an optimization.
Before moving:
temporary o.head --> A -> B -> C -> null
destination not yet initialized
Move constructor:
List::List(List &&o) : len{o.len}, head{o.head} {
o.head = nullptr;
o.len = 0;
}
After member initialization but before the body, both objects contain the same pointer. The body must reset the source:
destination.head --> A -> B -> C -> null
o.head ----------> null
o.len = 0
Now the destination owns the chain. The source is valid and empty. Its destructor is safe.
Before:
dest.head --> Old1 -> Old2 -> null
temp.head --> New1 -> New2 -> New3 -> null
Move assignment:
List &List::operator=(List &&o) {
swap(o);
return *this;
}
After:
dest.head --> New1 -> New2 -> New3 -> null
temp.head --> Old1 -> Old2 -> null
If o is a temporary returned from a function, it is destroyed soon and frees Old1 -> Old2. The destination has taken the useful chain without deep-copying it.
If o is a named object explicitly cast to an rvalue, this swap-based implementation leaves it valid but not necessarily empty. That is allowed. A moved-from object must satisfy class invariants and be destructible; clients should not rely on its old value.
List make();
List a;
const List ca;
Typical member selection:
List b{a}; // copy constructor: a is an lvalue
List c{ca}; // copy constructor: ca is a const lvalue
List d{make()}; // move constructor or copy elision: make() is an rvalue
b = a; // copy assignment
b = make(); // move assignment, unless elided in another context
Moving normally mutates the source object to transfer ownership, so move operations take non-const rvalue references: List &&, not const List &&.
For:
List incrementList(List p) {
p.cons(99);
return p;
}
List l2{incrementList(l1)};
without elision, you can reason about:
l1 into parameter pp into the function return valuel2Compilers often elide one or more of these moves by constructing directly in the final storage. This can make print-debugging constructor calls confusing. It does not change the design requirement: copy and move operations must be correct when called.
In the C list ADT, clients call freeList. In the C++ list, the destructor does that work automatically when the object lifetime ends:
void f() {
List l;
l.cons(1).cons(2);
} // destructor releases nodes here
This is RAII: resource acquisition is tied to object initialization, and release is tied to object destruction. RAII prevents forgotten cleanup, but it makes shallow copies dangerous because every owner automatically cleans up. That is why destructor, copy constructor, copy assignment, move constructor, and move assignment belong together.
The dynamic-array list in lecture_code/cpp/sepComp/dynArray/list.cc owns arr instead of linked nodes:
List::List() : len{0}, cap{4}, arr{new int[cap]} {}
List::~List() { delete[] arr; }
The same rules apply:
correct copy:
a.arr --> heap block A
b.arr --> separate heap block B with copied elements
shallow copy:
a.arr --+
+--> same heap block
b.arr --+
If two objects share the same arr, both destructors call delete[] on the same allocation. The resource shape differs from the linked list, but the Big 5 reasoning is identical.
In the move constructor, resetting o.arr = nullptr, o.len = 0, and o.cap = 0 keeps the moved-from array-list object internally consistent. A null array should not claim to have live elements.
Destructor protects against leaks:
owned heap nodes must be released when the owner dies
Copy constructor protects against shared ownership during initialization:
new object must receive its own nodes
Copy assignment protects against shared ownership and leaks in an already-live object:
old destination nodes must be released
new destination value must be a deep copy
self-assignment must not destroy the source before copying it
Move constructor protects performance and ownership when creating from a temporary:
new object takes the temporary's resource
temporary is reset so destruction is harmless
Move assignment protects performance and old-resource cleanup when replacing an existing object with a temporary:
destination takes temporary's resource
destination's old resource is transferred somewhere that will clean it up
For every proposed implementation, ask:
Empty-list cases matter. A correct implementation handles head == nullptr naturally. The copy constructor uses a conditional expression to avoid dereferencing null. The destructor can call delete nullptr. Move construction can steal a null pointer and leave the source null.
swap privateswap is an implementation helper for assignment. It directly exchanges private fields:
std::swap(len, o.len);
std::swap(head, o.head);
Client code does not need that operation as part of the public list abstraction in the lecture. Keeping it private reduces the public surface area. Internally, it is valuable because both copy assignment and move assignment can reuse the same small, reliable ownership transfer step.
delete[] for arrays.For a code line, decide which special member is called:
List b = a;: copy constructor.List b{a};: copy constructor.List b; b = a;: copy assignment.List b{returningList()};: move constructor or elision.b = returningList();: move assignment.For ownership diagrams:
For copy assignment implementations:
x = x?*this?For move implementations:
Built from summaries/13_big5.md and reviewed against slides/13_big5.pdf plus matching files in lecture_code/.