C++ OOP

The Big 5

Destructors, copy constructors, copy assignment, move operations, copy elision, and the rule of five for resource-owning classes.

Lecture File

slides/13_big5.pdf

Prerequisites

C++ classes, heap allocation with new/delete, linked-list ADT, references.

Lecture Code

lecture_code/cpp/big5, lecture_code/cpp/raii

1. Read Start with Big picture, then Deep study notes.
2. Trace Open the listed lecture-code files and follow the memory or stream state.
3. Check Use Pitfalls and Quick reference to catch common mistakes.
4. Practice Finish with the matching exam-practice deck.

The Big 5

What this deck is really about

The Big 5 are the special member functions a resource-owning C++ class usually needs:

  • destructor
  • copy constructor
  • copy assignment operator
  • move constructor
  • move assignment operator

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.

The running example

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.
  • Each node owns the rest of the chain through next.
  • No two live List objects own the same node chain unless the class is deliberately designed for shared ownership, which this one is not.

Destructor

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:

  • Deleting the head node runs the head node's destructor.
  • That destructor deletes next.
  • Deleting next runs the next node's destructor.
  • The chain continues until nullptr.

Important: delete nullptr is safe, so the base case is natural.

Failure modes:

  • No destructor: leak every allocated node.
  • Destructor deletes only the first node and Node does not delete next: leak the rest.
  • Two lists own the same head pointer: both destructors delete the same nodes, causing double free.

Copy constructor

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:

  • Mutating a node through one list changes what the other sees.
  • When both destructors run, the same node chain is deleted twice.

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.

Why the parameter is a reference

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.

When copy construction happens

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 operator

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:

  • avoid leaking the old nodes
  • avoid shallow ownership
  • handle self-assignment
  • leave the target valid if copying fails
  • return *this by reference so chained assignment works

Naive assignment 1: leak

List &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.

Naive assignment 2: self-assignment bug

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.

Better but still duplicated

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.

Copy-and-swap

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:

  1. tmp is copy-constructed from l. If allocation fails, q is unchanged.
  2. q.swap(tmp) gives q the new copied chain and gives tmp q's old chain.
  3. tmp goes out of scope.
  4. tmp's destructor deletes q's old chain.

Why it is strong:

  • old resources are freed automatically
  • copy logic is reused
  • self-assignment is safe because the temporary is a separate deep copy
  • target remains unchanged if copying fails before the swap

The swap itself should be simple and non-throwing. If swap can fail halfway, the safety argument breaks.

Move constructor

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:

  1. Destination copies the source pointer value.
  2. Destination now owns the node chain.
  3. Source is reset to an empty valid state.
  4. Source destructor later runs safely and deletes nothing.

The moved-from object must remain valid, but its old value is not guaranteed.

Lvalues, rvalues, and move selection

For this course:

  • lvalue: non-temporary object with a stable identity, such as a named variable.
  • rvalue: temporary value, often produced by a function returning by value.
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.

Move assignment operator

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:

  1. o refers to the temporary result.
  2. this swaps its old chain with o's chain.
  3. this now owns the useful temporary's data.
  4. o owns the old chain.
  5. The temporary is destroyed, freeing the old chain.

This reuses swap and avoids writing deletion logic again.

Copy elision

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.

Dynamic array comparison

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:

  • copy constructor allocates a new array and copies elements
  • destructor uses delete[]
  • move constructor steals arr and resets source to nullptr, len = 0, cap = 0
  • copy assignment uses copy-and-swap
  • move assignment swaps with the temporary

This shows the Big 5 are not about linked lists specifically. They are about any class that owns a resource.

Ownership trace: shallow vs deep copy

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.

Big 5 signatures

For a class List:

~List();
List(const List &o);
List &operator=(const List &o);
List(List &&o);
List &operator=(List &&o);

Why these forms:

  • Destructor has no return type and no parameters.
  • Copy constructor takes const List &.
  • Copy assignment takes const List & and returns List &.
  • Move constructor takes List &&.
  • Move assignment takes List && and returns List &.

Deeper ownership, RAII, and exception-safety reasoning

Why Big 5 is about ownership

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:

  • Does the destructor need to release something?
  • Does copying a field accidentally share something that should be independent?
  • Does assignment need to free an old resource before taking a new value?
  • Can a temporary's resource be transferred instead of copied?

If the answer to any of these is yes, the compiler defaults need scrutiny.

Full destructor trace

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.

Full copy-constructor trace

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 return and chained assignment

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.

Self-assignment and exception safety

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.

Copy-and-swap trace

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.

Move-constructor ownership transfer

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.

Move-assignment by swap

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.

Value category selection table

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 &&.

Copy elision and constructor counts

For:

List incrementList(List p) {
  p.cons(99);
  return p;
}

List l2{incrementList(l1)};

without elision, you can reason about:

  • a copy construction from l1 into parameter p
  • a move construction from local p into the function return value
  • a move construction from the return value into l2

Compilers 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.

RAII connection

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.

Dynamic array comparison

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.

What each special member protects against

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

Reading Big 5 exam code

For every proposed implementation, ask:

  1. What resources did the object own before the operation?
  2. What resources should it own after the operation?
  3. Did any allocation become unreachable?
  4. Do two live objects now own the same allocation?
  5. Is every moved-from object still destructible?
  6. Does the operation still work for an empty list?

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.

Why lecture code keeps swap private

swap 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.

Common failure modes

  • Relying on compiler-generated copy for a pointer-owning class.
  • Deleting only one node of a chain.
  • Forgetting delete[] for arrays.
  • Copy assignment leaks old resources.
  • Copy assignment deletes old resources before handling self-assignment.
  • Copy assignment leaves the object broken if allocation fails.
  • Move constructor steals a pointer but forgets to null out the source.
  • Move constructor nulls the source pointer but leaves other invariants inconsistent.
  • Move assignment overwrites the destination pointer and leaks the old resource.
  • Assuming copy elision will always hide broken copy/move code.

Exam reasoning patterns

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.
  • Leaving scope: destructor.

For ownership diagrams:

  1. Draw each object.
  2. Draw pointer fields.
  3. Mark who owns each allocation.
  4. After copy, verify duplicate values but separate allocations.
  5. After move, verify destination owns the resource and source is valid.
  6. At destruction, every allocation should be deleted exactly once.

For copy assignment implementations:

  1. Does it handle existing destination resources?
  2. Does it handle x = x?
  3. Does it deep-copy owned memory?
  4. Does it return *this?
  5. Is the object unchanged if allocation fails before commit?

For move implementations:

  1. Does it avoid deep copying?
  2. Does it transfer ownership?
  3. Is the moved-from object safe to destroy?
  4. Are invariants restored in the moved-from object?

Quick reference

  • Destructor: releases owned resources.
  • Copy constructor: creates a new independent object from an lvalue source.
  • Copy assignment: replaces an existing object's value with a deep copy.
  • Move constructor: creates a new object by stealing from an rvalue source.
  • Move assignment: replaces an existing object by taking resources from an rvalue source.
  • Copy-and-swap: copy first, swap second, let destructor clean old resources.
  • Moved-from object: valid but unspecified.
  • Copy elision: compiler may skip copy/move construction by constructing directly in destination.

Built from summaries/13_big5.md and reviewed against slides/13_big5.pdf plus matching files in lecture_code/.