C++ Rule of Three, Five and Zero: Safe Object Copying and Moving

Follow one four-integer Buffer from a dangerous shallow copy to correct copy and move operations, then remove the lifetime code with std::vector.

KnowledgeGate Team

Exam prep & CS education

Updated 23 Oct 20265 min read

Your C++ class may work until an object is copied, assigned, moved, or destroyed. With a raw owning pointer, two objects can claim one allocation, causing shared mutations, leaks, or a double delete[]. A copyable Buffer initialized with [3, 1, 4, 1] demonstrates all three rules.

C++ Rule of Three, Five and Zero starts with ownership

Our first Buffer has std::size_t size_ and an owning int* data_. Its invariant is size_ == 0 exactly when data_ == nullptr; otherwise, data_ points to one owned new int[size_] allocation. Revise pointers, classes, and constructors in the C++ Tutorial if needed.

Rule

Functions declared by the class

Purpose

Three

~Buffer(), Buffer(const Buffer&), Buffer& operator=(const Buffer&)

Release and deep-copy the resource

Five

Those three, plus Buffer(Buffer&&) noexcept and Buffer& operator=(Buffer&&) noexcept

Transfer ownership

Zero

None of the five

Delegate ownership to an RAII member

A constructor creates an object; assignment replaces an already-live object's value. First decide whether the type is a copyable value, move-only owner, or non-owning view. Class-policy questions ask whether destruction, deep-copy construction, and exception-safe assignment preserve one resource per owner. Expression-category questions ask which overload binds and whether elision occurs; C++ Move Semantics Tutorial: Trace Ownership with Runnable Examples works through those traces and vector relocation.

C++ shallow copying makes one allocation look like two resources

This constructor establishes the invariant and copies only when the list is non-empty:

cpp
Buffer(std::initializer_list<int> values)
    : size_(values.size()),
      data_(size_ ? new int[size_] : nullptr) {
    if (size_ > 0) {
        std::copy(values.begin(), values.end(), data_);
    }
}

Construct Buffer a{3, 1, 4, 1}. Its size_ is 4; suppose its illustrative data_ address is 0x1000. Compiler-generated Buffer b = a copies 4 and 0x1000, not the array. Thus a.data_ == b.data_, with one allocation.

Execute b[1] = 8. Both objects now observe [3, 8, 4, 1], proving that this supposed value copy aliases storage. If ~Buffer() calls delete[] data_, the first destructor frees 0x1000; the second tries to free 0x1000 again. That second deletion, and any access after the first deletion, is undefined behaviour, not a guaranteed crash.

Shallow-copy diagram: Buffer a and Buffer b both point to the same heap array at 0x1000, so both destructors delete it.

C++ Rule of Three implements a real deep copy

The Rule of Three gives each copied value its own allocation:

cpp
~Buffer() {
    delete[] data_;
}

Buffer(const Buffer& other)
    : size_(other.size_),
      data_(size_ > 0 ? new int[size_] : nullptr) {
    if (size_ > 0) {
        std::copy_n(other.data_, size_, data_);
    }
}

Buffer& operator=(const Buffer& other) {
    if (this == &other) {
        return *this;
    }
    Buffer temp(other);
    std::swap(size_, temp.size_);
    std::swap(data_, temp.data_);
    return *this;
}

Before copying, a owns 0x1000 -> [3, 1, 4, 1]. Buffer b = a allocates one new array, 0x2000 -> [3, 1, 4, 1]. After b[1] = 8, a remains [3, 1, 4, 1] while b becomes [3, 8, 4, 1]. Two allocations allow two valid deletions.

After copy-and-swap, temp owns the destination's old resource and releases it once. If constructing temp fails, the destination is unchanged, giving the strong exception guarantee. Deleting first could lose the old value before the next allocation succeeds. This connects value semantics to object-oriented class design.

C++ Rule of Five transfers ownership during moves

The Rule of Five transfers the pointer instead of copying four elements:

