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.
#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.
#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.
#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.
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.
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.
- 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
=deleteto 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?