C++ Copy Constructor — Fixing Double-Free Shallow Copy Bugs
Double-free crash on exit? Default copy constructors copy pointer addresses, not data.
- 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<MyClass> vec; vec.push_back(obj);— stores a copy.
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.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.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.Common mistakes to avoid
4 patternsForgetting the copy assignment operator after writing a copy constructor
buffer2 = buffer1; still performs a shallow copy via the compiler-generated assignment operator, causing a double-free when both go out of scope.Passing a copy constructor parameter by value instead of by reference
DocumentBuffer(DocumentBuffer source) instead of DocumentBuffer(const DocumentBuffer& source) causes infinite recursion: to pass source by value, C++ must copy it, which calls the copy constructor, which tries to copy source again, and so on until the stack overflows.const ClassName&. The compiler will actually error on this in many cases, but understanding why the rule exists matters.Not handling self-assignment in the copy assignment operator
archive = archive; is legal C++ and must work correctly. Without a self-assignment guard (if (this == &source) return *this;), the operator frees the old memory with delete[] content first, then tries to copy from source.content — which is now dangling memory that was just freed.if (this == &source) return *this; guard at the very top of your copy assignment operator before touching any memory.Assuming the compiler-generated copy constructor is safe when class uses smart pointers
std::unique_ptr enforce unique ownership, but the compiler-generated copy constructor tries to copy them — which is deleted. The code won't compile. With std::shared_ptr, the copy is shallow (reference count increase) which may not be intended.std::unique_ptr, explicitly delete or implement a custom copy (deep copy) if needed. For std::shared_ptr, understand that copies share the same managed object — if you need a deep copy, you must implement it manually.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
That's C++ Basics. Mark it forged?
5 min read · try the examples if you haven't