Skip to content

Copy Semantics: Deep vs Shallow

Learn the difference between shallow and deep copies, how to implement correct copy operations for resource-owning classes, and the copy-and-swap idiom.

Why Copy Semantics Matter

When you copy an object in C++, what exactly gets copied? For simple types, the answer is straightforward — all members are copied bit by bit. But when an object owns a resource through a pointer, the default (compiler-generated) copy constructor performs a shallow copy: it copies the pointer value but not the data it points to. This means two objects now share the same resource, leading to double-free bugs and data corruption.

Understanding copy semantics is essential because copies happen implicitly in many places: passing by value, returning from functions, inserting into containers, and assignment.

Shallow Copy Problem

The compiler-generated copy constructor copies each member. For pointers, this copies the address — not the pointed-to data. This is a shallow copy and it's dangerous for resource-owning classes.

shallow_copy_bug.cpp
#include <iostream>
#include <cstring>

class BrokenString {
    char* data_;
    std::size_t len_;

public:
    BrokenString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    ~BrokenString() {
        delete[] data_;  // frees the buffer
    }

    // No copy constructor defined!
    // Compiler generates shallow copy: copies the pointer

    void print() const {
        std::cout << data_ << '\n';
    }
};

int main() {
    BrokenString a{"Hello"};
    {
        BrokenString b = a;  // shallow copy: b.data_ == a.data_
        b.print();           // "Hello" — works for now
    }  // b destroyed: delete[] b.data_ (same pointer as a.data_!)

    // a.data_ is now a dangling pointer — UNDEFINED BEHAVIOR
    a.print();  // may crash, print garbage, or appear to work
    return 0;
}

Implementing Deep Copy

A deep copy allocates new memory and copies the actual data. This ensures each object owns its own independent copy of the resource.

deep_copy.cpp
#include <iostream>
#include <cstring>
#include <algorithm>

class SafeString {
    char* data_;
    std::size_t len_;

public:
    explicit SafeString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    // Deep copy constructor
    SafeString(const SafeString& other)
        : len_{other.len_}
        , data_{new char[other.len_ + 1]}
    {
        std::strcpy(data_, other.data_);
    }

    // Deep copy assignment with self-assignment check
    SafeString& operator=(const SafeString& other) {
        if (this == &other) return *this;  // self-assignment guard

        // Allocate new resource BEFORE releasing old one
        char* new_data = new char[other.len_ + 1];
        std::strcpy(new_data, other.data_);

        // Release old resource
        delete[] data_;

        // Adopt new resource
        data_ = new_data;
        len_ = other.len_;
        return *this;
    }

    ~SafeString() { delete[] data_; }

    void print() const {
        std::cout << data_ << " (len=" << len_ << ")\n";
    }
};

int main() {
    SafeString a{"Hello"};
    SafeString b = a;     // deep copy: b gets its own buffer
    SafeString c{"World"};
    c = a;                // deep copy assignment
    a.print();  // Hello (len=5)
    b.print();  // Hello (len=5)
    c.print();  // Hello (len=5)
    return 0;
}

The Copy-and-Swap Idiom

The copy-and-swap idiom provides an elegant, exception-safe implementation of the copy assignment operator. It reuses the copy constructor and a swap function, providing strong exception safety with minimal code duplication.

copy_and_swap.cpp
#include <iostream>
#include <cstring>
#include <utility>

class Buffer {
    char* data_;
    std::size_t size_;

public:
    explicit Buffer(std::size_t size)
        : size_{size}, data_{new char[size]{}} {}

    Buffer(const Buffer& other)
        : size_{other.size_}, data_{new char[other.size_]}
    {
        std::memcpy(data_, other.data_, size_);
    }

    ~Buffer() { delete[] data_; }

    // Copy-and-swap: take parameter by VALUE (invokes copy ctor)
    Buffer& operator=(Buffer other) {   // copy happens here
        swap(*this, other);             // swap resources
        return *this;
    }   // 'other' destroyed here, freeing our old resource

    // Non-throwing swap — essential for strong exception safety
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

    char& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }
};

int main() {
    Buffer a{5};
    a[0] = 'H'; a[1] = 'i';

    Buffer b{10};
    b = a;  // copy-and-swap: exception-safe, self-assignment-safe

    std::cout << b[0] << b[1] << " (size " << b.size() << ")\n";
    return 0;
}

Disabling Copy

Some types should not be copyable — for example, types that represent unique resources like file handles, network connections, or threads. Use =delete to clearly communicate and enforce this:

``cpp
class UniqueConnection {
public:
UniqueConnection(const UniqueConnection&) = delete;
UniqueConnection& operator=(const UniqueConnection&) = delete;
// Move operations can still be enabled
};
``

Before C++11, this was done by declaring copy operations private without a definition — a confusing hack that =delete elegantly replaces.

Pitfall

A naive copy assignment that deletes the old resource before allocating the new one is broken in two ways: (1) self-assignment (a = a) deletes the data then tries to copy from it, and (2) if new throws after delete, the object is left in an invalid state. The copy-and-swap idiom solves both problems: the copy is done before any modifications, and swap is noexcept.

Best Practice

The best way to avoid copy bugs is to never manage raw resources directly. Use std::string instead of char*, std::vector instead of raw arrays, and std::unique_ptr instead of owning raw pointers. When all your members have correct copy semantics, the compiler-generated copy operations are automatically correct. This is the Rule of Zero in practice.

Key Takeaways
  • Compiler-generated copy is shallow — it copies pointer values, not pointed-to data
  • Resource-owning classes need a deep copy constructor and assignment operator
  • The copy-and-swap idiom gives you exception safety and self-assignment safety for free
  • Use =delete to explicitly forbid copying of unique-resource types
  • The best strategy is the Rule of Zero: use RAII wrappers so you never need custom copy logic

Quiz — Test Your Knowledge

(15 XP)

1. What happens when you shallow-copy an object that owns heap memory through a raw pointer?

2. Why does the copy-and-swap idiom take the parameter by value?

3. Which approach is recommended to avoid needing custom copy operations altogether?