C++ Copy Constructor — Fixing Double-Free Shallow Copy Bugs
Double-free crash on exit? Default copy constructors copy pointer addresses, not data.
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Copy constructor initialises a new object from an existing one. It defines what "copy" means for your class.
- Compiler-generated copy does member-wise (shallow) copy. Safe for ints, dangerous for raw pointers.
- Deep copy constructor allocates fresh memory and copies the data, making both objects independent.
- In production, a missing or shallow copy causes double-free crashes that only show under load.
- Performance: deep copy is O(n), move constructor is O(1). Always prefer move when ownership transfer works.
- Biggest mistake: writing a destructor but forgetting the copy constructor and copy assignment — leads to corrupted heaps.
Imagine you have a detailed blueprint for a house. When your friend wants to build the same house, they could either trace your blueprint (sharing the same paper) or make a completely fresh photocopy they can mark up independently. A copy constructor is C++'s way of making that fresh photocopy of an object — so the new one starts life as an identical twin but is completely independent. Without it, C++ might just hand your friend the original blueprint, and any changes they make would mess up your copy too.
Every serious C++ program eventually needs to duplicate objects — passing one to a function, returning one from a method, or storing one in a container. At that moment, C++ must answer a critical question: what does 'make a copy' actually mean for this specific class? For a simple integer that answer is obvious, but the moment your class owns a raw pointer, opens a file handle, or manages a network socket, a naive byte-for-byte duplicate can silently corrupt your program. That's the problem the copy constructor was designed to solve.
The copy constructor is a special member function that C++ calls whenever an object is initialised from another object of the same type. It lets you define exactly what 'copy' means for your class — whether that's a shallow mirror image, a fully independent deep clone, or something in between. Without a thoughtful copy constructor, two seemingly separate objects can end up sharing the same underlying memory, leading to double-free crashes, dangling pointers, and data corruption that only shows up at the worst possible moment.
By the end of this article you'll understand why the compiler-generated copy constructor is sometimes a ticking time bomb, how to write a correct deep-copying version, when to delete it entirely, and how to talk confidently about it in a technical interview. We'll build a realistic DocumentBuffer class step by step so every concept has immediate, tangible context.
What the Compiler Generates — and Why That's Sometimes Dangerous
If you don't write a copy constructor, the compiler generates one for you. This default version performs a member-wise copy: it copies each data member from the source object into the new object one by one. For value types like int, double, or std::string, that works perfectly because those types already know how to copy themselves correctly.
The danger arrives with raw pointers. If your class holds a char or int pointing to heap memory, a member-wise copy duplicates the pointer value — the memory address — not the data it points to. Both the original object and the new copy now point at the exact same block of heap memory. Neither object knows the other exists.
When the first one is destroyed, its destructor frees that memory. When the second one is later destroyed, its destructor tries to free memory that's already gone. That's undefined behaviour, and on most platforms it's an immediate crash.
This is called a shallow copy, and understanding it deeply is the single most important prerequisite for writing correct C++ classes that manage resources.
Writing a Deep Copy Constructor That Actually Works
A deep copy constructor solves the shared-pointer problem by allocating brand-new memory for the copy and then copying the data across — not just the address. The result is two completely independent objects that happen to hold identical data at that moment in time.
The signature of a copy constructor is always the same pattern: ClassName(const ClassName& source). The parameter is a const reference to the same type. It must be a reference — if it were passed by value, C++ would need to copy it, which would call the copy constructor again, causing infinite recursion. The const is there because copying shouldn't modify the source.
Let's fix our NaiveBuffer by renaming it DocumentBuffer and adding a proper copy constructor, copy assignment operator, and destructor — the full Rule of Three in action.
std::string, std::vector, or std::unique_ptr means the compiler-generated copy constructor does the right thing automatically — because those types already implement deep copying. Prefer this to managing raw pointers yourself unless you're writing low-level infrastructure code.std::bad_alloc exception in a constructor leaves the object partially built.std::nothrow or throw and let the caller handle it.When to Delete the Copy Constructor — and the Move Constructor Alternative
Sometimes copying an object makes no logical sense. A database connection, a file handle, or a mutex represent unique real-world resources that can't meaningfully be duplicated. If you copy a database connection object, should both copies now own the same connection? The safest answer is to make copying impossible by deleting the copy constructor.
C++11 introduced the move constructor as a complementary tool. Where a copy says 'make me an identical twin', a move says 'transfer ownership to me — the original gives up its resource'. Moves are typically $O(1)$ operations because they just swap pointers, whereas deep copies are $O(N)$.
When the Copy Constructor Gets Called — and the Traps You'll Hit
The copy constructor isn't just for explicit =. It's triggered in three common scenarios that often surprise junior engineers:
- Pass by value:
void func(MyClass obj)— every call copies the object. - Return by value:
MyClass— the return value is copied unless copy elision kicks in.create(){ MyClass tmp; return tmp; } - Container insertion:
std::vector— stores a copy.vec; vec.push_back(obj);
But here's where the traps lie: if you pass a temporary (rvalue) to a function that expects a parameter by value, the compiler may use the move constructor instead if available. If your class has only a copy constructor and not a move constructor, the copy constructor will be called even for temporaries — which is a performance hit. Also, brace initialisation (MyClass obj = {arg}) might call the copy constructor if the constructor is explicit.
Understanding when copying happens is critical for performance-critical code. Every unnecessary copy in a hot path adds O(n) time and memory pressure.
const MyClass&) instead of by value. For returning objects, rely on RVO by returning the object directly (not std::move on locals — that can inhibit elision). Use reserve() on vectors to avoid repeated copies during reallocation.std::string by value in a tight loop — every log line copied 200KB of payload.const&. Only copy when you truly need a local mutable copy.Copy Elision and Return Value Optimization (RVO) — When the Copy Doesn't Happen
The C++ standard allows compilers to elide (skip) a copy or move constructor call under specific circumstances, even if the constructor has side effects. This is called copy elision. The most common form is Return Value Optimization (RVO): when a function returns a local variable, the compiler can construct it directly into the caller's variable, skipping the copy or move.
Since C++17, guaranteed copy elision is mandatory for certain cases: when a prvalue (pure rvalue) is used to initialise an object directly. That means MyClass obj = MyClass(42); constructs obj in place without calling the copy or move constructor — period.
Understanding elision is vital because you cannot rely on side effects in copy/move constructors for correctness. The compiler may or may not call them. RVO also affects performance: if you disable it (e.g., by returning std::move(local) instead of local), you may inhibit elision and force a move that's actually slower.
Tracked obj = Tracked(); does NOT call the copy or move constructor — even if they have side effects. This is a safety guarantee for factory functions and resource handles.-fno-elide-constructors (GCC/Clang), you'll see the raw number of copies. Useful for debugging, but never rely on it in production.The Rule of Five: Expanding for Move Semantics
With C++11, the Rule of Three became the Rule of Five. If your class manages a resource, you should now consider defining:
- Destructor
- Copy constructor
- Copy assignment operator
- Move constructor
- Move assignment operator
If you define a destructor, copy constructor, or copy assignment operator, the compiler will deprecate the default move operations (they are implicitly defined, but you should explicitly define them for correctness and performance). Similarly, if you define move operations, the copy operations are implicitly deleted.
In practice, if you're following the Rule of Five, use the copy-and-swap idiom for the copy assignment operator. It provides strong exception safety and reduces code duplication. The move constructor and assignment operator can simply swap pointers.
- Each special member function defines one ownership operation: destroy, copy, move.
- Missing one creates an implicit broken assumption (e.g., no move means copies for temporaries).
- If you write none, the compiler handles it correctly only for types without raw resources.
- The copy-and-swap idiom unifies copy assignment and provides strong exception safety.
std::vector reallocation copies all elements if only copy is available — O(n) per reallocation.memcpy-like optimisations.= default where the compiler behaviour is correct.Default Copy Constructor — Why Your Pointer Just Became a Landmine
When you don't write a copy constructor, the compiler generates one for you. It does what's called a memberwise copy — it copies each member exactly as-is, bit by bit (for trivial types) or member-by-member (for classes). That sounds harmless until you have a pointer, a file handle, or a dynamically allocated buffer.
The moment both objects point to the same heap memory, you've lost. Delete one, and the other holds a dangling pointer. Write to one, and you silently corrupt the other. Junior devs call this a 'shared state bug'. Senior devs call it Tuesday afternoon.
For classes that only contain primitive types or standard containers like std::vector, the default copy constructor works fine. But anything with raw ownership of a resource? You're one innocent Object copy = original away from a double-free in production. The compiler doesn't warn you because it assumes you know what you're doing. Don't prove it wrong.
Copy Constructor vs Assignment Operator — They're Not the Same Operation, Stop Treating Them Like They Are
Both copy an object's state into another. But one creates life, the other mutates it. The copy constructor initializes a new object from an existing one. The copy assignment operator replaces the contents of an already-living object. That difference matters when you manage resources.
In a copy constructor, the target object doesn't exist yet — its members are uninitialized. So you allocate fresh. In assignment, the target already holds resources that need proper cleanup before you overwrite them. Forget to release the old buffer in operator= and you've got a leak. Use assignment logic in your copy constructor and you'll crash on uninitialized pointers.
Here's the line that trips up most devs: MyClass a = b; — that's a copy constructor, not assignment. The = is syntactic sugar for initialization, not a call to operator=. If you've written a custom assignment operator but not a copy constructor, you just copied with cleanup code aimed at a non-existent target.
Write both. Or delete both. But never assume one can stand in for the other. They're siblings, not twins.
= inside a declaration, it's a constructor call. If the variable already exists, it's assignment.Copy Elision and RVO in C++17
Copy elision is a compiler optimization that eliminates unnecessary copying or moving of objects. In C++17, guaranteed copy elision was introduced for certain cases, making it a language requirement rather than just an optimization. This means that when a prvalue (pure rvalue) is used to initialize an object of the same type, the copy or move constructor is not required to be accessible or even defined. This is particularly important for factory functions and return value optimization (RVO).
Consider a function that returns an object by value:
```cpp struct MyType { MyType() { std::cout << "Default constructor "; } MyType(const MyType&) { std::cout << "Copy constructor "; } MyType(MyType&&) { std::cout << "Move constructor "; } };
MyType createObject() { return MyType(); // prvalue }
int main() { MyType obj = createObject(); // guaranteed copy elision in C++17 } ```
In C++17, the above code will only call the default constructor once. The copy and move constructors are not invoked because the prvalue MyType() is used to directly initialize obj. Before C++17, compilers might have performed this optimization, but it was not guaranteed, and the copy/move constructor had to be accessible.
Another form of copy elision is named return value optimization (NRVO), where a named local variable is returned. NRVO is not guaranteed but is widely implemented. For example:
``cpp MyType createObject() { MyType local; return local; // NRVO may elide the copy/move } ``
In C++17, if the compiler applies NRVO, the copy/move constructor is not required to be accessible, but if it doesn't, the move constructor (or copy if move is not available) must be accessible.
Understanding copy elision is crucial for writing efficient C++ code. It allows returning large objects from functions without performance penalties, and it enables the use of types that are non-copyable and non-movable in certain contexts, as long as they are returned by value from a factory function.
However, be aware that copy elision can affect the behavior of code that relies on side effects in copy/move constructors (e.g., logging). Since the constructors may not be called, such side effects may not occur as expected.
Rule of Five: Copy, Move, Destructor, Copy/Move Assignment
The Rule of Five states that if a class defines a custom destructor, copy constructor, or copy assignment operator, it likely needs all five special member functions: destructor, copy constructor, copy assignment operator, move constructor, and move assignment operator. This rule evolved from the Rule of Three (destructor, copy constructor, copy assignment) to include move semantics introduced in C++11.
Why is this important? If a class manages a resource (e.g., dynamic memory, file handle, network connection), the default compiler-generated functions may do shallow copies, leading to double-free errors or resource leaks. By explicitly defining all five, you ensure proper resource management.
Consider a simple Buffer class that owns a dynamically allocated array:
``cpp class Buffer { int data; size_t size; public: Buffer(size_t s) : size(s), data(new int[s]) {} ~``Buffer() { delete[] data; } // Copy constructor (deep copy) Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) { std::copy(other.data, other.data + size, data); } // Copy assignment operator Buffer& operator=(const Buffer& other) { if (this != &other) { delete[] data; size = other.size; data = new int[size]; std::copy(other.data, other.data + size, data); } return this; } // Move constructor Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) { other.data = nullptr; other.size = 0; } // Move assignment operator Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data; data = other.data; size = other.size; other.data = nullptr; other.size = 0; } return *this; } };
Notice the move operations are marked noexcept, which is important for optimizations like using std::vector's reallocation (which prefers noexcept move constructors).
If you omit move operations, the compiler will use copy operations instead when moving, which can be inefficient. Conversely, if you define move operations, the compiler will not generate copy operations automatically (unless you explicitly default them).
In modern C++, it's often recommended to follow the Rule of Zero: design classes that do not manage resources directly (use RAII wrappers like std::vector, std::string, std::unique_ptr). Then the compiler-generated special members work correctly. But if you must manage resources, follow the Rule of Five.
std::exchange for Efficient Move Assignment
When implementing move assignment operators, a common pattern is to transfer ownership of resources from the source object to the destination. Using std::exchange can make this operation more efficient and exception-safe. std::exchange replaces the value of an object with a new value and returns the old value. This is particularly useful for setting the source object's pointer to null after moving.
Consider a move assignment operator for a class that manages a raw pointer:
``cpp class Resource { int data; public: Resource& operator=(Resource&& other) noexcept { if (this != &other) { delete data; // release current resource data = std::exchange(other.data, nullptr); // steal resource and set other to null } return this; } }; ``
Without std::exchange, you might write:
``cpp data = other.data; other.data = nullptr; ``
But std::exchange does both in one step, which is clearer and can prevent mistakes if the order of operations matters. Additionally, std::exchange is constexpr and noexcept in C++20, making it safe for use in noexcept functions.
Another advantage is that std::exchange works well with smart pointers. For example, moving a std::unique_ptr:
``cpp class Container { std::unique_ptr``
In this case, std::move is simpler, but std::exchange can be useful when you want to explicitly set the source to a known state.
std::exchange is also handy in copy-and-swap idiom, though that idiom is less common with move semantics. Overall, using std::exchange in move assignment operators improves readability and safety, especially when dealing with multiple resources or complex state.
However, be cautious: std::exchange is not a silver bullet. For simple cases, direct assignment and nullification are fine. But for consistency and clarity, especially in generic code, std::exchange is a valuable tool.
Double-Free Crash in Logging Pipeline After Code Refactor
char* pointer. The default copy constructor copied the pointer address, not the data. Both the original and the copy pointed to the same heap memory. When one was destroyed, it freed the memory; when the other was destroyed, it tried to free the same memory again.- Never trust the compiler-generated copy constructor when your class manages a raw resource.
- If you write a destructor, you almost certainly need a copy constructor and copy assignment operator.
- Use AddressSanitizer (
-fsanitize=address) during development to catch these bugs early.
-fsanitize=address -g) to identify the exact allocation/free mismatch. Look for 'double-free' stack traces pointing to destructors.std::cout in the destructor printing the this pointer to confirm multiple objects are freeing the same address.g++ -fsanitize=address -g -o program program.cpp./program 2>&1 | grep -A 5 'double-free'| File | Command / Code | Purpose |
|---|---|---|
| ShallowCopyDanger.cpp | namespace io::thecodeforge::examples { | What the Compiler Generates |
| DocumentBuffer.cpp | namespace io::thecodeforge::core { | Writing a Deep Copy Constructor That Actually Works |
| DatabaseConnection.cpp | namespace io::thecodeforge::db { | When to Delete the Copy Constructor |
| CopyTrigger.cpp | namespace io::thecodeforge::examples { | When the Copy Constructor Gets Called |
| CopyElisionDemo.cpp | namespace io::thecodeforge::examples { | Copy Elision and Return Value Optimization (RVO) |
| RuleOfFive.cpp | namespace io::thecodeforge::core { | The Rule of Five |
| ShallowCopyTrap.cpp | class Buffer { | Default Copy Constructor |
| CopyVsAssign.cpp | class Config { | Copy Constructor vs Assignment Operator |
| copy_elision.cpp | struct MyType { | Copy Elision and RVO in C++17 |
| rule_of_five.cpp | class Buffer { | Rule of Five |
| exchange_move.cpp | class Resource { | std |
Key takeaways
= delete to make a class non-copyable when copying makes no logical sense (file handles, sockets, threads). Pair it with a move constructor to still allow efficient ownership transfer.Interview Questions on This Topic
Explain the 'Copy-and-Swap' idiom. How does it simplify the implementation of the copy assignment operator while providing strong exception safety?
this with the parameter. This provides strong exception safety because if the copy construction throws, this remains unchanged. After the swap, the destructor of the parameter cleans up the old resources. It also handles self-assignment correctly without an explicit guard.Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
That's C++ Basics. Mark it forged?
9 min read · try the examples if you haven't