cpp
Buffer(Buffer&& other) noexcept
    : size_(std::exchange(other.size_, 0)),
      data_(std::exchange(other.data_, nullptr)) {}

Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
        delete[] data_;
        size_ = std::exchange(other.size_, 0);
        data_ = std::exchange(other.data_, nullptr);
    }
    return *this;
}

Start with b owning 0x2000 -> [3, 8, 4, 1]. Buffer c = std::move(b) makes zero array allocations, transfers 0x2000 to c, and leaves this implementation's b as {size_=0, data_=nullptr}. std::move is only a cast that permits the move overload.

Let d own 0x3000 -> [6, 2]. d = std::move(c) deletes 0x3000 once, transfers 0x2000 -> [3, 8, 4, 1] to d, and leaves c empty. Moved-from objects stay valid, but empty is this class's chosen state, not a universal promise.

Move diagram: std::move transfers the array at 0x2000 from Buffer b to c and then to d, with no new allocations.

C++ special-member traps that survive copied boilerplate

A raw-owning class should not trust implicit special members. Memberwise copy can shallow-copy its pointer, while a user-declared destructor prevents automatic move generation. Define the complete policy, or disable copying with = delete for a move-only type.

Four defects deserve a direct check:

  1. delete data_ is wrong for an array. Use delete[] data_.

  2. Overwriting a destination pointer during copy assignment leaks its old array. Copy-and-swap releases it.

  3. Stealing a pointer without nulling the source causes a later double deletion.

  4. A potentially throwing move must not be noexcept. Truthful noexcept lets containers such as std::vector relocate by moving.

Copying an empty buffer must produce {0, nullptr} without pointer arithmetic on nullptr. a = a preserves [3, 1, 4, 1]. The identity guard makes self move-assignment preserve its value. Every allocation is released exactly once.

C++ Rule of Zero replaces manual ownership with std::vector

The safer design delegates ownership to an RAII member:

cpp
class Buffer {
    std::vector<int> data_;

public:
    Buffer(std::initializer_list<int> values) : data_(values) {}

    int& operator[](std::size_t index) { return data_[index]; }
    const int& operator[](std::size_t index) const { return data_[index]; }
};

This class declares none of the five special members. std::vector owns its storage, so generated copies perform a deep copy. After Buffer a{3, 1, 4, 1}; Buffer b = a; b[1] = 8;, a remains [3, 1, 4, 1] and b becomes [3, 8, 4, 1].

Buffer c = std::move(b) may transfer vector storage. Afterward, b is valid but unspecified, not necessarily empty. Use std::vector<int> for deep-copy values, std::unique_ptr<int[]> for unique ownership, and std::span<int> for a view whose source outlives it. Practise these ownership choices with containers in Coding & DSA Courses for Placements.

C++ Rule of Three, Five and Zero in exams and interviews

Trace which special member runs, identify aliasing or double deletion, count allocations, justify noexcept, and choose an owning member. Revise class mechanics with OOP for Teaching CS Exams: Classes and Inheritance.

Apply three quick checks:

  1. Broken raw-pointer Buffer y = x creates zero arrays and two owners, so destruction is unsafe.

  2. Explicit-move Buffer y = std::move(x) creates zero arrays, transfers the pointer, and leaves this implementation's x null.

  3. Vector-backed Buffer y = x; y[0] = 9; changes only y. If x began [3, 1, 4, 1], its first element remains 3.

In an interview, state the ownership invariant and copy and move contracts, then explain why the library member removes handwritten lifetime code.

C++ Rule of Zero is the short version and the safer next step

  • No owned resource means do not declare special members.

  • Manual resource ownership plus value copying means implement all five correctly.

  • An RAII member such as std::vector usually returns the class to the Rule of Zero.

The first two rules explain the mechanics, while the Rule of Zero is usually the production target. For structured work through C++ fundamentals, object-oriented programming, and practice, continue with C++ Programming.